@cabane/companion 0.5.0 → 0.6.0
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/README.md +2 -2
- package/dist/cli.js +1667 -570
- package/dist/pairing-config.js +38 -33
- package/dist/runtime.js +1547 -477
- package/dist/static/app.js +22 -19
- package/dist/static/index.html +6 -6
- package/dist/static/styles.css +1 -1
- package/package.json +2 -3
package/dist/runtime.js
CHANGED
|
@@ -16,13 +16,13 @@ import { dirname, join } from "path";
|
|
|
16
16
|
import { z as z3 } from "zod";
|
|
17
17
|
|
|
18
18
|
// src/errors.ts
|
|
19
|
-
var
|
|
19
|
+
var CompanionError = class extends Error {
|
|
20
20
|
constructor(message) {
|
|
21
21
|
super(message);
|
|
22
|
-
this.name = "
|
|
22
|
+
this.name = "CompanionError";
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
|
-
var ApiError = class extends
|
|
25
|
+
var ApiError = class extends CompanionError {
|
|
26
26
|
constructor(status, message, body) {
|
|
27
27
|
super(message);
|
|
28
28
|
this.status = status;
|
|
@@ -32,13 +32,13 @@ var ApiError = class extends BridgeError {
|
|
|
32
32
|
status;
|
|
33
33
|
body;
|
|
34
34
|
};
|
|
35
|
-
var ConfigError = class extends
|
|
35
|
+
var ConfigError = class extends CompanionError {
|
|
36
36
|
constructor(message) {
|
|
37
37
|
super(message);
|
|
38
38
|
this.name = "ConfigError";
|
|
39
39
|
}
|
|
40
40
|
};
|
|
41
|
-
var PrepareHookError = class extends
|
|
41
|
+
var PrepareHookError = class extends CompanionError {
|
|
42
42
|
constructor(message) {
|
|
43
43
|
super(message);
|
|
44
44
|
this.name = "PrepareHookError";
|
|
@@ -69,12 +69,12 @@ var pairingSchema = z.object({
|
|
|
69
69
|
baseUrl: z.string().url().refine(isAllowedBaseUrl, {
|
|
70
70
|
message: "baseUrl must be https (loopback http is allowed for local dev only)"
|
|
71
71
|
}),
|
|
72
|
-
// The `cabdev_` device token plaintext — the
|
|
72
|
+
// The `cabdev_` device token plaintext — the companion's one durable credential.
|
|
73
73
|
deviceToken: z.string().min(1).startsWith(DEVICE_TOKEN_PREFIX, {
|
|
74
74
|
message: 'deviceToken must be a cabane device token (starts with "cabdev_")'
|
|
75
75
|
}),
|
|
76
76
|
// Optional identity hints the app may include for nicer local display. The
|
|
77
|
-
//
|
|
77
|
+
// companion also learns these from the first assignments pull, so they're not
|
|
78
78
|
// required.
|
|
79
79
|
deviceId: z.string().min(1).optional(),
|
|
80
80
|
deviceLabel: z.string().min(1).optional()
|
|
@@ -90,13 +90,18 @@ var prepareHookSchema = z2.object({
|
|
|
90
90
|
env: z2.record(z2.string(), z2.string()).optional(),
|
|
91
91
|
// Wall-clock cap for the hook. Provisioning is slow (minutes), so the
|
|
92
92
|
// default is generous; a hook that hangs past this is killed and the turn
|
|
93
|
-
// fails with a clear timeout message rather than pinning the
|
|
93
|
+
// fails with a clear timeout message rather than pinning the companion.
|
|
94
94
|
timeoutMs: z2.number().int().positive().optional()
|
|
95
95
|
}).strict();
|
|
96
96
|
var DEFAULT_TIMEOUT_MS = 10 * 6e4;
|
|
97
97
|
var prepareResultSchema = z2.object({
|
|
98
98
|
cwd: z2.string().min(1),
|
|
99
|
-
env: z2.record(z2.string(), z2.string()).optional()
|
|
99
|
+
env: z2.record(z2.string(), z2.string()).optional(),
|
|
100
|
+
nativeWorkAssignment: z2.object({
|
|
101
|
+
itemId: z2.string(),
|
|
102
|
+
executionId: z2.string(),
|
|
103
|
+
activationEpoch: z2.number().int().nonnegative()
|
|
104
|
+
}).strict().optional()
|
|
100
105
|
});
|
|
101
106
|
function parsePrepareOutput(stdout) {
|
|
102
107
|
const last = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).at(-1);
|
|
@@ -115,10 +120,14 @@ function parsePrepareOutput(stdout) {
|
|
|
115
120
|
const r = prepareResultSchema.safeParse(parsed);
|
|
116
121
|
if (!r.success) {
|
|
117
122
|
throw new PrepareHookError(
|
|
118
|
-
'prepare hook JSON must carry a non-empty string "cwd" (
|
|
123
|
+
'prepare hook JSON must carry a non-empty string "cwd" (plus optional "env" or "nativeWorkAssignment")'
|
|
119
124
|
);
|
|
120
125
|
}
|
|
121
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
cwd: r.data.cwd,
|
|
128
|
+
...r.data.env ? { env: r.data.env } : {},
|
|
129
|
+
...r.data.nativeWorkAssignment ? { nativeWorkAssignment: r.data.nativeWorkAssignment } : {}
|
|
130
|
+
};
|
|
122
131
|
}
|
|
123
132
|
return { cwd: last };
|
|
124
133
|
}
|
|
@@ -143,6 +152,7 @@ var runPrepareHook = (hook, input) => {
|
|
|
143
152
|
CABANE_CONVERSATION_ID: input.conversationId,
|
|
144
153
|
CABANE_AGENT_ID: input.agentId,
|
|
145
154
|
CABANE_AGENT_USERNAME: input.agentUsername,
|
|
155
|
+
CABANE_RUNTIME: input.runtime ?? "",
|
|
146
156
|
// CT319: the conversation anchor is gone. These are kept as DEPRECATED
|
|
147
157
|
// back-compat constants so an old user prepare script that still reads
|
|
148
158
|
// them doesn't crash; a hook should key off CABANE_TRIGGER_ENTRY_PATHS.
|
|
@@ -154,10 +164,10 @@ var runPrepareHook = (hook, input) => {
|
|
|
154
164
|
CABANE_CONVERSATION_TITLE: input.title ?? ""
|
|
155
165
|
}
|
|
156
166
|
});
|
|
157
|
-
} catch (
|
|
167
|
+
} catch (err2) {
|
|
158
168
|
reject(
|
|
159
169
|
new PrepareHookError(
|
|
160
|
-
`prepare hook failed to start: ${
|
|
170
|
+
`prepare hook failed to start: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
161
171
|
)
|
|
162
172
|
);
|
|
163
173
|
return;
|
|
@@ -177,8 +187,8 @@ var runPrepareHook = (hook, input) => {
|
|
|
177
187
|
child.stderr?.on("data", (d) => {
|
|
178
188
|
stderr += d.toString();
|
|
179
189
|
});
|
|
180
|
-
child.on("error", (
|
|
181
|
-
finish(() => reject(new PrepareHookError(`prepare hook failed to start: ${
|
|
190
|
+
child.on("error", (err2) => {
|
|
191
|
+
finish(() => reject(new PrepareHookError(`prepare hook failed to start: ${err2.message}`)));
|
|
182
192
|
});
|
|
183
193
|
child.on("close", (code) => {
|
|
184
194
|
finish(() => {
|
|
@@ -191,8 +201,8 @@ var runPrepareHook = (hook, input) => {
|
|
|
191
201
|
}
|
|
192
202
|
try {
|
|
193
203
|
resolve(parsePrepareOutput(stdout));
|
|
194
|
-
} catch (
|
|
195
|
-
reject(
|
|
204
|
+
} catch (err2) {
|
|
205
|
+
reject(err2 instanceof PrepareHookError ? err2 : new PrepareHookError(String(err2)));
|
|
196
206
|
}
|
|
197
207
|
});
|
|
198
208
|
});
|
|
@@ -217,12 +227,12 @@ function realAccountHome() {
|
|
|
217
227
|
return realHomeCache;
|
|
218
228
|
}
|
|
219
229
|
function cabaneDir() {
|
|
220
|
-
const dir2 = join(process.env.
|
|
230
|
+
const dir2 = join(process.env.CABANE_COMPANION_HOME || homedir(), ".cabane");
|
|
221
231
|
if (process.env.VITEST) {
|
|
222
232
|
const real = realAccountHome();
|
|
223
233
|
if (real && dir2 === join(real, ".cabane")) {
|
|
224
234
|
throw new Error(
|
|
225
|
-
`cabaneDir() resolved to the real ${dir2} during a test run.
|
|
235
|
+
`cabaneDir() resolved to the real ${dir2} during a test run. Companion tests must swap process.env.HOME to a tmp dir before touching the config dir; this guard prevents wiping the operator's real companion config (see apps/companion/test/setup-home.ts).`
|
|
226
236
|
);
|
|
227
237
|
}
|
|
228
238
|
}
|
|
@@ -235,22 +245,22 @@ var localAgentConfigSchema = z3.object({
|
|
|
235
245
|
cwd: z3.string().optional(),
|
|
236
246
|
prepareHook: prepareHookSchema.optional(),
|
|
237
247
|
// CT289: the Claude Code auto-memory escape hatch. Auto-memory is forced OFF
|
|
238
|
-
// by default on every
|
|
239
|
-
//
|
|
240
|
-
// On a
|
|
248
|
+
// by default on every companion (memory belongs in the Cabane workspace, and a
|
|
249
|
+
// shared companion would otherwise pool one cwd-keyed memory dir across users).
|
|
250
|
+
// On a companion you run yourself, set `claudeCode: { autoMemory: true }` to hand
|
|
241
251
|
// auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
|
|
242
252
|
// injecting the off switch and your normal Claude Code memory workflow applies
|
|
243
253
|
// (in coding mode, where the checkout's project settings are read).
|
|
244
254
|
claudeCode: z3.object({ autoMemory: z3.boolean().optional() }).strict().optional()
|
|
245
255
|
}).strict();
|
|
246
|
-
var
|
|
256
|
+
var companionConfigSchema = z3.object({
|
|
247
257
|
// The cabane instance this device is paired with. SJ515: https-enforced
|
|
248
258
|
// (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
|
|
249
259
|
// base URL onto the MITM-able channel the device token + prompt ride.
|
|
250
260
|
baseUrl: z3.string().url().refine(isAllowedBaseUrl, {
|
|
251
261
|
message: "baseUrl must be https (loopback http is allowed for local dev only)"
|
|
252
262
|
}),
|
|
253
|
-
// The `cabdev_` device token plaintext — the
|
|
263
|
+
// The `cabdev_` device token plaintext — the companion's one durable credential,
|
|
254
264
|
// presented as `Authorization: Bearer <token>` on the device-facing pull +
|
|
255
265
|
// heartbeat endpoints. Optional so `cabane-companion logout` can strip it (a
|
|
256
266
|
// "paired but logged out" state the supervisor refuses to run) while keeping
|
|
@@ -261,19 +271,19 @@ var bridgeConfigSchema = z3.object({
|
|
|
261
271
|
deviceId: z3.string().optional(),
|
|
262
272
|
deviceLabel: z3.string().optional(),
|
|
263
273
|
// Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
|
|
264
|
-
// agentId / username / `slug/username`. Hand-added by the operator; the
|
|
274
|
+
// agentId / username / `slug/username`. Hand-added by the operator; the companion
|
|
265
275
|
// never writes this (it only persists credentials + the device, elsewhere).
|
|
266
276
|
agents: z3.record(z3.string(), localAgentConfigSchema).optional(),
|
|
267
277
|
// Dashboard settings (all optional). dashboardPort: preferred bind port (next
|
|
268
278
|
// free one if taken); autoOpen: whether `start` opens the browser (the
|
|
269
|
-
// `--no-open` flag / `
|
|
279
|
+
// `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
|
|
270
280
|
// level, live-editable from the dashboard settings panel.
|
|
271
281
|
dashboardPort: z3.number().int().min(1).max(65535).optional(),
|
|
272
282
|
autoOpen: z3.boolean().optional(),
|
|
273
283
|
logLevel: z3.enum(["warn", "info", "debug"]).optional(),
|
|
274
284
|
// CT270: the opencode runtime, when the operator runs one on this machine. The
|
|
275
285
|
// operator installs opencode, starts `opencode serve` (auth via opencode's own
|
|
276
|
-
// `/connect` — Cabane never sees provider keys), and points the
|
|
286
|
+
// `/connect` — Cabane never sees provider keys), and points the companion at it
|
|
277
287
|
// here. Setting this makes the device advertise the `opencode` runtime on its
|
|
278
288
|
// heartbeat manifest (so the server offers DeepSeek/opencode models here and
|
|
279
289
|
// routes those turns to this device) AND registers the opencode adapter in the
|
|
@@ -314,21 +324,21 @@ function loadConfig() {
|
|
|
314
324
|
let raw;
|
|
315
325
|
try {
|
|
316
326
|
raw = readFileSync(path3, "utf8");
|
|
317
|
-
} catch (
|
|
327
|
+
} catch (err2) {
|
|
318
328
|
throw new ConfigError(
|
|
319
|
-
`couldn't read ${path3}: ${
|
|
329
|
+
`couldn't read ${path3}: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
320
330
|
);
|
|
321
331
|
}
|
|
322
332
|
if (raw.trim().length === 0) return null;
|
|
323
333
|
let parsed;
|
|
324
334
|
try {
|
|
325
335
|
parsed = JSON.parse(raw);
|
|
326
|
-
} catch (
|
|
336
|
+
} catch (err2) {
|
|
327
337
|
throw new ConfigError(
|
|
328
|
-
`${path3} is not valid JSON: ${
|
|
338
|
+
`${path3} is not valid JSON: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
329
339
|
);
|
|
330
340
|
}
|
|
331
|
-
const result =
|
|
341
|
+
const result = companionConfigSchema.safeParse(parsed);
|
|
332
342
|
if (!result.success) {
|
|
333
343
|
const agentIssue = result.error.issues.find((i) => i.path[0] === "agents");
|
|
334
344
|
if (agentIssue) {
|
|
@@ -337,7 +347,7 @@ function loadConfig() {
|
|
|
337
347
|
);
|
|
338
348
|
}
|
|
339
349
|
throw new ConfigError(
|
|
340
|
-
`${path3} is from an incompatible or older version of the
|
|
350
|
+
`${path3} is from an incompatible or older version of the companion, or was hand-edited. Run \`cabane-companion pair\` to re-pair, or \`cabane-companion logout --purge\` to reset.`
|
|
341
351
|
);
|
|
342
352
|
}
|
|
343
353
|
return result.data;
|
|
@@ -357,19 +367,19 @@ function saveConfig(cfg) {
|
|
|
357
367
|
} catch {
|
|
358
368
|
}
|
|
359
369
|
renameSync(tmp, path3);
|
|
360
|
-
} catch (
|
|
370
|
+
} catch (err2) {
|
|
361
371
|
try {
|
|
362
372
|
rmSync(tmp, { force: true });
|
|
363
373
|
} catch {
|
|
364
374
|
}
|
|
365
|
-
throw
|
|
375
|
+
throw err2;
|
|
366
376
|
}
|
|
367
377
|
}
|
|
368
378
|
function requireConfig() {
|
|
369
379
|
const cfg = loadConfig();
|
|
370
380
|
if (!cfg) {
|
|
371
381
|
throw new ConfigError(
|
|
372
|
-
"this
|
|
382
|
+
"this companion is not paired. Run `cabane-companion pair` \u2014 it shows a short code \u2014 then enter that code in the cabane app (Settings \u2192 Companions) to connect this machine."
|
|
373
383
|
);
|
|
374
384
|
}
|
|
375
385
|
return cfg;
|
|
@@ -392,8 +402,8 @@ import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
|
|
|
392
402
|
import { dirname as dirname2, join as join2 } from "path";
|
|
393
403
|
import pino from "pino";
|
|
394
404
|
import pretty from "pino-pretty";
|
|
395
|
-
function
|
|
396
|
-
return join2(cabaneDir(), "
|
|
405
|
+
function companionLogPath() {
|
|
406
|
+
return join2(cabaneDir(), "companion.log");
|
|
397
407
|
}
|
|
398
408
|
var CONSOLE_IGNORE = [
|
|
399
409
|
"pid",
|
|
@@ -403,7 +413,7 @@ var CONSOLE_IGNORE = [
|
|
|
403
413
|
"agentId",
|
|
404
414
|
"messageId",
|
|
405
415
|
"sessionId",
|
|
406
|
-
"
|
|
416
|
+
"companionId"
|
|
407
417
|
].join(",");
|
|
408
418
|
function consoleShortId(log) {
|
|
409
419
|
const id = log.conversationId ?? log.workspaceId;
|
|
@@ -417,10 +427,10 @@ function consoleMessageFormat(log, messageKey) {
|
|
|
417
427
|
var cached = null;
|
|
418
428
|
function getLogger() {
|
|
419
429
|
if (cached) return cached;
|
|
420
|
-
const path3 =
|
|
430
|
+
const path3 = companionLogPath();
|
|
421
431
|
mkdirSync2(dirname2(path3), { recursive: true });
|
|
422
432
|
const streams = [];
|
|
423
|
-
if (process.env.
|
|
433
|
+
if (process.env.CABANE_COMPANION_DAEMON !== "1") {
|
|
424
434
|
const consoleStream = pretty({
|
|
425
435
|
colorize: true,
|
|
426
436
|
ignore: CONSOLE_IGNORE,
|
|
@@ -452,7 +462,7 @@ var MAX_DISPATCHES = 50;
|
|
|
452
462
|
function today() {
|
|
453
463
|
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
454
464
|
}
|
|
455
|
-
var
|
|
465
|
+
var CompanionStateHub = class {
|
|
456
466
|
constructor(opts) {
|
|
457
467
|
this.opts = opts;
|
|
458
468
|
this.emitter.setMaxListeners(0);
|
|
@@ -466,7 +476,7 @@ var BridgeStateHub = class {
|
|
|
466
476
|
deviceLabel = null;
|
|
467
477
|
deviceError = null;
|
|
468
478
|
// CT586: the latest harness snapshot the supervisor probed, or null before the
|
|
469
|
-
// first probe (see
|
|
479
|
+
// first probe (see CompanionStatusJson.harnesses).
|
|
470
480
|
harnesses = null;
|
|
471
481
|
// ---- subscription (SSE) ----
|
|
472
482
|
on(listener) {
|
|
@@ -658,7 +668,7 @@ var BridgeStateHub = class {
|
|
|
658
668
|
})),
|
|
659
669
|
last_event_at_overall: lastOverall,
|
|
660
670
|
dashboard_url: this.dashboardUrl,
|
|
661
|
-
|
|
671
|
+
companion_version: this.opts.companionVersion,
|
|
662
672
|
instance_id: this.opts.instanceId ?? null,
|
|
663
673
|
harnesses: this.harnesses
|
|
664
674
|
};
|
|
@@ -699,7 +709,7 @@ function registerRoutes(app, deps) {
|
|
|
699
709
|
});
|
|
700
710
|
app.get("/api/logs", async (c) => {
|
|
701
711
|
const lines = clampLimit(c.req.query("lines"), 200, 1e3);
|
|
702
|
-
return c.json({ lines: tailFile(
|
|
712
|
+
return c.json({ lines: tailFile(companionLogPath(), lines) });
|
|
703
713
|
});
|
|
704
714
|
app.post("/api/settings", async (c) => {
|
|
705
715
|
const body = await readJson(c);
|
|
@@ -835,15 +845,15 @@ var DEFAULT_PORT = 7474;
|
|
|
835
845
|
var PORT_FALLBACK_SPAN = 10;
|
|
836
846
|
function buildDashboardApp(deps) {
|
|
837
847
|
const app = new Hono();
|
|
838
|
-
app.onError((
|
|
839
|
-
if (
|
|
840
|
-
const status =
|
|
841
|
-
return c.json({ error:
|
|
848
|
+
app.onError((err2, c) => {
|
|
849
|
+
if (err2 instanceof ApiError) {
|
|
850
|
+
const status = err2.status >= 400 && err2.status < 600 ? err2.status : 502;
|
|
851
|
+
return c.json({ error: err2.message }, status);
|
|
842
852
|
}
|
|
843
|
-
if (
|
|
844
|
-
return c.json({ error:
|
|
853
|
+
if (err2 instanceof CompanionError) {
|
|
854
|
+
return c.json({ error: err2.message }, 400);
|
|
845
855
|
}
|
|
846
|
-
return c.json({ error:
|
|
856
|
+
return c.json({ error: err2 instanceof Error ? err2.message : "internal error" }, 500);
|
|
847
857
|
});
|
|
848
858
|
registerRoutes(app, { ...deps, staticDir: resolveStaticDir() });
|
|
849
859
|
return app;
|
|
@@ -864,15 +874,15 @@ async function startDashboard(opts) {
|
|
|
864
874
|
server.closeAllConnections?.();
|
|
865
875
|
})
|
|
866
876
|
};
|
|
867
|
-
} catch (
|
|
868
|
-
if (isAddrInUse(
|
|
869
|
-
lastErr =
|
|
877
|
+
} catch (err2) {
|
|
878
|
+
if (isAddrInUse(err2)) {
|
|
879
|
+
lastErr = err2;
|
|
870
880
|
continue;
|
|
871
881
|
}
|
|
872
|
-
throw
|
|
882
|
+
throw err2;
|
|
873
883
|
}
|
|
874
884
|
}
|
|
875
|
-
throw new
|
|
885
|
+
throw new CompanionError(
|
|
876
886
|
`couldn't bind the dashboard to any port in ${preferred}\u2013${preferred + PORT_FALLBACK_SPAN - 1} (all in use). Free one up or pass --port. (last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)})`
|
|
877
887
|
);
|
|
878
888
|
}
|
|
@@ -885,16 +895,16 @@ function listen(app, port) {
|
|
|
885
895
|
resolve(server);
|
|
886
896
|
}
|
|
887
897
|
});
|
|
888
|
-
server.on("error", (
|
|
898
|
+
server.on("error", (err2) => {
|
|
889
899
|
if (!settled) {
|
|
890
900
|
settled = true;
|
|
891
|
-
reject(
|
|
901
|
+
reject(err2);
|
|
892
902
|
}
|
|
893
903
|
});
|
|
894
904
|
});
|
|
895
905
|
}
|
|
896
|
-
function isAddrInUse(
|
|
897
|
-
return Boolean(
|
|
906
|
+
function isAddrInUse(err2) {
|
|
907
|
+
return Boolean(err2 && typeof err2 === "object" && "code" in err2 && err2.code === "EADDRINUSE");
|
|
898
908
|
}
|
|
899
909
|
function resolveStaticDir() {
|
|
900
910
|
return join4(dirname3(fileURLToPath(import.meta.url)), "static");
|
|
@@ -1018,8 +1028,8 @@ async function ensureRuntimeAvailable(cfg, deps = {}) {
|
|
|
1018
1028
|
);
|
|
1019
1029
|
return;
|
|
1020
1030
|
}
|
|
1021
|
-
throw new
|
|
1022
|
-
"Claude Code, the
|
|
1031
|
+
throw new CompanionError(
|
|
1032
|
+
"Claude Code, the companion\u2019s default runtime, is not on your PATH. Install it with `npm i -g @anthropic-ai/claude-code`, log in (`claude` then follow the prompts), and run `cabane-companion start` again. The default runtime uses your local Claude Code subscription to run each agent turn; opencode is supported as an alternate runtime you configure per device (see the companion README)."
|
|
1023
1033
|
);
|
|
1024
1034
|
}
|
|
1025
1035
|
|
|
@@ -1091,8 +1101,8 @@ async function verifyRuntime(state, fetchImpl = fetch) {
|
|
|
1091
1101
|
let res;
|
|
1092
1102
|
try {
|
|
1093
1103
|
res = await fetchImpl(url, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
1094
|
-
} catch (
|
|
1095
|
-
return isConnRefused(
|
|
1104
|
+
} catch (err2) {
|
|
1105
|
+
return isConnRefused(err2) ? "stale" : "unknown";
|
|
1096
1106
|
}
|
|
1097
1107
|
if (!res.ok) return "unknown";
|
|
1098
1108
|
let body;
|
|
@@ -1104,9 +1114,9 @@ async function verifyRuntime(state, fetchImpl = fetch) {
|
|
|
1104
1114
|
if (typeof body.instance_id !== "string") return "unknown";
|
|
1105
1115
|
return body.instance_id === state.instanceId ? "ours" : "stale";
|
|
1106
1116
|
}
|
|
1107
|
-
function isConnRefused(
|
|
1108
|
-
if (!
|
|
1109
|
-
const cause =
|
|
1117
|
+
function isConnRefused(err2) {
|
|
1118
|
+
if (!err2 || typeof err2 !== "object") return false;
|
|
1119
|
+
const cause = err2.cause;
|
|
1110
1120
|
return !!cause && typeof cause === "object" && cause.code === "ECONNREFUSED";
|
|
1111
1121
|
}
|
|
1112
1122
|
function trimSlash(s) {
|
|
@@ -1169,10 +1179,10 @@ var CabaneApi = class {
|
|
|
1169
1179
|
for (let attempt = 1; ; attempt++) {
|
|
1170
1180
|
try {
|
|
1171
1181
|
return await this.attempt(method, path3, body, signal);
|
|
1172
|
-
} catch (
|
|
1173
|
-
if (attempt >= maxAttempts || signal?.aborted || !isRetryable(
|
|
1182
|
+
} catch (err2) {
|
|
1183
|
+
if (attempt >= maxAttempts || signal?.aborted || !isRetryable(err2)) throw err2;
|
|
1174
1184
|
await sleep(RETRY_BACKOFF_MS[attempt - 1], signal);
|
|
1175
|
-
if (signal?.aborted) throw
|
|
1185
|
+
if (signal?.aborted) throw err2;
|
|
1176
1186
|
}
|
|
1177
1187
|
}
|
|
1178
1188
|
}
|
|
@@ -1200,15 +1210,15 @@ var CabaneApi = class {
|
|
|
1200
1210
|
retry: true,
|
|
1201
1211
|
...signal ? { signal } : {}
|
|
1202
1212
|
});
|
|
1203
|
-
} catch (
|
|
1213
|
+
} catch (err2) {
|
|
1204
1214
|
const outbox = this.opts.outbox;
|
|
1205
|
-
if (!outbox) throw
|
|
1206
|
-
if (signal?.aborted || isAbortError(
|
|
1207
|
-
if (!isRetryable(
|
|
1215
|
+
if (!outbox) throw err2;
|
|
1216
|
+
if (signal?.aborted || isAbortError(err2)) throw err2;
|
|
1217
|
+
if (!isRetryable(err2)) throw err2;
|
|
1208
1218
|
outbox.persist({ enqueuedAt: Date.now(), turnId, seq, method: "POST", path: path3, body, kind });
|
|
1209
1219
|
this.opts.log?.warn(
|
|
1210
|
-
{ kind, turnId, seq, err:
|
|
1211
|
-
"
|
|
1220
|
+
{ kind, turnId, seq, err: err2 instanceof Error ? err2.message : String(err2) },
|
|
1221
|
+
"companion: commit queued to outbox after transient failure (will drain when the API returns)"
|
|
1212
1222
|
);
|
|
1213
1223
|
}
|
|
1214
1224
|
}
|
|
@@ -1231,11 +1241,11 @@ var CabaneApi = class {
|
|
|
1231
1241
|
await this.request(entry.method, entry.path, entry.body, { retry: true });
|
|
1232
1242
|
outbox.remove(entry.turnId, entry.seq);
|
|
1233
1243
|
progressed = true;
|
|
1234
|
-
} catch (
|
|
1235
|
-
if (
|
|
1244
|
+
} catch (err2) {
|
|
1245
|
+
if (err2 instanceof ApiError && err2.status >= 400 && err2.status < 500) {
|
|
1236
1246
|
this.opts.log?.warn(
|
|
1237
|
-
{ kind: entry.kind, turnId: entry.turnId, seq: entry.seq, status:
|
|
1238
|
-
"
|
|
1247
|
+
{ kind: entry.kind, turnId: entry.turnId, seq: entry.seq, status: err2.status },
|
|
1248
|
+
"companion outbox: discarding entry on terminal 4xx (will never land)"
|
|
1239
1249
|
);
|
|
1240
1250
|
outbox.remove(entry.turnId, entry.seq);
|
|
1241
1251
|
progressed = true;
|
|
@@ -1262,7 +1272,7 @@ var CabaneApi = class {
|
|
|
1262
1272
|
// the machinery the `sub_agent` turn-control tool is sugar over. Two things make
|
|
1263
1273
|
// it distinct from an ordinary `request` call, so it does its own `fetch`:
|
|
1264
1274
|
// - a PER-CALL bearer — the turn's OBO token when the API minted one, else the
|
|
1265
|
-
//
|
|
1275
|
+
// companion PAT — so the spawn carries the same authority as the agent's other
|
|
1266
1276
|
// cabane calls this turn (mirrors build-options' `turnToken ?? agentPat`);
|
|
1267
1277
|
// - the `x-cabane-active-conversation` header naming the caller's turn, which
|
|
1268
1278
|
// the server verifies against the live run to resolve the caller pair for the
|
|
@@ -1295,15 +1305,15 @@ var CabaneApi = class {
|
|
|
1295
1305
|
}
|
|
1296
1306
|
return { status: res.status, body: parsed };
|
|
1297
1307
|
}
|
|
1298
|
-
// SJ383:
|
|
1308
|
+
// SJ383: companion-only participant ops. All three authenticate with the
|
|
1299
1309
|
// workspace's agent-bound PAT (passed as `token` on this client) — never
|
|
1300
1310
|
// the user PAT.
|
|
1301
|
-
// Recovery-path read: returns the
|
|
1311
|
+
// Recovery-path read: returns the companion's `agentSessionId` for this
|
|
1302
1312
|
// conversation so the dispatcher can pass it as `Options.resume`, plus
|
|
1303
|
-
// the current `activeRunStartedAt` so a freshly-reconnected
|
|
1313
|
+
// the current `activeRunStartedAt` so a freshly-reconnected companion can
|
|
1304
1314
|
// see whether a prior run is still flagged in-flight.
|
|
1305
1315
|
// SJ383: recovery-path read for a participant row — the `agentSessionId` a
|
|
1306
|
-
// freshly-reconnected
|
|
1316
|
+
// freshly-reconnected companion resumes on. CT262: the per-turn piggybacks this
|
|
1307
1317
|
// fetch grew (agentRules / visionBlocks / channel / members / conversationContext)
|
|
1308
1318
|
// are gone — `getTurnContext` composes the whole turn now — so this is back to
|
|
1309
1319
|
// the plain recovery read, with no `messageId` param.
|
|
@@ -1313,17 +1323,37 @@ var CabaneApi = class {
|
|
|
1313
1323
|
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}`
|
|
1314
1324
|
);
|
|
1315
1325
|
}
|
|
1316
|
-
// CT262: the ONE turn-context fetch. Collapses the
|
|
1326
|
+
// CT262: the ONE turn-context fetch. Collapses the companion's old four-fetch
|
|
1317
1327
|
// choreography (getConversation + getMessage + getAgentSelf + getParticipantAgent)
|
|
1318
1328
|
// into a single call: the server composes the whole server portion of the
|
|
1319
1329
|
// `TurnRequest` — systemPrompt, per-turn prompt + vision content, effective
|
|
1320
1330
|
// run-config, `HostPolicy`, prior session, the user MCP definitions to resolve
|
|
1321
1331
|
// locally, plus the anchor/title + trigger-message summary the host needs.
|
|
1322
1332
|
// Agent-PAT authed; the workspace is implied by the PAT.
|
|
1323
|
-
|
|
1324
|
-
|
|
1333
|
+
// CT714: `turnId` is the host-minted id for THIS turn, passed so the server can
|
|
1334
|
+
// bind the minted turn token to it — the turn-control surface then rejects a
|
|
1335
|
+
// token whose turn has ended. The dispatcher mints it before this call and
|
|
1336
|
+
// reuses the same value on its active-run PATCH, so the token's turn id and the
|
|
1337
|
+
// pair's `active_turn_id` agree.
|
|
1338
|
+
getTurnContext(conversationId, messageId2, turnId) {
|
|
1339
|
+
const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
|
|
1325
1340
|
return this.request("GET", `/api/agent/turn-context?${q}`);
|
|
1326
1341
|
}
|
|
1342
|
+
// CT714: read a turn's recorded turn-control intent (ask/wake/summon/skip). An
|
|
1343
|
+
// EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
|
|
1344
|
+
// `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
|
|
1345
|
+
// in-memory closures, so the dispatcher fetches this once at settle — by
|
|
1346
|
+
// `turnId` — and populates those closures, letting the unchanged settle path
|
|
1347
|
+
// materialize the effects identically to claude-code. Agent-PAT authed +
|
|
1348
|
+
// self-scoped (`:agentId` must match the PAT's agent). A turn that recorded no
|
|
1349
|
+
// control verb returns all-empty fields.
|
|
1350
|
+
getTurnIntent(workspaceId, conversationId, agentId, turnId) {
|
|
1351
|
+
const q = `turnId=${encodeURIComponent(turnId)}`;
|
|
1352
|
+
return this.request(
|
|
1353
|
+
"GET",
|
|
1354
|
+
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-intent?${q}`
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1327
1357
|
// Flips `active_run_started_at` and optionally captures the SDK session
|
|
1328
1358
|
// id. The dispatcher hits this twice per turn (now() before the SDK
|
|
1329
1359
|
// loop; null when it settles) plus once with the session id on the
|
|
@@ -1336,7 +1366,7 @@ var CabaneApi = class {
|
|
|
1336
1366
|
// `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
|
|
1337
1367
|
// left best-effort: it's lower-stakes and self-heals on the next turn, so it
|
|
1338
1368
|
// stays a single-shot PATCH and is deliberately out of CT93's scope.
|
|
1339
|
-
|
|
1369
|
+
setActiveRun(workspaceId, conversationId, agentId, body) {
|
|
1340
1370
|
const path3 = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
|
|
1341
1371
|
const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
|
|
1342
1372
|
if (touchesFlag && this.opts.outbox) {
|
|
@@ -1362,11 +1392,11 @@ var CabaneApi = class {
|
|
|
1362
1392
|
try {
|
|
1363
1393
|
await this.request("PATCH", path3, body, { retry: true });
|
|
1364
1394
|
outbox?.remove(key, ACTIVE_RUN_OUTBOX_SEQ);
|
|
1365
|
-
} catch (
|
|
1366
|
-
if (!outbox) throw
|
|
1367
|
-
if (!isRetryable(
|
|
1395
|
+
} catch (err2) {
|
|
1396
|
+
if (!outbox) throw err2;
|
|
1397
|
+
if (!isRetryable(err2)) {
|
|
1368
1398
|
outbox.remove(key, ACTIVE_RUN_OUTBOX_SEQ);
|
|
1369
|
-
throw
|
|
1399
|
+
throw err2;
|
|
1370
1400
|
}
|
|
1371
1401
|
outbox.persist({
|
|
1372
1402
|
enqueuedAt: Date.now(),
|
|
@@ -1378,17 +1408,17 @@ var CabaneApi = class {
|
|
|
1378
1408
|
kind: "active-run"
|
|
1379
1409
|
});
|
|
1380
1410
|
this.opts.log?.warn(
|
|
1381
|
-
{ conversationId, agentId, err:
|
|
1382
|
-
"
|
|
1411
|
+
{ conversationId, agentId, err: err2 instanceof Error ? err2.message : String(err2) },
|
|
1412
|
+
"companion: active-run write queued to outbox after transient failure (will drain when the API returns)"
|
|
1383
1413
|
);
|
|
1384
1414
|
}
|
|
1385
1415
|
}
|
|
1386
1416
|
// CT29: per-device liveness moved off the per-workspace agent PAT and onto the
|
|
1387
1417
|
// device token — see `DeviceApi.heartbeat`. There is no agent-PAT heartbeat
|
|
1388
1418
|
// anymore.
|
|
1389
|
-
// SJ477: commit one row of the
|
|
1419
|
+
// SJ477: commit one row of the companion's turn (a `progress` interim note or
|
|
1390
1420
|
// the `final` reply), derived from its own SDK transcript. Posts to the same
|
|
1391
|
-
// public messages endpoint a user hits — the
|
|
1421
|
+
// public messages endpoint a user hits — the companion holds an agent-bound
|
|
1392
1422
|
// PAT, so the server attributes the row to this agent (role `agent`) and
|
|
1393
1423
|
// won't re-dispatch (the route gates re-dispatch on role `user`). `turnId`
|
|
1394
1424
|
// groups every row of one turn so the chat drawer renders them as a single
|
|
@@ -1403,12 +1433,12 @@ var CabaneApi = class {
|
|
|
1403
1433
|
// CT11: `kind` now includes `'stopped'` for the terminal marker the
|
|
1404
1434
|
// dispatcher writes when a turn is cancelled — same wire shape as
|
|
1405
1435
|
// `progress`/`final`, distinguished only by `kind` so the chat drawer's
|
|
1406
|
-
// turn-group renderer treats it as a closing row. `seq` is the
|
|
1436
|
+
// turn-group renderer treats it as a closing row. `seq` is the companion's
|
|
1407
1437
|
// per-turn monotonic counter, stamped on the row so the merged timeline
|
|
1408
1438
|
// orders the commit deterministically against the persisted tool/thinking
|
|
1409
|
-
// rows. Both fields are optional on the wire — an older
|
|
1439
|
+
// rows. Both fields are optional on the wire — an older companion that didn't
|
|
1410
1440
|
// mint seq still validates (the server defaults to 0); `stopped` is only
|
|
1411
|
-
// emitted by post-CT11
|
|
1441
|
+
// emitted by post-CT11 companions.
|
|
1412
1442
|
postTurnMessage(workspaceId, conversationId, body, signal) {
|
|
1413
1443
|
return this.durableCommit(
|
|
1414
1444
|
"message",
|
|
@@ -1449,11 +1479,11 @@ var CabaneApi = class {
|
|
|
1449
1479
|
);
|
|
1450
1480
|
}
|
|
1451
1481
|
// SJ493: fetch the agent's self-view — identity + operating context. The
|
|
1452
|
-
//
|
|
1482
|
+
// companion calls this on each dispatch to get its `systemPrompt` (composed
|
|
1453
1483
|
// server-side from the bundled default + the agent's charter) rather than
|
|
1454
1484
|
// baking a copy of the prompt into the download. Cabane is the control plane
|
|
1455
1485
|
// for the prompt, so changing it (or the per-agent charter) takes effect
|
|
1456
|
-
// without shipping a new
|
|
1486
|
+
// without shipping a new companion. Agent-PAT authed; the workspace is implied
|
|
1457
1487
|
// by the PAT, so no workspace arg.
|
|
1458
1488
|
// CT245: pass the triggering turn's `conversationId` so the server returns the
|
|
1459
1489
|
// run-config RESOLVED for this conversation (agent default + that
|
|
@@ -1464,10 +1494,10 @@ var CabaneApi = class {
|
|
|
1464
1494
|
const path3 = conversationId ? `/api/agent/me?conversationId=${encodeURIComponent(conversationId)}` : `/api/agent/me`;
|
|
1465
1495
|
return this.request("GET", path3);
|
|
1466
1496
|
}
|
|
1467
|
-
// The
|
|
1497
|
+
// The companion fetches the triggering message body by listing the
|
|
1468
1498
|
// conversation's messages and finding the one with `id === messageId`.
|
|
1469
1499
|
// Cabane has no single-message GET endpoint; for v0 this is fine because
|
|
1470
|
-
// the
|
|
1500
|
+
// the companion only reaches for the specific row immediately after the
|
|
1471
1501
|
// event fires (the thread is small at that point).
|
|
1472
1502
|
async getMessage(workspaceId, conversationId, messageId2) {
|
|
1473
1503
|
const res = await this.request(
|
|
@@ -1477,13 +1507,13 @@ var CabaneApi = class {
|
|
|
1477
1507
|
return res.messages.find((m) => m.id === messageId2) ?? null;
|
|
1478
1508
|
}
|
|
1479
1509
|
};
|
|
1480
|
-
function isRetryable(
|
|
1481
|
-
if (
|
|
1482
|
-
if (isAbortError(
|
|
1510
|
+
function isRetryable(err2) {
|
|
1511
|
+
if (err2 instanceof ApiError) return err2.status >= 500;
|
|
1512
|
+
if (isAbortError(err2)) return false;
|
|
1483
1513
|
return true;
|
|
1484
1514
|
}
|
|
1485
|
-
function isAbortError(
|
|
1486
|
-
return
|
|
1515
|
+
function isAbortError(err2) {
|
|
1516
|
+
return err2 instanceof Error && err2.name === "AbortError";
|
|
1487
1517
|
}
|
|
1488
1518
|
function sleep(ms, signal) {
|
|
1489
1519
|
return new Promise((resolve) => {
|
|
@@ -1501,8 +1531,8 @@ function sleep(ms, signal) {
|
|
|
1501
1531
|
}
|
|
1502
1532
|
function errorMessage(status, body) {
|
|
1503
1533
|
if (body && typeof body === "object" && "error" in body) {
|
|
1504
|
-
const
|
|
1505
|
-
if (typeof
|
|
1534
|
+
const err2 = body.error;
|
|
1535
|
+
if (typeof err2 === "string") return `${status} ${err2}`;
|
|
1506
1536
|
}
|
|
1507
1537
|
if (typeof body === "string" && body.length > 0) return `${status} ${body.slice(0, 200)}`;
|
|
1508
1538
|
return `${status} error`;
|
|
@@ -1546,7 +1576,10 @@ var DeviceApi = class {
|
|
|
1546
1576
|
getAssignments() {
|
|
1547
1577
|
return this.request("GET", "/api/companion/assignments");
|
|
1548
1578
|
}
|
|
1549
|
-
|
|
1579
|
+
beginDrain() {
|
|
1580
|
+
return this.request("POST", "/api/companion/drain", {});
|
|
1581
|
+
}
|
|
1582
|
+
// Per-device liveness ping. Reports the companion build version and the env-var
|
|
1550
1583
|
// names the operator's secret store exposes (never values), so CT30's UI can
|
|
1551
1584
|
// warn pre-emptively about an agent that needs a secret this device lacks.
|
|
1552
1585
|
heartbeat(body) {
|
|
@@ -1555,8 +1588,8 @@ var DeviceApi = class {
|
|
|
1555
1588
|
};
|
|
1556
1589
|
function errorMessage2(status, body) {
|
|
1557
1590
|
if (body && typeof body === "object" && "error" in body) {
|
|
1558
|
-
const
|
|
1559
|
-
if (typeof
|
|
1591
|
+
const err2 = body.error;
|
|
1592
|
+
if (typeof err2 === "string") return `${status} ${err2}`;
|
|
1560
1593
|
}
|
|
1561
1594
|
if (typeof body === "string" && body.length > 0) return `${status} ${body.slice(0, 200)}`;
|
|
1562
1595
|
return `${status} error`;
|
|
@@ -1610,12 +1643,12 @@ function save(map) {
|
|
|
1610
1643
|
} catch {
|
|
1611
1644
|
}
|
|
1612
1645
|
renameSync2(tmp, path3);
|
|
1613
|
-
} catch (
|
|
1646
|
+
} catch (err2) {
|
|
1614
1647
|
try {
|
|
1615
1648
|
rmSync3(tmp, { force: true });
|
|
1616
1649
|
} catch {
|
|
1617
1650
|
}
|
|
1618
|
-
throw
|
|
1651
|
+
throw err2;
|
|
1619
1652
|
}
|
|
1620
1653
|
}
|
|
1621
1654
|
function getCredential(agentId) {
|
|
@@ -1781,7 +1814,7 @@ function bumpResumeAttempt(workspaceId, eventId) {
|
|
|
1781
1814
|
return next;
|
|
1782
1815
|
}
|
|
1783
1816
|
function noResume() {
|
|
1784
|
-
return process.env.
|
|
1817
|
+
return process.env.CABANE_COMPANION_NO_RESUME === "1";
|
|
1785
1818
|
}
|
|
1786
1819
|
|
|
1787
1820
|
// packages/agent-runtime/src/version.ts
|
|
@@ -1798,16 +1831,17 @@ var hostPolicySchema = z5.object({
|
|
|
1798
1831
|
// Public web (today's `WebSearch` / `WebFetch`, the `DEFAULT_WEB_TOOLS`). Public
|
|
1799
1832
|
// web, not host reach — granted by default today, but expressible as a grant.
|
|
1800
1833
|
web: z5.boolean(),
|
|
1801
|
-
// Browser automation (the Playwright MCP surface). Varies by host: a
|
|
1834
|
+
// Browser automation (the Playwright MCP surface). Varies by host: a companion has
|
|
1802
1835
|
// it, the house executor does not (CT230).
|
|
1803
1836
|
browser: z5.boolean(),
|
|
1804
1837
|
// User-configured MCP servers permitted. False for the house executor
|
|
1805
|
-
// (CT227: Cabane agents run no user MCP servers), true for a personal
|
|
1838
|
+
// (CT227: Cabane agents run no user MCP servers), true for a personal companion.
|
|
1806
1839
|
userMcp: z5.boolean(),
|
|
1807
1840
|
// Subagent / sub-dispatch (`Task` / `Agent`). A per-host GRANT (see the CT261
|
|
1808
1841
|
// amendment above): `false` on the locked assistant/house surface (banned via
|
|
1809
1842
|
// `DISALLOWED_TOOLS`), `true` under coding / custom where the fan-out is left to
|
|
1810
|
-
//
|
|
1843
|
+
// the coding allow-hook (which reaches sub-agent tool calls, CT680) or the user
|
|
1844
|
+
// allowlist. The subagent completes within the turn, so
|
|
1811
1845
|
// it's not the turn-model invariant `scheduling` is.
|
|
1812
1846
|
subagents: z5.boolean(),
|
|
1813
1847
|
// ── Hard platform invariants — always denied, never granted ────────────────
|
|
@@ -1828,7 +1862,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1828
1862
|
// The runtime's opaque session state, emitted when the adapter learns it (e.g.
|
|
1829
1863
|
// the SDK `system/init` frame). The platform stores `state` verbatim per
|
|
1830
1864
|
// (conversation, agent) and hands it back on the next turn; only the adapter
|
|
1831
|
-
// knows what it means. Today's
|
|
1865
|
+
// knows what it means. Today's companion captures the raw SDK session id here; a
|
|
1832
1866
|
// future adapter may encode more (e.g. `{sdkSessionId, cwd}`) — still one
|
|
1833
1867
|
// opaque string to the platform.
|
|
1834
1868
|
//
|
|
@@ -1841,7 +1875,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1841
1875
|
// so the mark now over-reaches an empty session. The host relays `degraded`
|
|
1842
1876
|
// on settle and the server rewinds the mark, so the NEXT turn rebuilds a full
|
|
1843
1877
|
// catch-up (this failing turn is unavoidably lossy — the degrade is only known
|
|
1844
|
-
// on the
|
|
1878
|
+
// on the companion, after the server committed the manifest). Runtime-neutral: a
|
|
1845
1879
|
// plain boolean, not a runtime-specific reason string (that stays in the
|
|
1846
1880
|
// adapter's `onWarn` log). Additive + optional — an old receiver ignores it.
|
|
1847
1881
|
z6.object({
|
|
@@ -1889,7 +1923,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1889
1923
|
// dropped on the floor before. `inputTokens` is the full context the model saw
|
|
1890
1924
|
// (uncached + cache-read + cache-creation input), so it doubles as the
|
|
1891
1925
|
// context-window cost; `outputTokens` the generated tokens. Optional ⇒
|
|
1892
|
-
// backward-compatible: an old
|
|
1926
|
+
// backward-compatible: an old companion / adapter that never sets it, and a
|
|
1893
1927
|
// receiver that never reads it, are unaffected (the turn's token columns stay
|
|
1894
1928
|
// null → the UI shows `—`).
|
|
1895
1929
|
//
|
|
@@ -1899,6 +1933,19 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1899
1933
|
// the server just leaves the cache columns null. Captured now because honest
|
|
1900
1934
|
// costing later prices a cache-read token far below a fresh input token.
|
|
1901
1935
|
//
|
|
1936
|
+
// CT699: `inputTokens` (and the cache slice) is a BILLING quantity — for
|
|
1937
|
+
// claude-code/codex it's the runtime's CUMULATIVE total summed across every model
|
|
1938
|
+
// request in the agentic turn, so it grows with the tool-call count and is NOT
|
|
1939
|
+
// "how full is the window." `contextTokens` is the distinct CONTEXT-OCCUPANCY
|
|
1940
|
+
// read: the input the model saw on its FINAL request of the turn (uncached +
|
|
1941
|
+
// cache, since cached tokens still occupy the window) — the number the composer
|
|
1942
|
+
// gauge wants. `contextWindow` is the model's true window in tokens when the
|
|
1943
|
+
// runtime reports it (claude-code's SDK does, per model) — a real denominator so
|
|
1944
|
+
// the gauge can show a fraction. Both optional: a runtime that can't source a
|
|
1945
|
+
// clean final-request figure (codex's cumulative-only usage) omits `contextTokens`
|
|
1946
|
+
// and the gauge falls back to the raw count; `contextWindow` falls back to the
|
|
1947
|
+
// model catalog.
|
|
1948
|
+
//
|
|
1902
1949
|
// CT601: two more optional carry-homes on the terminal result, alongside
|
|
1903
1950
|
// `usage`. `resolvedModel` is the CONCRETE model the runtime actually ran —
|
|
1904
1951
|
// claude-code learns it from the `system/init` frame mid-run (even for a
|
|
@@ -1908,7 +1955,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1908
1955
|
// for claude-code today). Both only known after the run streams — so they ride
|
|
1909
1956
|
// the terminal event home, the host relays them on settle, and the server writes
|
|
1910
1957
|
// `message_turn_metadata.resolved_model` / `resolved_config`. Optional ⇒
|
|
1911
|
-
// backward-compatible: an old adapter/
|
|
1958
|
+
// backward-compatible: an old adapter/companion omits them, a cancel has no result
|
|
1912
1959
|
// event at all, and the columns stay null → the UI shows `—`.
|
|
1913
1960
|
z6.object({
|
|
1914
1961
|
type: z6.literal("result"),
|
|
@@ -1918,7 +1965,9 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1918
1965
|
inputTokens: z6.number(),
|
|
1919
1966
|
outputTokens: z6.number(),
|
|
1920
1967
|
cacheReadTokens: z6.number().optional(),
|
|
1921
|
-
cacheCreationTokens: z6.number().optional()
|
|
1968
|
+
cacheCreationTokens: z6.number().optional(),
|
|
1969
|
+
contextTokens: z6.number().optional(),
|
|
1970
|
+
contextWindow: z6.number().optional()
|
|
1922
1971
|
}).optional(),
|
|
1923
1972
|
resolvedModel: z6.string().optional(),
|
|
1924
1973
|
resolvedConfig: z6.object({
|
|
@@ -2006,7 +2055,8 @@ function classifyErrorText(text) {
|
|
|
2006
2055
|
const t = text.toLowerCase();
|
|
2007
2056
|
if (AUTH_PATTERNS.some((re) => re.test(t))) return { kind: "auth_expired" };
|
|
2008
2057
|
if (SERVER_PATTERNS.some((re) => re.test(t))) return { kind: "server_error" };
|
|
2009
|
-
const
|
|
2058
|
+
const withoutNegatedCap = t.replace(NEGATED_CAP, "");
|
|
2059
|
+
const capNoun = CAP_PATTERNS.some((re) => re.test(withoutNegatedCap));
|
|
2010
2060
|
const rateToken = RATE_PATTERNS.some((re) => re.test(t));
|
|
2011
2061
|
if (capNoun) return { kind: "usage_capped" };
|
|
2012
2062
|
if (rateToken) return { kind: "rate_limited" };
|
|
@@ -2035,6 +2085,7 @@ var SERVER_PATTERNS = [
|
|
|
2035
2085
|
/fetch failed/
|
|
2036
2086
|
];
|
|
2037
2087
|
var CAP_PATTERNS = [/usage limit/, /weekly limit/, /session limit/, /\bquota\b/];
|
|
2088
|
+
var NEGATED_CAP = /not (your|a) usage limit/g;
|
|
2038
2089
|
var RATE_PATTERNS = [/rate[_ ]?limit/, /\b429\b/, /too many requests/];
|
|
2039
2090
|
var BARE_LIMIT = /\blimit (reached|exceeded)\b/;
|
|
2040
2091
|
|
|
@@ -2096,34 +2147,62 @@ var turnRequestSchema = z8.object({
|
|
|
2096
2147
|
mcpUrl: z8.string(),
|
|
2097
2148
|
bearer: z8.string(),
|
|
2098
2149
|
activeConversationId: z8.string(),
|
|
2150
|
+
// CT714: the scoped TURN-CONTROL MCP endpoint (`/api/turn-control`). The
|
|
2151
|
+
// EXTERNAL adapters (Codex / opencode) mount it by URL under the key
|
|
2152
|
+
// `cabane_companion` — using the same `bearer` (the turn token) and the same
|
|
2153
|
+
// active-conversation header they send to the `cabane` server — so their
|
|
2154
|
+
// agents get `ask`/`wake_me`/`summon_agent`/`sub_agent`/`skip_turn`, the
|
|
2155
|
+
// verbs they can't get from the companion's in-process SDK server. Optional:
|
|
2156
|
+
// claude-code ignores it (it mounts the in-process instance instead), and
|
|
2157
|
+
// every existing `cabane`-block fixture keeps parsing unchanged; the
|
|
2158
|
+
// companion always populates it (`build-options.ts`).
|
|
2159
|
+
turnControlUrl: z8.string().optional(),
|
|
2099
2160
|
// CT598: the workspace this turn runs in. The claude-code/opencode/codex
|
|
2100
2161
|
// adapters never need it (they reach Cabane through the `cabane` MCP server,
|
|
2101
2162
|
// which takes `workspaceId` as a per-tool arg the model supplies); the
|
|
2102
2163
|
// native runtime's interim tool surface calls the workspace-scoped REST API
|
|
2103
2164
|
// DIRECTLY, so it needs the id host-side rather than trusting the model to
|
|
2104
2165
|
// pass it. Optional so every existing `cabane`-block constructor (the three
|
|
2105
|
-
// adapters' conformance fixtures, tests) keeps parsing unchanged — the
|
|
2166
|
+
// adapters' conformance fixtures, tests) keeps parsing unchanged — the companion
|
|
2106
2167
|
// always populates it (`build-options.ts`), and the native adapter fails the
|
|
2107
2168
|
// turn loudly when it is somehow absent rather than guessing.
|
|
2108
|
-
workspaceId: z8.string().optional()
|
|
2169
|
+
workspaceId: z8.string().optional(),
|
|
2170
|
+
// CT752: the server-resolved workspace surface this credential exposes.
|
|
2171
|
+
// Readiness uses this explicit fact to require `sdk` for code mode and the
|
|
2172
|
+
// granular floor for classic mode; inventory contents alone cannot infer it
|
|
2173
|
+
// because `sdk` is intentionally also available on the classic surface.
|
|
2174
|
+
workspaceToolSurface: z8.enum(["code", "classic"]).optional()
|
|
2109
2175
|
}),
|
|
2110
2176
|
// Machine-local resolution (host-filled): the checkout cwd, extra env from a
|
|
2111
2177
|
// prepare hook, and the resolved user MCP servers.
|
|
2112
2178
|
local: z8.object({
|
|
2113
2179
|
cwd: z8.string().optional(),
|
|
2114
2180
|
env: z8.record(z8.string(), z8.string()).optional(),
|
|
2181
|
+
nativeWorkAssignment: z8.object({
|
|
2182
|
+
itemId: z8.string(),
|
|
2183
|
+
executionId: z8.string(),
|
|
2184
|
+
activationEpoch: z8.number().int().nonnegative()
|
|
2185
|
+
}).strict().optional(),
|
|
2115
2186
|
mcpServers: resolvedMcpServersSchema.optional(),
|
|
2116
2187
|
// CT289: machine-local claude-code adapter knobs the operator sets on a
|
|
2117
|
-
//
|
|
2188
|
+
// companion they run themselves — the auto-memory escape hatch. `autoMemory:
|
|
2118
2189
|
// true` opts back into Claude Code's auto-memory (governed by the operator's
|
|
2119
2190
|
// own `.claude/settings.json`); absent/false leaves the adapter's force-off
|
|
2120
|
-
// default in place (see `buildClaudeCodeOptions`). The
|
|
2191
|
+
// default in place (see `buildClaudeCodeOptions`). The In-Cabane executor never
|
|
2121
2192
|
// sets it, so house stays force-off unconditionally.
|
|
2122
2193
|
claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
|
|
2123
2194
|
}),
|
|
2124
2195
|
// Host-owned injected servers (host-filled) — e.g. the summon server.
|
|
2125
2196
|
extra: z8.object({
|
|
2126
|
-
mcpServers: hostInjectedServersSchema
|
|
2197
|
+
mcpServers: hostInjectedServersSchema,
|
|
2198
|
+
// CT666: the per-turn turn-control HANDLER for the in-process cabane-native
|
|
2199
|
+
// runtime — the seam that gives it ask/wake_me/summon_agent/sub_agent/skip_turn
|
|
2200
|
+
// without a subprocess `cabane_companion` MCP server. Typed `unknown` for the same
|
|
2201
|
+
// reason as `mcpServers`: it's a live host object (a `NativeTurnControl` whose
|
|
2202
|
+
// methods close over the dispatcher's per-turn state), passed to the native
|
|
2203
|
+
// adapter WITHOUT the contract package inspecting it. The subprocess adapters
|
|
2204
|
+
// ignore it (they get the same verbs from the injected SDK server instead).
|
|
2205
|
+
turnControl: z8.unknown().optional()
|
|
2127
2206
|
})
|
|
2128
2207
|
});
|
|
2129
2208
|
|
|
@@ -2143,8 +2222,8 @@ function createTerminalTextBuffer() {
|
|
|
2143
2222
|
async function safeEmit(emit, event, onError) {
|
|
2144
2223
|
try {
|
|
2145
2224
|
await emit(event);
|
|
2146
|
-
} catch (
|
|
2147
|
-
onError?.(
|
|
2225
|
+
} catch (err2) {
|
|
2226
|
+
onError?.(err2, event.type);
|
|
2148
2227
|
}
|
|
2149
2228
|
}
|
|
2150
2229
|
async function processAssistantMessage(msg, emit, pending, buffer, onError) {
|
|
@@ -2405,16 +2484,16 @@ var TurnPump = class {
|
|
|
2405
2484
|
// minimal note. Skipped when cancelled or already final. The held-text flush
|
|
2406
2485
|
// that precedes it is a classification concern, driven by the caller before
|
|
2407
2486
|
// this runs.
|
|
2408
|
-
async finalize(
|
|
2409
|
-
if (!
|
|
2487
|
+
async finalize(ok2) {
|
|
2488
|
+
if (!ok2 || this.opts.signal.aborted || this.emittedFinal) return;
|
|
2410
2489
|
const body = this.lastProgressBody ?? this.opts.emptyFinalBody;
|
|
2411
2490
|
const seq = this.opts.nextSeq();
|
|
2412
2491
|
try {
|
|
2413
2492
|
await this.opts.commit.commitMessage({ body, kind: "final", seq });
|
|
2414
2493
|
this.emittedFinal = true;
|
|
2415
2494
|
this.finalReplyBody = body;
|
|
2416
|
-
} catch (
|
|
2417
|
-
this.opts.onError?.(
|
|
2495
|
+
} catch (err2) {
|
|
2496
|
+
this.opts.onError?.(err2, "empty-final");
|
|
2418
2497
|
}
|
|
2419
2498
|
}
|
|
2420
2499
|
// Whether the turn has committed its `final` row — read by the host to decide
|
|
@@ -2504,10 +2583,10 @@ var claudeCodeDialectSchema = z10.object({
|
|
|
2504
2583
|
allowedTools: z10.array(z10.string()).optional(),
|
|
2505
2584
|
disallowedTools: z10.array(z10.string()).optional(),
|
|
2506
2585
|
// Which claude-code harness shape to run. `coding` switches to the
|
|
2507
|
-
// `claude_code` preset + project settings + always-allow `
|
|
2586
|
+
// `claude_code` preset + project settings + always-allow `PreToolUse` hook;
|
|
2508
2587
|
// `custom`/`assistant` (or absent) use a plain-string prompt + bypass. This
|
|
2509
2588
|
// is the claude-code-specific PRESET selector — kept distinct from
|
|
2510
|
-
// `policy.hostFs` (the host-fs BLOCK), because
|
|
2589
|
+
// `policy.hostFs` (the host-fs BLOCK), because companion `custom` mode wants host
|
|
2511
2590
|
// fs available (via its own allowlist) WITHOUT the coding harness, and in-app
|
|
2512
2591
|
// `custom` wants host fs blocked — neither of which a single `hostFs` boolean
|
|
2513
2592
|
// can express alongside the preset choice.
|
|
@@ -2555,10 +2634,18 @@ function decideResume(stored, currentCwd) {
|
|
|
2555
2634
|
// packages/agent-runtime/src/claude-code/options.ts
|
|
2556
2635
|
var CABANE_MCP_SERVER = "cabane";
|
|
2557
2636
|
var ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2637
|
+
async function allowEverythingHook(input) {
|
|
2638
|
+
const toolInput = "tool_input" in input && input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
|
|
2639
|
+
return {
|
|
2640
|
+
continue: true,
|
|
2641
|
+
hookSpecificOutput: {
|
|
2642
|
+
hookEventName: "PreToolUse",
|
|
2643
|
+
permissionDecision: "allow",
|
|
2644
|
+
permissionDecisionReason: "coding mode: headless never-prompt (CT680)",
|
|
2645
|
+
updatedInput: toolInput
|
|
2646
|
+
}
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2562
2649
|
function buildClaudeCodeOptions(req, augment) {
|
|
2563
2650
|
const { policy, config } = req;
|
|
2564
2651
|
const cwd = req.local.cwd;
|
|
@@ -2619,7 +2706,7 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
2619
2706
|
...devControlsAutoMemory ? {} : { settings: { autoMemoryEnabled: false } },
|
|
2620
2707
|
mcpServers,
|
|
2621
2708
|
...cwd ? { cwd } : {},
|
|
2622
|
-
// Extra env (a
|
|
2709
|
+
// Extra env (a companion prepare hook's tokens/ports; the in-app's debug flags)
|
|
2623
2710
|
// merged OVER the inherited environment.
|
|
2624
2711
|
...req.local.env ? { env: { ...process.env, ...req.local.env } } : {},
|
|
2625
2712
|
...resume ? { resume } : {}
|
|
@@ -2632,7 +2719,7 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
2632
2719
|
settingSources: ["project"],
|
|
2633
2720
|
allowedTools,
|
|
2634
2721
|
disallowedTools,
|
|
2635
|
-
|
|
2722
|
+
hooks: { PreToolUse: [{ hooks: [allowEverythingHook] }] }
|
|
2636
2723
|
};
|
|
2637
2724
|
} else {
|
|
2638
2725
|
options = {
|
|
@@ -2659,13 +2746,15 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
2659
2746
|
out.push(event);
|
|
2660
2747
|
};
|
|
2661
2748
|
let sessionEmitted = false;
|
|
2662
|
-
let
|
|
2749
|
+
let ok2 = false;
|
|
2663
2750
|
let resultReason;
|
|
2664
2751
|
let sawResult = false;
|
|
2665
2752
|
let usage;
|
|
2753
|
+
let lastRequestContextTokens;
|
|
2666
2754
|
let resolvedModel;
|
|
2667
2755
|
let sawRejectedLimit = false;
|
|
2668
2756
|
let rateLimitResetIso;
|
|
2757
|
+
let rateLimitType;
|
|
2669
2758
|
let authError;
|
|
2670
2759
|
let lastAssistantError;
|
|
2671
2760
|
try {
|
|
@@ -2688,6 +2777,8 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
2688
2777
|
if (typeof assistantErr === "string" && assistantErr.length > 0) {
|
|
2689
2778
|
lastAssistantError = assistantErr;
|
|
2690
2779
|
}
|
|
2780
|
+
const reqContext = readRequestContextTokens(msg);
|
|
2781
|
+
if (reqContext !== void 0) lastRequestContextTokens = reqContext;
|
|
2691
2782
|
await processAssistantMessage(msg, emit, pending, buffer);
|
|
2692
2783
|
yield* drain(out);
|
|
2693
2784
|
} else if (msg.type === "user") {
|
|
@@ -2698,16 +2789,23 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
2698
2789
|
if (info?.status === "rejected") {
|
|
2699
2790
|
sawRejectedLimit = true;
|
|
2700
2791
|
rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? rateLimitResetIso;
|
|
2792
|
+
if (typeof info.rateLimitType === "string") rateLimitType = info.rateLimitType;
|
|
2701
2793
|
}
|
|
2702
2794
|
} else if (msg.type === "auth_status") {
|
|
2703
|
-
const
|
|
2704
|
-
if (typeof
|
|
2795
|
+
const err2 = msg.error;
|
|
2796
|
+
if (typeof err2 === "string" && err2.length > 0) authError = err2;
|
|
2705
2797
|
} else if (msg.type === "result") {
|
|
2706
2798
|
sawResult = true;
|
|
2707
2799
|
usage = readSdkUsage(msg);
|
|
2800
|
+
if (usage) {
|
|
2801
|
+
if (lastRequestContextTokens !== void 0)
|
|
2802
|
+
usage.contextTokens = lastRequestContextTokens;
|
|
2803
|
+
const window = readContextWindow(msg, resolvedModel);
|
|
2804
|
+
if (window !== void 0) usage.contextWindow = window;
|
|
2805
|
+
}
|
|
2708
2806
|
const isError = msg.is_error === true;
|
|
2709
2807
|
if (msg.subtype === "success" && !isError) {
|
|
2710
|
-
|
|
2808
|
+
ok2 = true;
|
|
2711
2809
|
} else {
|
|
2712
2810
|
const resultText = msg.result ?? "";
|
|
2713
2811
|
const terminalReason = msg.terminal_reason;
|
|
@@ -2715,36 +2813,40 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
2715
2813
|
const errorText = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(
|
|
2716
2814
|
" "
|
|
2717
2815
|
);
|
|
2718
|
-
const
|
|
2816
|
+
const rejectedIsCap = sawRejectedLimit && (rateLimitResetIso !== void 0 || isSubscriptionWindow(rateLimitType));
|
|
2817
|
+
const failure = rejectedIsCap || terminalReason === "blocking_limit" ? {
|
|
2719
2818
|
kind: "usage_capped",
|
|
2720
2819
|
...rateLimitResetIso ? { resetsAt: rateLimitResetIso } : {}
|
|
2721
2820
|
} : classifyAssistantError(lastAssistantError) ?? classifyErrorText([authError, errorText].filter(Boolean).join(" "));
|
|
2722
2821
|
resultReason = failure ? encodeFailureReason(failure) : isError ? `error:${resultText.slice(0, 200) || "unknown"}` : `result_error:${msg.subtype}`;
|
|
2723
|
-
|
|
2822
|
+
ok2 = false;
|
|
2724
2823
|
}
|
|
2725
2824
|
break;
|
|
2726
2825
|
}
|
|
2727
2826
|
}
|
|
2728
|
-
} catch (
|
|
2729
|
-
if (ctx.signal.aborted) throw
|
|
2730
|
-
const failure = classifyErrorText(
|
|
2731
|
-
if (!failure) throw
|
|
2732
|
-
|
|
2827
|
+
} catch (err2) {
|
|
2828
|
+
if (ctx.signal.aborted) throw err2;
|
|
2829
|
+
const failure = classifyErrorText(err2 instanceof Error ? err2.message : String(err2));
|
|
2830
|
+
if (!failure) throw err2;
|
|
2831
|
+
ok2 = false;
|
|
2733
2832
|
resultReason = encodeFailureReason(failure);
|
|
2734
2833
|
sawResult = true;
|
|
2735
2834
|
}
|
|
2736
2835
|
if (ctx.signal.aborted) return;
|
|
2737
|
-
await flushHeldText(buffer, emit,
|
|
2836
|
+
await flushHeldText(buffer, emit, ok2);
|
|
2738
2837
|
yield* drain(out);
|
|
2739
|
-
if (!
|
|
2838
|
+
if (!ok2 && !resultReason && !sawResult) resultReason = "no_result";
|
|
2740
2839
|
yield {
|
|
2741
2840
|
type: "result",
|
|
2742
|
-
ok,
|
|
2841
|
+
ok: ok2,
|
|
2743
2842
|
...resultReason ? { reason: resultReason } : {},
|
|
2744
2843
|
...usage ? { usage } : {},
|
|
2745
2844
|
...resolvedModel ? { resolvedModel } : {}
|
|
2746
2845
|
};
|
|
2747
2846
|
}
|
|
2847
|
+
function isSubscriptionWindow(value) {
|
|
2848
|
+
return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
|
|
2849
|
+
}
|
|
2748
2850
|
function* drain(out) {
|
|
2749
2851
|
while (out.length > 0) yield out.shift();
|
|
2750
2852
|
}
|
|
@@ -2758,6 +2860,27 @@ function readSdkUsage(msg) {
|
|
|
2758
2860
|
const outputTokens = num(usage.output_tokens);
|
|
2759
2861
|
return { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens };
|
|
2760
2862
|
}
|
|
2863
|
+
function readRequestContextTokens(msg) {
|
|
2864
|
+
const usage = msg.message?.usage;
|
|
2865
|
+
if (!usage || typeof usage !== "object") return void 0;
|
|
2866
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
2867
|
+
return num(usage.input_tokens) + num(usage.cache_read_input_tokens) + num(usage.cache_creation_input_tokens);
|
|
2868
|
+
}
|
|
2869
|
+
function readContextWindow(msg, resolvedModel) {
|
|
2870
|
+
const modelUsage = msg.modelUsage;
|
|
2871
|
+
if (!modelUsage || typeof modelUsage !== "object") return void 0;
|
|
2872
|
+
const pos = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
2873
|
+
if (resolvedModel) {
|
|
2874
|
+
const direct = pos(modelUsage[resolvedModel]?.contextWindow);
|
|
2875
|
+
if (direct !== void 0) return direct;
|
|
2876
|
+
}
|
|
2877
|
+
let max;
|
|
2878
|
+
for (const entry of Object.values(modelUsage)) {
|
|
2879
|
+
const w = pos(entry?.contextWindow);
|
|
2880
|
+
if (w !== void 0 && (max === void 0 || w > max)) max = w;
|
|
2881
|
+
}
|
|
2882
|
+
return max;
|
|
2883
|
+
}
|
|
2761
2884
|
|
|
2762
2885
|
// packages/agent-runtime/src/claude-code/prompt-input.ts
|
|
2763
2886
|
function buildQueryPrompt(req) {
|
|
@@ -2875,9 +2998,13 @@ var resultErrorFull = (subtype, extra = {}) => ({
|
|
|
2875
2998
|
session_id: "s",
|
|
2876
2999
|
...extra
|
|
2877
3000
|
});
|
|
2878
|
-
var rateLimitEvent = (status, resetsAt) => ({
|
|
3001
|
+
var rateLimitEvent = (status, resetsAt, rateLimitType) => ({
|
|
2879
3002
|
type: "rate_limit_event",
|
|
2880
|
-
rate_limit_info: {
|
|
3003
|
+
rate_limit_info: {
|
|
3004
|
+
status,
|
|
3005
|
+
...resetsAt !== void 0 ? { resetsAt } : {},
|
|
3006
|
+
...rateLimitType !== void 0 ? { rateLimitType } : {}
|
|
3007
|
+
},
|
|
2881
3008
|
session_id: "s"
|
|
2882
3009
|
});
|
|
2883
3010
|
var authStatus = (error) => ({
|
|
@@ -3041,17 +3168,16 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
|
|
|
3041
3168
|
]
|
|
3042
3169
|
},
|
|
3043
3170
|
{
|
|
3044
|
-
// CT558/CT592: a subscription cap. The SDK emits a
|
|
3045
|
-
// `
|
|
3046
|
-
// structured `usage_capped` reason
|
|
3047
|
-
//
|
|
3048
|
-
// as `progress`. CT592: the cap event is `usage_capped`, distinct from a 429.
|
|
3171
|
+
// CT558/CT592/CT731: a subscription cap. The SDK emits a rejected
|
|
3172
|
+
// `rate_limit_event` with a named subscription window; the terminal error result
|
|
3173
|
+
// then classifies as the structured `usage_capped` reason. Partial narration
|
|
3174
|
+
// lands as `progress`. The cap event is distinct from a provider 429 throttle.
|
|
3049
3175
|
name: "subscription cap \u2192 usage_capped",
|
|
3050
3176
|
request: makeRequest(),
|
|
3051
3177
|
nativeStream: [
|
|
3052
3178
|
init("s1"),
|
|
3053
3179
|
assistantText("Let me work on that."),
|
|
3054
|
-
rateLimitEvent("rejected"),
|
|
3180
|
+
rateLimitEvent("rejected", void 0, "five_hour"),
|
|
3055
3181
|
resultError("error_during_execution")
|
|
3056
3182
|
],
|
|
3057
3183
|
expected: [
|
|
@@ -3275,9 +3401,9 @@ function readSessionId(properties) {
|
|
|
3275
3401
|
}
|
|
3276
3402
|
function readSessionError(properties) {
|
|
3277
3403
|
const props = asRecord(properties);
|
|
3278
|
-
const
|
|
3279
|
-
if (typeof
|
|
3280
|
-
const rec = asRecord(
|
|
3404
|
+
const err2 = props?.error;
|
|
3405
|
+
if (typeof err2 === "string") return err2;
|
|
3406
|
+
const rec = asRecord(err2);
|
|
3281
3407
|
if (!rec) return "unknown";
|
|
3282
3408
|
const { name, message } = deepestError(rec);
|
|
3283
3409
|
if (message && name && !isGenericErrorName(name)) return `${name}: ${message}`;
|
|
@@ -3313,7 +3439,7 @@ async function* decodeOpencodeStream(events, ctx) {
|
|
|
3313
3439
|
const pending = /* @__PURE__ */ new Map();
|
|
3314
3440
|
const startedTools = /* @__PURE__ */ new Set();
|
|
3315
3441
|
const finishedTools = /* @__PURE__ */ new Set();
|
|
3316
|
-
let
|
|
3442
|
+
let ok2 = false;
|
|
3317
3443
|
let reason;
|
|
3318
3444
|
let settled = false;
|
|
3319
3445
|
const userMessageIds = /* @__PURE__ */ new Set();
|
|
@@ -3376,7 +3502,7 @@ async function* decodeOpencodeStream(events, ctx) {
|
|
|
3376
3502
|
const sealed = sealHeld(held, true);
|
|
3377
3503
|
held = null;
|
|
3378
3504
|
if (sealed) yield sealed;
|
|
3379
|
-
|
|
3505
|
+
ok2 = true;
|
|
3380
3506
|
settled = true;
|
|
3381
3507
|
break;
|
|
3382
3508
|
} else if (ev.type === "session.error") {
|
|
@@ -3385,7 +3511,7 @@ async function* decodeOpencodeStream(events, ctx) {
|
|
|
3385
3511
|
const sealed = sealHeld(held, false);
|
|
3386
3512
|
held = null;
|
|
3387
3513
|
if (sealed) yield sealed;
|
|
3388
|
-
|
|
3514
|
+
ok2 = false;
|
|
3389
3515
|
const errorText = readSessionError(ev.properties);
|
|
3390
3516
|
const failure = classifyErrorText(errorText);
|
|
3391
3517
|
reason = failure ? encodeFailureReason(failure) : `error:${errorText.slice(0, 200)}`;
|
|
@@ -3400,7 +3526,7 @@ async function* decodeOpencodeStream(events, ctx) {
|
|
|
3400
3526
|
if (sealed) yield sealed;
|
|
3401
3527
|
reason = "no_terminal";
|
|
3402
3528
|
}
|
|
3403
|
-
yield { type: "result", ok, ...reason ? { reason } : {} };
|
|
3529
|
+
yield { type: "result", ok: ok2, ...reason ? { reason } : {} };
|
|
3404
3530
|
}
|
|
3405
3531
|
function hasToolInput(input) {
|
|
3406
3532
|
return !!input && typeof input === "object" && Object.keys(input).length > 0;
|
|
@@ -3458,6 +3584,7 @@ function parseOpencodeModel(model) {
|
|
|
3458
3584
|
|
|
3459
3585
|
// packages/agent-runtime/src/opencode/run-spec.ts
|
|
3460
3586
|
var CABANE_MCP_SERVER2 = "cabane";
|
|
3587
|
+
var TURN_CONTROL_MCP_SERVER = "cabane_companion";
|
|
3461
3588
|
var ACTIVE_CONVERSATION_HEADER3 = "x-cabane-active-conversation";
|
|
3462
3589
|
function buildRunSpec(req, resumeSessionId) {
|
|
3463
3590
|
const { policy, config } = req;
|
|
@@ -3519,6 +3646,17 @@ function buildMcp(req) {
|
|
|
3519
3646
|
},
|
|
3520
3647
|
enabled: true
|
|
3521
3648
|
};
|
|
3649
|
+
if (req.cabane.turnControlUrl) {
|
|
3650
|
+
mcp[TURN_CONTROL_MCP_SERVER] = {
|
|
3651
|
+
type: "remote",
|
|
3652
|
+
url: req.cabane.turnControlUrl,
|
|
3653
|
+
headers: {
|
|
3654
|
+
Authorization: `Bearer ${req.cabane.bearer}`,
|
|
3655
|
+
[ACTIVE_CONVERSATION_HEADER3]: req.cabane.activeConversationId
|
|
3656
|
+
},
|
|
3657
|
+
enabled: true
|
|
3658
|
+
};
|
|
3659
|
+
}
|
|
3522
3660
|
for (const [name, raw] of Object.entries(req.extra.mcpServers)) {
|
|
3523
3661
|
const server = raw;
|
|
3524
3662
|
if (typeof server.url === "string") {
|
|
@@ -3651,9 +3789,9 @@ function createHttpOpencodeTransport(opts) {
|
|
|
3651
3789
|
// The lock is released when this stream finishes draining.
|
|
3652
3790
|
events: releaseAfter(parseSseStream(eventRes.body, sessionId, signal), release)
|
|
3653
3791
|
};
|
|
3654
|
-
} catch (
|
|
3792
|
+
} catch (err2) {
|
|
3655
3793
|
release();
|
|
3656
|
-
throw
|
|
3794
|
+
throw err2;
|
|
3657
3795
|
}
|
|
3658
3796
|
}
|
|
3659
3797
|
};
|
|
@@ -3784,7 +3922,7 @@ var opencodeAdapter = createOpencodeAdapter();
|
|
|
3784
3922
|
// packages/agent-runtime/src/opencode/conformance.ts
|
|
3785
3923
|
var ABORT_SENTINEL2 = { __abortHere: true };
|
|
3786
3924
|
var NEW_SESSION_ID = "sess_new";
|
|
3787
|
-
var
|
|
3925
|
+
var COMPANION_POLICY = {
|
|
3788
3926
|
hostFs: false,
|
|
3789
3927
|
web: true,
|
|
3790
3928
|
browser: true,
|
|
@@ -3800,7 +3938,7 @@ function makeRequest2(overrides = {}) {
|
|
|
3800
3938
|
prompt: "hi there",
|
|
3801
3939
|
content: [{ type: "text", text: "hi there" }],
|
|
3802
3940
|
config: { model: "deepseek/deepseek-chat" },
|
|
3803
|
-
policy:
|
|
3941
|
+
policy: COMPANION_POLICY,
|
|
3804
3942
|
session: null,
|
|
3805
3943
|
cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
|
|
3806
3944
|
local: { cwd: DIR },
|
|
@@ -4148,13 +4286,19 @@ var CODEX_ADDENDUM = [
|
|
|
4148
4286
|
"as the turn\u2019s reply."
|
|
4149
4287
|
].join(" ");
|
|
4150
4288
|
var CODEX_ADDENDUM_CODE_MODE = [
|
|
4151
|
-
"Your
|
|
4152
|
-
"
|
|
4289
|
+
"Your Cabane workspace tool is exposed to Codex as the qualified MCP tool",
|
|
4290
|
+
"`mcp__cabane__sdk`. On Codex versions that defer MCP tools, locate it in the",
|
|
4291
|
+
"`functions.exec` deferred-tool inventory and invoke that exact qualified tool",
|
|
4292
|
+
"from the exec program; do not look for or call a bare top-level `sdk` tool.",
|
|
4293
|
+
"If discovery or an invocation fails, report the recorded tool error; never",
|
|
4294
|
+
"declare the SDK absent without attempting discovery and invocation. The SDK",
|
|
4295
|
+
"call runs a TypeScript program against the ambient `cabane` object. The",
|
|
4153
4296
|
"turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
|
|
4154
|
-
"
|
|
4297
|
+
"qualified `mcp__cabane_companion__\u2026` tools (and may be deferred too). There is",
|
|
4298
|
+
"no Cabane `read`/`write`/`search`/`edit` tool here \u2014 those",
|
|
4155
4299
|
"are `cabane` SDK calls inside your program, not tools. If a tool appears in this",
|
|
4156
|
-
"prompt with an `mcp__\u2026__` prefix, that
|
|
4157
|
-
"
|
|
4300
|
+
"prompt with an `mcp__\u2026__` prefix, preserve that qualified name. Write your",
|
|
4301
|
+
"closing reply as the last thing you say in the",
|
|
4158
4302
|
"turn: you can interleave narration with tool calls, but only your final message",
|
|
4159
4303
|
"is recorded as the turn\u2019s reply."
|
|
4160
4304
|
].join(" ");
|
|
@@ -4181,7 +4325,7 @@ function readItemMessage(item) {
|
|
|
4181
4325
|
function isModelMetadataError(message) {
|
|
4182
4326
|
return message.includes("Defaulting to fallback metadata");
|
|
4183
4327
|
}
|
|
4184
|
-
function readToolItem(item) {
|
|
4328
|
+
function readToolItem(item, eventType) {
|
|
4185
4329
|
const type = str(item.type);
|
|
4186
4330
|
const id = str(item.id);
|
|
4187
4331
|
if (!type || !id) return null;
|
|
@@ -4225,7 +4369,12 @@ function readToolItem(item) {
|
|
|
4225
4369
|
}
|
|
4226
4370
|
if (type === "web_search") {
|
|
4227
4371
|
const query = str(item.query) ?? "";
|
|
4228
|
-
return {
|
|
4372
|
+
return {
|
|
4373
|
+
id,
|
|
4374
|
+
name: "web_search",
|
|
4375
|
+
input: { query },
|
|
4376
|
+
status: eventType === "item.completed" ? "completed" : "in_progress"
|
|
4377
|
+
};
|
|
4229
4378
|
}
|
|
4230
4379
|
return null;
|
|
4231
4380
|
}
|
|
@@ -4235,9 +4384,9 @@ function readItemType(item) {
|
|
|
4235
4384
|
function readErrorMessage(ev) {
|
|
4236
4385
|
const direct = str(ev.message);
|
|
4237
4386
|
if (direct) return direct;
|
|
4238
|
-
const
|
|
4239
|
-
if (
|
|
4240
|
-
const m = str(
|
|
4387
|
+
const err2 = asRecord2(ev.error);
|
|
4388
|
+
if (err2) {
|
|
4389
|
+
const m = str(err2.message);
|
|
4241
4390
|
if (m) return m;
|
|
4242
4391
|
}
|
|
4243
4392
|
return "unknown";
|
|
@@ -4293,7 +4442,7 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4293
4442
|
const startedTools = /* @__PURE__ */ new Set();
|
|
4294
4443
|
const finishedTools = /* @__PURE__ */ new Set();
|
|
4295
4444
|
let sessionEmitted = false;
|
|
4296
|
-
let
|
|
4445
|
+
let ok2 = false;
|
|
4297
4446
|
let reason;
|
|
4298
4447
|
let usage;
|
|
4299
4448
|
let settled = false;
|
|
@@ -4324,7 +4473,7 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4324
4473
|
const message = readItemMessage(item);
|
|
4325
4474
|
if (isModelMetadataError(message)) {
|
|
4326
4475
|
yield* flushInterim();
|
|
4327
|
-
|
|
4476
|
+
ok2 = false;
|
|
4328
4477
|
reason = `model_unavailable:${message.slice(0, 200)}`;
|
|
4329
4478
|
settled = true;
|
|
4330
4479
|
break;
|
|
@@ -4343,7 +4492,7 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4343
4492
|
if (text) yield { type: "thinking", text };
|
|
4344
4493
|
continue;
|
|
4345
4494
|
}
|
|
4346
|
-
const tool2 = readToolItem(item);
|
|
4495
|
+
const tool2 = readToolItem(item, ev.type);
|
|
4347
4496
|
if (!tool2) continue;
|
|
4348
4497
|
yield* flushInterim();
|
|
4349
4498
|
const name = prettyToolName(tool2.name);
|
|
@@ -4379,13 +4528,13 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4379
4528
|
held = null;
|
|
4380
4529
|
if (sealed) yield sealed;
|
|
4381
4530
|
usage = readUsage(ev);
|
|
4382
|
-
|
|
4531
|
+
ok2 = true;
|
|
4383
4532
|
settled = true;
|
|
4384
4533
|
break;
|
|
4385
4534
|
}
|
|
4386
4535
|
if (ev.type === "turn.failed" || ev.type === "error") {
|
|
4387
4536
|
yield* flushInterim();
|
|
4388
|
-
|
|
4537
|
+
ok2 = false;
|
|
4389
4538
|
const text = readErrorMessage(ev);
|
|
4390
4539
|
const failure = classifyErrorText(text);
|
|
4391
4540
|
reason = failure ? encodeFailureReason(failure) : `error:${text.slice(0, 200)}`;
|
|
@@ -4403,7 +4552,7 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4403
4552
|
const resolvedConfig = ctx.resolvedReasoningEffort ? { reasoningEffort: ctx.resolvedReasoningEffort } : void 0;
|
|
4404
4553
|
yield {
|
|
4405
4554
|
type: "result",
|
|
4406
|
-
ok,
|
|
4555
|
+
ok: ok2,
|
|
4407
4556
|
...reason ? { reason } : {},
|
|
4408
4557
|
...usage ? { usage } : {},
|
|
4409
4558
|
...ctx.resolvedModel ? { resolvedModel: ctx.resolvedModel } : {},
|
|
@@ -4423,12 +4572,15 @@ function sealHeld2(held, terminal) {
|
|
|
4423
4572
|
// packages/agent-runtime/src/codex/policy.ts
|
|
4424
4573
|
import { z as z12 } from "zod";
|
|
4425
4574
|
function codexToolPolicy(policy) {
|
|
4426
|
-
return {
|
|
4427
|
-
|
|
4575
|
+
return policy.hostFs ? {
|
|
4576
|
+
permissionProfile: "cabane-coding",
|
|
4577
|
+
approvalPolicy: "never",
|
|
4578
|
+
networkAccessEnabled: policy.web
|
|
4579
|
+
} : {
|
|
4580
|
+
sandboxMode: "read-only",
|
|
4428
4581
|
// Headless: the sandbox is the boundary; never pause for a human.
|
|
4429
4582
|
approvalPolicy: "never",
|
|
4430
|
-
//
|
|
4431
|
-
// Codex's shell commands may reach the network.
|
|
4583
|
+
// Retained in the policy value for symmetry; read-only ignores it.
|
|
4432
4584
|
networkAccessEnabled: policy.web
|
|
4433
4585
|
};
|
|
4434
4586
|
}
|
|
@@ -4451,6 +4603,7 @@ function parseCodexModel(model) {
|
|
|
4451
4603
|
|
|
4452
4604
|
// packages/agent-runtime/src/codex/run-spec.ts
|
|
4453
4605
|
var CABANE_MCP_SERVER3 = "cabane";
|
|
4606
|
+
var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
|
|
4454
4607
|
var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
|
|
4455
4608
|
function buildRunSpec2(req, resumeThreadId) {
|
|
4456
4609
|
const { policy, config } = req;
|
|
@@ -4481,13 +4634,15 @@ function buildConfig(req) {
|
|
|
4481
4634
|
if ("url" in server) {
|
|
4482
4635
|
mcp_servers[name] = {
|
|
4483
4636
|
url: server.url,
|
|
4484
|
-
...server.headers ? { http_headers: server.headers } : {}
|
|
4637
|
+
...server.headers ? { http_headers: server.headers } : {},
|
|
4638
|
+
default_tools_approval_mode: "approve"
|
|
4485
4639
|
};
|
|
4486
4640
|
} else if ("command" in server) {
|
|
4487
4641
|
mcp_servers[name] = {
|
|
4488
4642
|
command: server.command,
|
|
4489
4643
|
...server.args ? { args: server.args } : {},
|
|
4490
|
-
...server.env ? { env: server.env } : {}
|
|
4644
|
+
...server.env ? { env: server.env } : {},
|
|
4645
|
+
default_tools_approval_mode: "approve"
|
|
4491
4646
|
};
|
|
4492
4647
|
}
|
|
4493
4648
|
}
|
|
@@ -4496,12 +4651,14 @@ function buildConfig(req) {
|
|
|
4496
4651
|
if (typeof server.url === "string") {
|
|
4497
4652
|
mcp_servers[name] = {
|
|
4498
4653
|
url: server.url,
|
|
4499
|
-
...isStringRecord2(server.headers) ? { http_headers: server.headers } : {}
|
|
4654
|
+
...isStringRecord2(server.headers) ? { http_headers: server.headers } : {},
|
|
4655
|
+
default_tools_approval_mode: "approve"
|
|
4500
4656
|
};
|
|
4501
4657
|
} else if (typeof server.command === "string") {
|
|
4502
4658
|
mcp_servers[name] = {
|
|
4503
4659
|
command: server.command,
|
|
4504
|
-
...Array.isArray(server.args) ? { args: server.args } : {}
|
|
4660
|
+
...Array.isArray(server.args) ? { args: server.args } : {},
|
|
4661
|
+
default_tools_approval_mode: "approve"
|
|
4505
4662
|
};
|
|
4506
4663
|
}
|
|
4507
4664
|
}
|
|
@@ -4513,13 +4670,46 @@ function buildConfig(req) {
|
|
|
4513
4670
|
},
|
|
4514
4671
|
default_tools_approval_mode: "approve"
|
|
4515
4672
|
};
|
|
4516
|
-
|
|
4673
|
+
if (req.cabane.turnControlUrl) {
|
|
4674
|
+
mcp_servers[TURN_CONTROL_MCP_SERVER2] = {
|
|
4675
|
+
url: req.cabane.turnControlUrl,
|
|
4676
|
+
http_headers: {
|
|
4677
|
+
Authorization: `Bearer ${req.cabane.bearer}`,
|
|
4678
|
+
[ACTIVE_CONVERSATION_HEADER4]: req.cabane.activeConversationId
|
|
4679
|
+
},
|
|
4680
|
+
default_tools_approval_mode: "approve"
|
|
4681
|
+
};
|
|
4682
|
+
}
|
|
4683
|
+
const policy = codexToolPolicy(req.policy);
|
|
4684
|
+
return {
|
|
4685
|
+
mcp_servers,
|
|
4686
|
+
experimental_use_rmcp_client: true,
|
|
4687
|
+
...policy.permissionProfile ? {
|
|
4688
|
+
// CT733: named permission profiles are Codex's split-filesystem path.
|
|
4689
|
+
// `:root = read` preserves coding-mode host reads; the one explicit
|
|
4690
|
+
// workspace-root write grants the checkout, and the more-specific
|
|
4691
|
+
// `.git` write reopens the metadata Codex protects by default. Neither
|
|
4692
|
+
// rule grants an adjacent directory. Do not combine this with legacy `sandbox_mode` /
|
|
4693
|
+
// `sandbox_workspace_write`, which would restore the `.git` carve-out.
|
|
4694
|
+
approval_policy: policy.approvalPolicy,
|
|
4695
|
+
default_permissions: policy.permissionProfile,
|
|
4696
|
+
permissions: {
|
|
4697
|
+
[policy.permissionProfile]: {
|
|
4698
|
+
filesystem: {
|
|
4699
|
+
":root": "read",
|
|
4700
|
+
":workspace_roots": { ".": "write", ".git": "write" }
|
|
4701
|
+
},
|
|
4702
|
+
network: { enabled: policy.networkAccessEnabled, mode: "full" }
|
|
4703
|
+
}
|
|
4704
|
+
}
|
|
4705
|
+
} : {}
|
|
4706
|
+
};
|
|
4517
4707
|
}
|
|
4518
4708
|
function isStringRecord2(v) {
|
|
4519
4709
|
return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
|
|
4520
4710
|
}
|
|
4521
4711
|
|
|
4522
|
-
// node_modules/.pnpm/@openai+codex-sdk@0.
|
|
4712
|
+
// node_modules/.pnpm/@openai+codex-sdk@0.146.0/node_modules/@openai/codex-sdk/dist/index.js
|
|
4523
4713
|
import { promises as fs } from "fs";
|
|
4524
4714
|
import os from "os";
|
|
4525
4715
|
import path from "path";
|
|
@@ -4608,6 +4798,8 @@ var Thread = class {
|
|
|
4608
4798
|
}
|
|
4609
4799
|
if (parsed.type === "thread.started") {
|
|
4610
4800
|
this._id = parsed.thread_id;
|
|
4801
|
+
} else if (parsed.type === "turn.completed") {
|
|
4802
|
+
parsed.usage.cache_write_input_tokens ??= 0;
|
|
4611
4803
|
}
|
|
4612
4804
|
yield parsed;
|
|
4613
4805
|
}
|
|
@@ -4769,7 +4961,7 @@ var CodexExec = class {
|
|
|
4769
4961
|
signal: args.signal
|
|
4770
4962
|
});
|
|
4771
4963
|
let spawnError = null;
|
|
4772
|
-
child.once("error", (
|
|
4964
|
+
child.once("error", (err2) => spawnError = err2);
|
|
4773
4965
|
if (!child.stdin) {
|
|
4774
4966
|
child.kill();
|
|
4775
4967
|
throw new Error("Child process has no stdin");
|
|
@@ -5047,6 +5239,17 @@ var Codex = class {
|
|
|
5047
5239
|
};
|
|
5048
5240
|
|
|
5049
5241
|
// packages/agent-runtime/src/codex/transport.ts
|
|
5242
|
+
function buildSdkThreadOptions(spec) {
|
|
5243
|
+
return {
|
|
5244
|
+
...spec.model ? { model: spec.model } : {},
|
|
5245
|
+
...spec.policy.sandboxMode ? { sandboxMode: spec.policy.sandboxMode } : {},
|
|
5246
|
+
workingDirectory: spec.directory,
|
|
5247
|
+
skipGitRepoCheck: spec.skipGitRepoCheck,
|
|
5248
|
+
...spec.policy.sandboxMode ? { approvalPolicy: spec.policy.approvalPolicy } : {},
|
|
5249
|
+
...spec.policy.sandboxMode === "workspace-write" ? { networkAccessEnabled: spec.policy.networkAccessEnabled } : {},
|
|
5250
|
+
...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
|
|
5251
|
+
};
|
|
5252
|
+
}
|
|
5050
5253
|
function createSdkCodexTransport(opts = {}) {
|
|
5051
5254
|
return {
|
|
5052
5255
|
async run(spec, signal) {
|
|
@@ -5060,17 +5263,7 @@ function createSdkCodexTransport(opts = {}) {
|
|
|
5060
5263
|
config: spec.config
|
|
5061
5264
|
};
|
|
5062
5265
|
const codex = new Codex(codexOptions);
|
|
5063
|
-
const threadOptions =
|
|
5064
|
-
...spec.model ? { model: spec.model } : {},
|
|
5065
|
-
sandboxMode: spec.policy.sandboxMode,
|
|
5066
|
-
workingDirectory: spec.directory,
|
|
5067
|
-
skipGitRepoCheck: spec.skipGitRepoCheck,
|
|
5068
|
-
approvalPolicy: spec.policy.approvalPolicy,
|
|
5069
|
-
// `networkAccessEnabled` only bites under `workspace-write` (read-only
|
|
5070
|
-
// denies command network regardless); set it there off the `web` grant.
|
|
5071
|
-
...spec.policy.sandboxMode === "workspace-write" ? { networkAccessEnabled: spec.policy.networkAccessEnabled } : {},
|
|
5072
|
-
...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
|
|
5073
|
-
};
|
|
5266
|
+
const threadOptions = buildSdkThreadOptions(spec);
|
|
5074
5267
|
const thread = spec.resumeThreadId ? codex.resumeThread(spec.resumeThreadId, threadOptions) : codex.startThread(threadOptions);
|
|
5075
5268
|
const streamed = await thread.runStreamed(spec.input, { signal });
|
|
5076
5269
|
return { events: streamed.events };
|
|
@@ -5127,7 +5320,7 @@ var codexAdapter = createCodexAdapter();
|
|
|
5127
5320
|
// packages/agent-runtime/src/codex/conformance.ts
|
|
5128
5321
|
var ABORT_SENTINEL3 = { __abortHere: true };
|
|
5129
5322
|
var NEW_THREAD_ID = "th_new";
|
|
5130
|
-
var
|
|
5323
|
+
var COMPANION_POLICY2 = {
|
|
5131
5324
|
hostFs: false,
|
|
5132
5325
|
web: true,
|
|
5133
5326
|
browser: true,
|
|
@@ -5148,7 +5341,7 @@ function makeRequest3(overrides = {}) {
|
|
|
5148
5341
|
// emitted → their expected results stay unchanged; a dedicated capture fixture
|
|
5149
5342
|
// sets a real model + effort.
|
|
5150
5343
|
config: { model: null },
|
|
5151
|
-
policy:
|
|
5344
|
+
policy: COMPANION_POLICY2,
|
|
5152
5345
|
session: null,
|
|
5153
5346
|
cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
|
|
5154
5347
|
local: { cwd: DIR2 },
|
|
@@ -5312,6 +5505,75 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
5312
5505
|
{ type: "result", ok: true }
|
|
5313
5506
|
]
|
|
5314
5507
|
},
|
|
5508
|
+
{
|
|
5509
|
+
// CT715: web_search LIFECYCLE. Unlike the other three tool kinds, a `web_search`
|
|
5510
|
+
// item carries NO `status` field — completion is signaled by the frame TYPE
|
|
5511
|
+
// (`item.started` → `item.completed`). The `start` fires off the first frame
|
|
5512
|
+
// (empty query, no output card); the `done` must resolve off `item.completed`
|
|
5513
|
+
// and carry the populated query the completed frame filled in. Before CT715 the
|
|
5514
|
+
// card was pinned at "running" forever (status derived from a missing field).
|
|
5515
|
+
name: "web_search lifecycle \u2014 completes off frame type, populated query on done",
|
|
5516
|
+
request: makeRequest3(),
|
|
5517
|
+
nativeStream: [
|
|
5518
|
+
threadStarted(NEW_THREAD_ID),
|
|
5519
|
+
toolFrame("item.started", { id: "ws1", type: "web_search", query: "" }),
|
|
5520
|
+
toolFrame("item.completed", { id: "ws1", type: "web_search", query: "best pizza in nyc" }),
|
|
5521
|
+
turnCompleted()
|
|
5522
|
+
],
|
|
5523
|
+
expected: [
|
|
5524
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
5525
|
+
{
|
|
5526
|
+
type: "tool",
|
|
5527
|
+
id: "ws1",
|
|
5528
|
+
name: "web_search",
|
|
5529
|
+
phase: "start",
|
|
5530
|
+
summary: "",
|
|
5531
|
+
input: { query: "" }
|
|
5532
|
+
},
|
|
5533
|
+
{
|
|
5534
|
+
type: "tool",
|
|
5535
|
+
id: "ws1",
|
|
5536
|
+
name: "web_search",
|
|
5537
|
+
phase: "done",
|
|
5538
|
+
summary: "best pizza in nyc",
|
|
5539
|
+
input: { query: "best pizza in nyc" }
|
|
5540
|
+
},
|
|
5541
|
+
{ type: "result", ok: true }
|
|
5542
|
+
]
|
|
5543
|
+
},
|
|
5544
|
+
{
|
|
5545
|
+
// CT715: a non-text web action (`action.type: "other"`) legitimately completes
|
|
5546
|
+
// with an EMPTY query — that's Codex's own data, not our bug. It must still
|
|
5547
|
+
// resolve to `done` (empty query acceptable; stuck-running is not).
|
|
5548
|
+
name: "web_search lifecycle \u2014 empty-query completion still resolves to done",
|
|
5549
|
+
request: makeRequest3(),
|
|
5550
|
+
nativeStream: [
|
|
5551
|
+
threadStarted(NEW_THREAD_ID),
|
|
5552
|
+
toolFrame("item.started", { id: "ws2", type: "web_search", query: "" }),
|
|
5553
|
+
toolFrame("item.completed", { id: "ws2", type: "web_search", query: "" }),
|
|
5554
|
+
turnCompleted()
|
|
5555
|
+
],
|
|
5556
|
+
expected: [
|
|
5557
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
5558
|
+
{
|
|
5559
|
+
type: "tool",
|
|
5560
|
+
id: "ws2",
|
|
5561
|
+
name: "web_search",
|
|
5562
|
+
phase: "start",
|
|
5563
|
+
summary: "",
|
|
5564
|
+
input: { query: "" }
|
|
5565
|
+
},
|
|
5566
|
+
{
|
|
5567
|
+
type: "tool",
|
|
5568
|
+
id: "ws2",
|
|
5569
|
+
name: "web_search",
|
|
5570
|
+
phase: "done",
|
|
5571
|
+
summary: "",
|
|
5572
|
+
input: { query: "" }
|
|
5573
|
+
},
|
|
5574
|
+
{ type: "result", ok: true }
|
|
5575
|
+
]
|
|
5576
|
+
},
|
|
5315
5577
|
{
|
|
5316
5578
|
// HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
|
|
5317
5579
|
// adapter stops before the closing reply — no final text, no `result` event.
|
|
@@ -5606,15 +5868,35 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
5606
5868
|
// packages/agent-runtime/src/cabane-native/addendum.ts
|
|
5607
5869
|
var CABANE_NATIVE_ADDENDUM = `## Your tools (native runtime)
|
|
5608
5870
|
|
|
5609
|
-
You are running on Cabane's own agent runtime. You have a small,
|
|
5871
|
+
You are running on Cabane's own agent runtime. You have a small, curated set of workspace tools, all prefixed \`cabane_\`:
|
|
5610
5872
|
|
|
5611
5873
|
- \`cabane_list\` \u2014 list a folder's files and subfolders.
|
|
5612
5874
|
- \`cabane_read\` \u2014 read one file's contents by path.
|
|
5613
|
-
- \`cabane_search\` \u2014 substring search across file names and
|
|
5875
|
+
- \`cabane_search\` \u2014 substring search across file/folder names, file contents, and conversations.
|
|
5614
5876
|
- \`cabane_write\` \u2014 create or overwrite a file (pass \`overwrite: true\` to replace).
|
|
5615
5877
|
- \`cabane_edit\` \u2014 find/replace inside an existing file.
|
|
5878
|
+
- \`cabane_mkdir\` \u2014 create a folder (pass \`recursive: true\` to also make missing parents).
|
|
5879
|
+
- \`cabane_move\` \u2014 move or rename a file or folder.
|
|
5880
|
+
- \`cabane_delete\` \u2014 delete a file or folder (folders need \`recursive: true\`; soft-delete, recoverable).
|
|
5881
|
+
- \`cabane_context\` \u2014 gather an object plus its related context (files, conversations, people) in one call \u2014 your first move to understand what surrounds something.
|
|
5882
|
+
- \`cabane_messages\` \u2014 read a conversation's messages by id (from \`cabane_search\` / \`cabane_context\`).
|
|
5883
|
+
|
|
5884
|
+
Paths are workspace-relative with a leading slash (\`/notes/todo.md\`). This is a deliberately curated tool set \u2014 if a job needs an operation that isn't here, say so plainly in your reply rather than inventing a tool. Your reply text is streamed straight into the conversation; there is no separate send step.`;
|
|
5885
|
+
var CABANE_NATIVE_ADDENDUM_CODE_MODE = `## Your tools (native runtime \u2014 code mode)
|
|
5886
|
+
|
|
5887
|
+
You are running on Cabane's own agent runtime, in **code mode**. Your one workspace tool is \`sdk\`: you act on the workspace by writing a short TypeScript program against the ambient \`cabane\` object and submitting it as the \`code\` argument. Only what your program \`return\`s (plus \`console.log\`) comes back \u2014 so gather, filter, and summarize *inside* the program and return the small answer, not the raw material.
|
|
5616
5888
|
|
|
5617
|
-
|
|
5889
|
+
There is **no** \`cabane_read\` / \`cabane_write\` / \`cabane_search\` / \`cabane_edit\` tool \u2014 those are \`cabane\` SDK calls *inside* your program (\`cabane.files.read(path)\`, \`cabane.find.search(q)\`, \u2026), not tools you call directly. The full SDK surface \u2014 every method and its options \u2014 is documented below this note in your prompt.
|
|
5890
|
+
|
|
5891
|
+
Beside \`sdk\` you also have a few **plain-named** tools that act on the TURN itself, not the workspace (they can't be a program's \`return\` value):
|
|
5892
|
+
- \`ask\` \u2014 put a structured question to a human and END your turn (they may answer in days).
|
|
5893
|
+
- \`wake_me\` \u2014 end this turn now and be re-dispatched later to check a condition (a PR merging, a reply landing).
|
|
5894
|
+
- \`summon_agent\` \u2014 pull a peer agent into THIS conversation to reply on your turn.
|
|
5895
|
+
- \`sub_agent\` \u2014 spawn a private worker with a fresh context window; its result comes back here later (don't wait \u2014 end your turn).
|
|
5896
|
+
- \`skip_turn\` \u2014 end your turn with NO reply, when the message doesn't need one from you.
|
|
5897
|
+
- \`mint_render_token\` \u2014 mint a short-lived credential to load a workspace HTML page in a browser.
|
|
5898
|
+
|
|
5899
|
+
Your reply text is streamed straight into the conversation; there is no separate send step.`;
|
|
5618
5900
|
|
|
5619
5901
|
// packages/agent-runtime/src/cabane-native/context.ts
|
|
5620
5902
|
var DEFAULT_HISTORY_LIMIT = 20;
|
|
@@ -5679,6 +5961,18 @@ function parseCabaneNativeModel(model) {
|
|
|
5679
5961
|
return model.startsWith(CABANE_NATIVE_MODEL_PREFIX) ? model.slice(CABANE_NATIVE_MODEL_PREFIX.length) : model;
|
|
5680
5962
|
}
|
|
5681
5963
|
|
|
5964
|
+
// packages/agent-runtime/src/cabane-native/policy.ts
|
|
5965
|
+
import { z as z13 } from "zod";
|
|
5966
|
+
var NATIVE_SURFACES = ["code", "classic"];
|
|
5967
|
+
var cabaneNativeDialectSchema = z13.object({ surface: z13.enum(NATIVE_SURFACES).optional() }).loose();
|
|
5968
|
+
function readCabaneNativeDialect(runtimeOptions) {
|
|
5969
|
+
const parsed = cabaneNativeDialectSchema.safeParse(runtimeOptions?.["cabane-native"] ?? {});
|
|
5970
|
+
return parsed.success ? parsed.data : {};
|
|
5971
|
+
}
|
|
5972
|
+
function cabaneNativeSurface(runtimeOptions) {
|
|
5973
|
+
return readCabaneNativeDialect(runtimeOptions).surface ?? "code";
|
|
5974
|
+
}
|
|
5975
|
+
|
|
5682
5976
|
// packages/agent-runtime/src/cabane-native/tools.ts
|
|
5683
5977
|
var TOOL_RESULT_MAX_CHARS = 8e3;
|
|
5684
5978
|
var CABANE_NATIVE_TOOLS = [
|
|
@@ -5762,11 +6056,157 @@ var CABANE_NATIVE_TOOLS = [
|
|
|
5762
6056
|
required: ["path", "find", "replace"]
|
|
5763
6057
|
}
|
|
5764
6058
|
}
|
|
6059
|
+
},
|
|
6060
|
+
{
|
|
6061
|
+
type: "function",
|
|
6062
|
+
function: {
|
|
6063
|
+
name: "cabane_mkdir",
|
|
6064
|
+
description: "Create a folder at a workspace path. Pass `recursive: true` to also create any missing parent folders (like `mkdir -p`); otherwise the parent must already exist.",
|
|
6065
|
+
parameters: {
|
|
6066
|
+
type: "object",
|
|
6067
|
+
properties: {
|
|
6068
|
+
path: { type: "string", description: "Workspace folder path, e.g. /notes/archive." },
|
|
6069
|
+
recursive: {
|
|
6070
|
+
type: "boolean",
|
|
6071
|
+
description: "Create missing parent folders too (default false)."
|
|
6072
|
+
}
|
|
6073
|
+
},
|
|
6074
|
+
required: ["path"]
|
|
6075
|
+
}
|
|
6076
|
+
}
|
|
6077
|
+
},
|
|
6078
|
+
{
|
|
6079
|
+
type: "function",
|
|
6080
|
+
function: {
|
|
6081
|
+
name: "cabane_move",
|
|
6082
|
+
description: "Move and/or rename a file or folder. `fromPath` is the current path; `toPath` is the new full path (its parent must already exist). Same parent + new name renames in place; a different parent moves it.",
|
|
6083
|
+
parameters: {
|
|
6084
|
+
type: "object",
|
|
6085
|
+
properties: {
|
|
6086
|
+
fromPath: { type: "string", description: "The current file/folder path." },
|
|
6087
|
+
toPath: { type: "string", description: "The new full path (parent + name)." }
|
|
6088
|
+
},
|
|
6089
|
+
required: ["fromPath", "toPath"]
|
|
6090
|
+
}
|
|
6091
|
+
}
|
|
6092
|
+
},
|
|
6093
|
+
{
|
|
6094
|
+
type: "function",
|
|
6095
|
+
function: {
|
|
6096
|
+
name: "cabane_delete",
|
|
6097
|
+
description: "Delete the file or folder at a workspace path. Folders require `recursive: true` (deletes everything inside). Soft delete \u2014 the entry is recoverable from the trash, not erased.",
|
|
6098
|
+
parameters: {
|
|
6099
|
+
type: "object",
|
|
6100
|
+
properties: {
|
|
6101
|
+
path: { type: "string", description: "Workspace file/folder path to delete." },
|
|
6102
|
+
recursive: {
|
|
6103
|
+
type: "boolean",
|
|
6104
|
+
description: "Required to delete a non-empty folder (default false)."
|
|
6105
|
+
}
|
|
6106
|
+
},
|
|
6107
|
+
required: ["path"]
|
|
6108
|
+
}
|
|
6109
|
+
}
|
|
6110
|
+
},
|
|
6111
|
+
{
|
|
6112
|
+
type: "function",
|
|
6113
|
+
function: {
|
|
6114
|
+
name: "cabane_context",
|
|
6115
|
+
description: "Gather a workspace object plus its most relevant connected context \u2014 related files, conversations, people \u2014 in one call. Your first move to understand what surrounds something before acting. `ref` is the object id, or its workspace path when `kind` is `file` or `folder`.",
|
|
6116
|
+
parameters: {
|
|
6117
|
+
type: "object",
|
|
6118
|
+
properties: {
|
|
6119
|
+
kind: {
|
|
6120
|
+
type: "string",
|
|
6121
|
+
enum: ["conversation", "file", "folder", "user", "agent", "channel"],
|
|
6122
|
+
description: "The kind of object to orient around."
|
|
6123
|
+
},
|
|
6124
|
+
ref: {
|
|
6125
|
+
type: "string",
|
|
6126
|
+
description: "The object id (or the workspace path for a file/folder)."
|
|
6127
|
+
}
|
|
6128
|
+
},
|
|
6129
|
+
required: ["kind", "ref"]
|
|
6130
|
+
}
|
|
6131
|
+
}
|
|
6132
|
+
},
|
|
6133
|
+
{
|
|
6134
|
+
type: "function",
|
|
6135
|
+
function: {
|
|
6136
|
+
name: "cabane_messages",
|
|
6137
|
+
description: 'Read a page of messages from a conversation by id (get ids from `cabane_search` or `cabane_context`). `order: "desc"` (default) returns newest-first \u2014 where a thread is now; `order: "asc"` returns oldest-first \u2014 how it began. Your own current thread is already in context; use this to catch up on other conversations or to page further back.',
|
|
6138
|
+
parameters: {
|
|
6139
|
+
type: "object",
|
|
6140
|
+
properties: {
|
|
6141
|
+
conversationId: { type: "string", description: "The conversation id to read." },
|
|
6142
|
+
order: {
|
|
6143
|
+
type: "string",
|
|
6144
|
+
enum: ["asc", "desc"],
|
|
6145
|
+
description: "newest-first (desc, default) or oldest-first (asc)."
|
|
6146
|
+
},
|
|
6147
|
+
limit: {
|
|
6148
|
+
type: "number",
|
|
6149
|
+
description: "Max messages to return (default 50, max 100)."
|
|
6150
|
+
}
|
|
6151
|
+
},
|
|
6152
|
+
required: ["conversationId"]
|
|
6153
|
+
}
|
|
6154
|
+
}
|
|
5765
6155
|
}
|
|
5766
6156
|
];
|
|
6157
|
+
var CABANE_NATIVE_CODE_TOOL = {
|
|
6158
|
+
type: "function",
|
|
6159
|
+
function: {
|
|
6160
|
+
name: "sdk",
|
|
6161
|
+
description: "Run a TypeScript program against the workspace. `code` is the program \u2014 the body of an async function, so top-level `await` works and `return <value>` produces the result. Write against the ambient `cabane` object (no imports); only what you `return` plus `console.log` comes back, so gather/filter/summarize inside the program and return the small answer. The full `cabane` SDK surface \u2014 every method and its options \u2014 is documented in your system prompt above.",
|
|
6162
|
+
parameters: {
|
|
6163
|
+
type: "object",
|
|
6164
|
+
properties: {
|
|
6165
|
+
code: {
|
|
6166
|
+
type: "string",
|
|
6167
|
+
description: "The TypeScript program to run (an async function body)."
|
|
6168
|
+
}
|
|
6169
|
+
},
|
|
6170
|
+
required: ["code"]
|
|
6171
|
+
}
|
|
6172
|
+
}
|
|
6173
|
+
};
|
|
6174
|
+
var CABANE_NATIVE_RENDER_TOKEN_TOOL = {
|
|
6175
|
+
type: "function",
|
|
6176
|
+
function: {
|
|
6177
|
+
name: "mint_render_token",
|
|
6178
|
+
description: "Mint a short-lived, scoped credential that lets a browser load a workspace HTML page at the content-isolation origin. Returns `{ url, cookieName, cookieValue, expiresAt, pathPrefix }`. `pathPrefix` (default `/`) narrows the grant to a folder or single file; `ttlSeconds` (default 30 min, max 86400) overrides the TTL.",
|
|
6179
|
+
parameters: {
|
|
6180
|
+
type: "object",
|
|
6181
|
+
properties: {
|
|
6182
|
+
pathPrefix: {
|
|
6183
|
+
type: "string",
|
|
6184
|
+
description: "Workspace-rooted path-prefix the token authorizes; `/` for whole-workspace."
|
|
6185
|
+
},
|
|
6186
|
+
ttlSeconds: {
|
|
6187
|
+
type: "number",
|
|
6188
|
+
description: "Override the default 30-minute TTL; capped at 24h (86400s)."
|
|
6189
|
+
}
|
|
6190
|
+
}
|
|
6191
|
+
}
|
|
6192
|
+
}
|
|
6193
|
+
};
|
|
5767
6194
|
function summarizeCabaneToolArgs(name, args) {
|
|
5768
|
-
if (name === "
|
|
5769
|
-
|
|
6195
|
+
if (name === "mint_render_token") return str2(args.pathPrefix) ?? "/";
|
|
6196
|
+
if (name === "sdk") {
|
|
6197
|
+
const code = str2(args.code) ?? "";
|
|
6198
|
+
const firstLine = code.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
6199
|
+
return firstLine.length > 80 ? `${firstLine.slice(0, 80)}\u2026` : firstLine;
|
|
6200
|
+
}
|
|
6201
|
+
if (name === "cabane_search") return str2(args.q) ?? "";
|
|
6202
|
+
if (name === "cabane_move") {
|
|
6203
|
+
const from = str2(args.fromPath) ?? "";
|
|
6204
|
+
const to = str2(args.toPath) ?? "";
|
|
6205
|
+
return from && to ? `${from} \u2192 ${to}` : from || to;
|
|
6206
|
+
}
|
|
6207
|
+
if (name === "cabane_context") return str2(args.ref) ?? "";
|
|
6208
|
+
if (name === "cabane_messages") return str2(args.conversationId) ?? "";
|
|
6209
|
+
return str2(args.path) ?? "";
|
|
5770
6210
|
}
|
|
5771
6211
|
async function executeCabaneNativeTool(name, args, ctx, signal) {
|
|
5772
6212
|
const doFetch = ctx.fetchImpl ?? fetch;
|
|
@@ -5785,10 +6225,10 @@ async function executeCabaneNativeTool(name, args, ctx, signal) {
|
|
|
5785
6225
|
...init2?.body !== void 0 ? { body: JSON.stringify(init2.body) } : {},
|
|
5786
6226
|
signal
|
|
5787
6227
|
});
|
|
5788
|
-
} catch (
|
|
6228
|
+
} catch (err2) {
|
|
5789
6229
|
return {
|
|
5790
6230
|
ok: false,
|
|
5791
|
-
result: `error: request failed: ${
|
|
6231
|
+
result: `error: request failed: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
5792
6232
|
};
|
|
5793
6233
|
}
|
|
5794
6234
|
const contentType = res.headers.get("content-type") ?? "";
|
|
@@ -5797,6 +6237,20 @@ async function executeCabaneNativeTool(name, args, ctx, signal) {
|
|
|
5797
6237
|
return { ok: true, result: truncate2(text) };
|
|
5798
6238
|
};
|
|
5799
6239
|
switch (name) {
|
|
6240
|
+
case "sdk":
|
|
6241
|
+
return call("POST", "/sdk", {
|
|
6242
|
+
body: {
|
|
6243
|
+
program: str2(args.code) ?? "",
|
|
6244
|
+
...ctx.conversationId ? { activeConversationId: ctx.conversationId } : {}
|
|
6245
|
+
}
|
|
6246
|
+
});
|
|
6247
|
+
case "mint_render_token":
|
|
6248
|
+
return call("POST", "/render-tokens", {
|
|
6249
|
+
body: {
|
|
6250
|
+
pathPrefix: str2(args.pathPrefix) ?? "/",
|
|
6251
|
+
...typeof args.ttlSeconds === "number" ? { ttlSeconds: args.ttlSeconds } : {}
|
|
6252
|
+
}
|
|
6253
|
+
});
|
|
5800
6254
|
case "cabane_list":
|
|
5801
6255
|
return call("GET", "/files/tree", { query: { path: str2(args.path) ?? "/" } });
|
|
5802
6256
|
case "cabane_read":
|
|
@@ -5820,6 +6274,39 @@ async function executeCabaneNativeTool(name, args, ctx, signal) {
|
|
|
5820
6274
|
...args.replaceAll === true ? { replaceAll: true } : {}
|
|
5821
6275
|
}
|
|
5822
6276
|
});
|
|
6277
|
+
case "cabane_mkdir":
|
|
6278
|
+
return call("POST", "/folders", {
|
|
6279
|
+
body: {
|
|
6280
|
+
path: str2(args.path),
|
|
6281
|
+
...args.recursive === true ? { recursive: true } : {}
|
|
6282
|
+
}
|
|
6283
|
+
});
|
|
6284
|
+
case "cabane_move":
|
|
6285
|
+
return call("POST", "/entries/move", {
|
|
6286
|
+
body: { fromPath: str2(args.fromPath), toPath: str2(args.toPath) }
|
|
6287
|
+
});
|
|
6288
|
+
case "cabane_delete":
|
|
6289
|
+
return call("DELETE", "/entries", {
|
|
6290
|
+
query: {
|
|
6291
|
+
path: str2(args.path),
|
|
6292
|
+
recursive: args.recursive === true ? "true" : void 0
|
|
6293
|
+
}
|
|
6294
|
+
});
|
|
6295
|
+
case "cabane_context":
|
|
6296
|
+
return call("POST", "/graph/query", {
|
|
6297
|
+
body: { preset: "orient", args: { kind: str2(args.kind), ref: str2(args.ref) } }
|
|
6298
|
+
});
|
|
6299
|
+
case "cabane_messages":
|
|
6300
|
+
return call(
|
|
6301
|
+
"GET",
|
|
6302
|
+
`/conversations/${encodeURIComponent(str2(args.conversationId) ?? "")}/messages`,
|
|
6303
|
+
{
|
|
6304
|
+
query: {
|
|
6305
|
+
order: str2(args.order),
|
|
6306
|
+
limit: typeof args.limit === "number" ? String(args.limit) : void 0
|
|
6307
|
+
}
|
|
6308
|
+
}
|
|
6309
|
+
);
|
|
5823
6310
|
default:
|
|
5824
6311
|
return { ok: false, result: `error: unknown tool "${name}"` };
|
|
5825
6312
|
}
|
|
@@ -5832,6 +6319,238 @@ function truncate2(s) {
|
|
|
5832
6319
|
\u2026 [truncated]` : s;
|
|
5833
6320
|
}
|
|
5834
6321
|
|
|
6322
|
+
// packages/agent-runtime/src/cabane-native/turn-control.ts
|
|
6323
|
+
function describeSubAgentError(status, body) {
|
|
6324
|
+
const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
|
|
6325
|
+
switch (code) {
|
|
6326
|
+
case "callout_cap_exceeded":
|
|
6327
|
+
return "sub_agent: you already have the maximum open sub-agents for this thread. Wait for some to return \u2014 you're woken once they're all back \u2014 before spawning more.";
|
|
6328
|
+
case "callout_depth_exceeded":
|
|
6329
|
+
return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
|
|
6330
|
+
case "dispatch_agent_not_found":
|
|
6331
|
+
return "sub_agent: no live agent in this workspace matches that `agentId`. Check `list_agents`, or omit `agentId` to spawn yourself.";
|
|
6332
|
+
case "dispatch_return_requires_turn":
|
|
6333
|
+
case "dispatch_return_requires_agent":
|
|
6334
|
+
case "dispatch_return_requires_dispatch":
|
|
6335
|
+
return `sub_agent: the spawn was rejected (${code}). This is a turn-context problem, not something to retry blindly \u2014 report it rather than looping.`;
|
|
6336
|
+
default:
|
|
6337
|
+
return `sub_agent: the spawn failed (${code ?? `HTTP ${status}`}).`;
|
|
6338
|
+
}
|
|
6339
|
+
}
|
|
6340
|
+
var CABANE_NATIVE_TURN_CONTROL_TOOLS = [
|
|
6341
|
+
{
|
|
6342
|
+
type: "function",
|
|
6343
|
+
function: {
|
|
6344
|
+
name: "ask",
|
|
6345
|
+
description: "Ask a HUMAN a structured question you need answered to continue, then END your turn (do not wait \u2014 a reply may take days). Pass `targetUserId` (a workspace member id). Single form: a one-sentence `headline` (the question, capitalized, ending in `?`) + a short `question` body of framing, with 2\u20134 `options` when the answer is a bounded/yes-no choice. Or `questions`: 1\u20135 items each `{ headline, body?, options? }` when several decisions land at once. Provide EITHER `question` or `questions`, never both. Your surrounding context goes in your reply; the ask carries the question.",
|
|
6346
|
+
parameters: {
|
|
6347
|
+
type: "object",
|
|
6348
|
+
properties: {
|
|
6349
|
+
targetUserId: {
|
|
6350
|
+
type: "string",
|
|
6351
|
+
description: "The workspace member (human) to ask \u2014 a user id from the roster."
|
|
6352
|
+
},
|
|
6353
|
+
question: {
|
|
6354
|
+
type: "string",
|
|
6355
|
+
description: "Single-question form: a short body of framing (one or two sentences)."
|
|
6356
|
+
},
|
|
6357
|
+
headline: {
|
|
6358
|
+
type: "string",
|
|
6359
|
+
description: "Single-question form: the question itself as one clear capitalized sentence ending in `?`."
|
|
6360
|
+
},
|
|
6361
|
+
options: {
|
|
6362
|
+
type: "array",
|
|
6363
|
+
items: { type: "string" },
|
|
6364
|
+
description: "Single-question form: 2\u20134 suggested one-click answers."
|
|
6365
|
+
},
|
|
6366
|
+
questions: {
|
|
6367
|
+
type: "array",
|
|
6368
|
+
items: {
|
|
6369
|
+
type: "object",
|
|
6370
|
+
properties: {
|
|
6371
|
+
headline: { type: "string" },
|
|
6372
|
+
body: { type: "string" },
|
|
6373
|
+
options: { type: "array", items: { type: "string" } }
|
|
6374
|
+
},
|
|
6375
|
+
required: ["headline"]
|
|
6376
|
+
},
|
|
6377
|
+
description: "Multi-question form: 1\u20135 questions to ask at once."
|
|
6378
|
+
}
|
|
6379
|
+
},
|
|
6380
|
+
required: ["targetUserId"]
|
|
6381
|
+
}
|
|
6382
|
+
}
|
|
6383
|
+
},
|
|
6384
|
+
{
|
|
6385
|
+
type: "function",
|
|
6386
|
+
function: {
|
|
6387
|
+
name: "wake_me",
|
|
6388
|
+
description: 'Wake yourself later \u2014 end this turn now and be re-dispatched at a time you pick, to CHECK a condition that has not happened yet (a PR merging, a reply landing, "check back in five minutes"). Pass EXACTLY ONE of `afterSeconds` (a relative delay, \u226560) or `at` (an absolute ISO-8601 timestamp WITH a zone you compute yourself). `note` is the message to your future self \u2014 write the condition to re-check. It arms when the turn ends; one wake per turn.',
|
|
6389
|
+
parameters: {
|
|
6390
|
+
type: "object",
|
|
6391
|
+
properties: {
|
|
6392
|
+
afterSeconds: {
|
|
6393
|
+
type: "number",
|
|
6394
|
+
description: "Relative delay in seconds from when this turn ends (floor 60)."
|
|
6395
|
+
},
|
|
6396
|
+
at: {
|
|
6397
|
+
type: "string",
|
|
6398
|
+
description: "Absolute ISO-8601 timestamp with a zone (e.g. 2026-07-16T09:00:00-07:00)."
|
|
6399
|
+
},
|
|
6400
|
+
note: {
|
|
6401
|
+
type: "string",
|
|
6402
|
+
description: "A note to your future self \u2014 becomes the wake body (the condition to re-check)."
|
|
6403
|
+
}
|
|
6404
|
+
},
|
|
6405
|
+
required: ["note"]
|
|
6406
|
+
}
|
|
6407
|
+
}
|
|
6408
|
+
},
|
|
6409
|
+
{
|
|
6410
|
+
type: "function",
|
|
6411
|
+
function: {
|
|
6412
|
+
name: "summon_agent",
|
|
6413
|
+
description: "Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Pass the peer `agentId` (from the agent roster). The peer is dispatched on your final reply and picks up this conversation as context, so write the context/ask into your reply first. Writing `@handle` in prose summons no one \u2014 this tool is the only in-thread lever. Single target, last call wins; summoning yourself is a no-op.",
|
|
6414
|
+
parameters: {
|
|
6415
|
+
type: "object",
|
|
6416
|
+
properties: {
|
|
6417
|
+
agentId: {
|
|
6418
|
+
type: "string",
|
|
6419
|
+
description: "The peer agent to summon \u2014 a workspace agent id from the roster."
|
|
6420
|
+
}
|
|
6421
|
+
},
|
|
6422
|
+
required: ["agentId"]
|
|
6423
|
+
}
|
|
6424
|
+
}
|
|
6425
|
+
},
|
|
6426
|
+
{
|
|
6427
|
+
type: "function",
|
|
6428
|
+
function: {
|
|
6429
|
+
name: "sub_agent",
|
|
6430
|
+
description: "Spawn a sub-agent \u2014 hand a piece of work to a private worker with a fresh context window whose result comes back to you automatically. It does NOT return inline: this returns the child's id immediately and the outcome lands LATER as a message in this conversation (you're woken once every sub-agent you have out here has returned). So don't wait \u2014 finish what else this turn can do and end your turn. `prompt` is the child's self-contained opening instruction; `agentId` (optional) dispatches a peer instead of yourself; `title` (optional) names the child thread. Reach for it to isolate a big read or fan out N independent pieces in parallel.",
|
|
6431
|
+
parameters: {
|
|
6432
|
+
type: "object",
|
|
6433
|
+
properties: {
|
|
6434
|
+
prompt: {
|
|
6435
|
+
type: "string",
|
|
6436
|
+
description: "The sub-agent's self-contained opening instruction."
|
|
6437
|
+
},
|
|
6438
|
+
agentId: {
|
|
6439
|
+
type: "string",
|
|
6440
|
+
description: "Optional peer to run the sub-agent as; omit to spawn yourself."
|
|
6441
|
+
},
|
|
6442
|
+
title: { type: "string", description: "Optional title for the child thread." }
|
|
6443
|
+
},
|
|
6444
|
+
required: ["prompt"]
|
|
6445
|
+
}
|
|
6446
|
+
}
|
|
6447
|
+
},
|
|
6448
|
+
{
|
|
6449
|
+
type: "function",
|
|
6450
|
+
function: {
|
|
6451
|
+
name: "skip_turn",
|
|
6452
|
+
description: "End your current turn WITHOUT posting a reply. Call this when you've been dispatched but the message genuinely doesn't need a response from you \u2014 an aside, a question already answered, a pile-on someone else has. Your turn ends silently (no message bubble). `reason` is a short free-text note for telemetry. Prefer this over posting a low-value \"ok!\"; skipping IS the whole turn.",
|
|
6453
|
+
parameters: {
|
|
6454
|
+
type: "object",
|
|
6455
|
+
properties: {
|
|
6456
|
+
reason: {
|
|
6457
|
+
type: "string",
|
|
6458
|
+
description: "Short reason you are declining \u2014 used for telemetry."
|
|
6459
|
+
}
|
|
6460
|
+
},
|
|
6461
|
+
required: ["reason"]
|
|
6462
|
+
}
|
|
6463
|
+
}
|
|
6464
|
+
}
|
|
6465
|
+
];
|
|
6466
|
+
var CABANE_NATIVE_TURN_CONTROL_NAMES = new Set(
|
|
6467
|
+
CABANE_NATIVE_TURN_CONTROL_TOOLS.map((t) => t.function.name)
|
|
6468
|
+
);
|
|
6469
|
+
function summarizeTurnControlArgs(name, args) {
|
|
6470
|
+
if (name === "ask") return str3(args.headline) ?? str3(args.question) ?? "";
|
|
6471
|
+
if (name === "wake_me") return str3(args.note) ?? "";
|
|
6472
|
+
if (name === "summon_agent") return str3(args.agentId) ?? "";
|
|
6473
|
+
if (name === "sub_agent") return str3(args.title) ?? truncateHead(str3(args.prompt) ?? "", 60);
|
|
6474
|
+
if (name === "skip_turn") return str3(args.reason) ?? "";
|
|
6475
|
+
return "";
|
|
6476
|
+
}
|
|
6477
|
+
async function executeNativeTurnControl(name, args, tc) {
|
|
6478
|
+
switch (name) {
|
|
6479
|
+
case "summon_agent": {
|
|
6480
|
+
const agentId = str3(args.agentId);
|
|
6481
|
+
if (!agentId) return err("summon_agent: `agentId` is required.");
|
|
6482
|
+
tc.summon(agentId);
|
|
6483
|
+
return ok({ summoned: agentId });
|
|
6484
|
+
}
|
|
6485
|
+
case "skip_turn": {
|
|
6486
|
+
const reason = str3(args.reason);
|
|
6487
|
+
if (!reason) return err("skip_turn: `reason` is required.");
|
|
6488
|
+
tc.skip(reason);
|
|
6489
|
+
return ok({ skipped: true });
|
|
6490
|
+
}
|
|
6491
|
+
case "ask": {
|
|
6492
|
+
const targetUserId = str3(args.targetUserId);
|
|
6493
|
+
if (!targetUserId) return err("ask: `targetUserId` is required.");
|
|
6494
|
+
const hasSingle = args.question !== void 0;
|
|
6495
|
+
const hasArray = Array.isArray(args.questions) && args.questions.length > 0;
|
|
6496
|
+
if (hasSingle && hasArray)
|
|
6497
|
+
return err("ask: provide either `question` or `questions`, not both.");
|
|
6498
|
+
if (!hasSingle && !hasArray) return err("ask: provide `question` or `questions`.");
|
|
6499
|
+
const payload = hasArray ? { targetUserId, questions: args.questions } : {
|
|
6500
|
+
targetUserId,
|
|
6501
|
+
question: str3(args.question),
|
|
6502
|
+
...str3(args.headline) ? { headline: str3(args.headline) } : {},
|
|
6503
|
+
...Array.isArray(args.options) ? { options: args.options } : {}
|
|
6504
|
+
};
|
|
6505
|
+
tc.ask(payload);
|
|
6506
|
+
return ok({ asked: targetUserId });
|
|
6507
|
+
}
|
|
6508
|
+
case "wake_me": {
|
|
6509
|
+
const note = str3(args.note);
|
|
6510
|
+
if (!note) return err("wake_me: `note` is required.");
|
|
6511
|
+
const hasAfter = typeof args.afterSeconds === "number";
|
|
6512
|
+
const hasAt = typeof args.at === "string";
|
|
6513
|
+
if (hasAfter && hasAt)
|
|
6514
|
+
return err("wake_me: provide either `afterSeconds` or `at`, not both.");
|
|
6515
|
+
if (!hasAfter && !hasAt) return err("wake_me: provide `afterSeconds` or `at`.");
|
|
6516
|
+
tc.wake({
|
|
6517
|
+
...hasAfter ? { afterSeconds: args.afterSeconds } : {},
|
|
6518
|
+
...hasAt ? { at: args.at } : {},
|
|
6519
|
+
note
|
|
6520
|
+
});
|
|
6521
|
+
return ok({ armed: hasAt ? { at: args.at } : { afterSeconds: args.afterSeconds } });
|
|
6522
|
+
}
|
|
6523
|
+
case "sub_agent": {
|
|
6524
|
+
const prompt = str3(args.prompt);
|
|
6525
|
+
if (!prompt) return err("sub_agent: `prompt` is required.");
|
|
6526
|
+
const result = await tc.subAgent({
|
|
6527
|
+
prompt,
|
|
6528
|
+
...str3(args.agentId) ? { agentId: str3(args.agentId) } : {},
|
|
6529
|
+
...str3(args.title) ? { title: str3(args.title) } : {}
|
|
6530
|
+
});
|
|
6531
|
+
if (!result.ok) return err(result.error);
|
|
6532
|
+
return ok({
|
|
6533
|
+
conversationId: result.conversationId,
|
|
6534
|
+
note: "Spawned. Don't wait \u2014 finish what else this turn can do, then end your turn; the result posts back here."
|
|
6535
|
+
});
|
|
6536
|
+
}
|
|
6537
|
+
default:
|
|
6538
|
+
return err(`unknown turn-control tool "${name}"`);
|
|
6539
|
+
}
|
|
6540
|
+
}
|
|
6541
|
+
function ok(payload) {
|
|
6542
|
+
return { ok: true, result: JSON.stringify(payload) };
|
|
6543
|
+
}
|
|
6544
|
+
function err(message) {
|
|
6545
|
+
return { ok: false, result: `error: ${message}` };
|
|
6546
|
+
}
|
|
6547
|
+
function str3(v) {
|
|
6548
|
+
return typeof v === "string" ? v : void 0;
|
|
6549
|
+
}
|
|
6550
|
+
function truncateHead(s, n) {
|
|
6551
|
+
return s.length > n ? `${s.slice(0, n)}\u2026` : s;
|
|
6552
|
+
}
|
|
6553
|
+
|
|
5835
6554
|
// packages/agent-runtime/src/cabane-native/loop.ts
|
|
5836
6555
|
var DEFAULT_MAX_ITERATIONS = 12;
|
|
5837
6556
|
async function* runCabaneNativeTurn(req, signal, deps) {
|
|
@@ -5840,10 +6559,20 @@ async function* runCabaneNativeTurn(req, signal, deps) {
|
|
|
5840
6559
|
return;
|
|
5841
6560
|
}
|
|
5842
6561
|
const model = parseCabaneNativeModel(req.config.model);
|
|
6562
|
+
const surface = cabaneNativeSurface(req.config.runtimeOptions);
|
|
6563
|
+
const turnControl = req.extra.turnControl;
|
|
6564
|
+
const tools = surface === "code" ? [
|
|
6565
|
+
CABANE_NATIVE_CODE_TOOL,
|
|
6566
|
+
CABANE_NATIVE_RENDER_TOKEN_TOOL,
|
|
6567
|
+
...turnControl ? CABANE_NATIVE_TURN_CONTROL_TOOLS : []
|
|
6568
|
+
] : CABANE_NATIVE_TOOLS;
|
|
5843
6569
|
const toolCtx = {
|
|
5844
6570
|
apiRoot: deps.apiRoot,
|
|
5845
6571
|
workspaceId: deps.workspaceId,
|
|
5846
6572
|
bearer: deps.bearer,
|
|
6573
|
+
// CT666: the origin conversation, forwarded by the `sdk` tool to the `/sdk`
|
|
6574
|
+
// endpoint so a code-mode program's returning callout binds to this turn.
|
|
6575
|
+
...deps.conversationId ? { conversationId: deps.conversationId } : {},
|
|
5847
6576
|
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
|
|
5848
6577
|
};
|
|
5849
6578
|
const messages = await assembleMessages(req.systemPrompt, req.content, req.prompt, {
|
|
@@ -5863,10 +6592,7 @@ async function* runCabaneNativeTurn(req, signal, deps) {
|
|
|
5863
6592
|
const toolAcc = /* @__PURE__ */ new Map();
|
|
5864
6593
|
let finishReason;
|
|
5865
6594
|
let errored2;
|
|
5866
|
-
for await (const ev of deps.provider.stream(
|
|
5867
|
-
{ model, messages, tools: CABANE_NATIVE_TOOLS },
|
|
5868
|
-
signal
|
|
5869
|
-
)) {
|
|
6595
|
+
for await (const ev of deps.provider.stream({ model, messages, tools }, signal)) {
|
|
5870
6596
|
if (signal.aborted) return;
|
|
5871
6597
|
switch (ev.type) {
|
|
5872
6598
|
case "text":
|
|
@@ -5881,7 +6607,11 @@ async function* runCabaneNativeTurn(req, signal, deps) {
|
|
|
5881
6607
|
break;
|
|
5882
6608
|
}
|
|
5883
6609
|
case "usage":
|
|
5884
|
-
usage = {
|
|
6610
|
+
usage = {
|
|
6611
|
+
inputTokens: ev.inputTokens,
|
|
6612
|
+
outputTokens: ev.outputTokens,
|
|
6613
|
+
contextTokens: ev.inputTokens
|
|
6614
|
+
};
|
|
5885
6615
|
break;
|
|
5886
6616
|
case "model":
|
|
5887
6617
|
resolvedModel = ev.model;
|
|
@@ -5932,9 +6662,10 @@ async function* runCabaneNativeTurn(req, signal, deps) {
|
|
|
5932
6662
|
if (signal.aborted) return;
|
|
5933
6663
|
const args = parseArgs(t.args);
|
|
5934
6664
|
const displayName = prettyToolName(t.name);
|
|
5935
|
-
const
|
|
6665
|
+
const isTurnControl = CABANE_NATIVE_TURN_CONTROL_NAMES.has(t.name);
|
|
6666
|
+
const summary = isTurnControl ? summarizeTurnControlArgs(t.name, args) : summarizeCabaneToolArgs(t.name, args);
|
|
5936
6667
|
yield { type: "tool", id: t.id, name: displayName, phase: "start", summary, input: args };
|
|
5937
|
-
const result = await executeCabaneNativeTool(t.name, args, toolCtx, signal);
|
|
6668
|
+
const result = isTurnControl && turnControl ? await executeNativeTurnControl(t.name, args, turnControl) : await executeCabaneNativeTool(t.name, args, toolCtx, signal);
|
|
5938
6669
|
if (signal.aborted) return;
|
|
5939
6670
|
yield {
|
|
5940
6671
|
type: "tool",
|
|
@@ -5978,10 +6709,6 @@ function parseArgs(raw) {
|
|
|
5978
6709
|
}
|
|
5979
6710
|
}
|
|
5980
6711
|
|
|
5981
|
-
// packages/agent-runtime/src/cabane-native/policy.ts
|
|
5982
|
-
import { z as z13 } from "zod";
|
|
5983
|
-
var cabaneNativeDialectSchema = z13.object({}).loose();
|
|
5984
|
-
|
|
5985
6712
|
// packages/agent-runtime/src/cabane-native/provider.ts
|
|
5986
6713
|
var DEFAULT_OPENROUTER_BASE = "https://openrouter.ai/api/v1";
|
|
5987
6714
|
function createOpenRouterProvider(opts) {
|
|
@@ -6011,9 +6738,9 @@ function createOpenRouterProvider(opts) {
|
|
|
6011
6738
|
}),
|
|
6012
6739
|
signal
|
|
6013
6740
|
});
|
|
6014
|
-
} catch (
|
|
6741
|
+
} catch (err2) {
|
|
6015
6742
|
if (signal.aborted) return;
|
|
6016
|
-
yield { type: "error", message: `request failed: ${errText(
|
|
6743
|
+
yield { type: "error", message: `request failed: ${errText(err2)}` };
|
|
6017
6744
|
return;
|
|
6018
6745
|
}
|
|
6019
6746
|
if (!res.ok || !res.body) {
|
|
@@ -6083,9 +6810,9 @@ function createOpenRouterProvider(opts) {
|
|
|
6083
6810
|
}
|
|
6084
6811
|
}
|
|
6085
6812
|
}
|
|
6086
|
-
} catch (
|
|
6813
|
+
} catch (err2) {
|
|
6087
6814
|
if (signal.aborted) return;
|
|
6088
|
-
yield { type: "error", message: `stream read failed: ${errText(
|
|
6815
|
+
yield { type: "error", message: `stream read failed: ${errText(err2)}` };
|
|
6089
6816
|
return;
|
|
6090
6817
|
}
|
|
6091
6818
|
yield { type: "done", ...finishReason ? { finishReason } : {} };
|
|
@@ -6101,8 +6828,8 @@ function providerErrorMessage(status, body) {
|
|
|
6101
6828
|
}
|
|
6102
6829
|
return `HTTP ${status}: ${detail}`;
|
|
6103
6830
|
}
|
|
6104
|
-
function errText(
|
|
6105
|
-
return
|
|
6831
|
+
function errText(err2) {
|
|
6832
|
+
return err2 instanceof Error ? err2.message : String(err2);
|
|
6106
6833
|
}
|
|
6107
6834
|
|
|
6108
6835
|
// packages/agent-runtime/src/cabane-native/index.ts
|
|
@@ -6115,13 +6842,15 @@ function createCabaneNativeAdapter(deps = {}) {
|
|
|
6115
6842
|
}) : null);
|
|
6116
6843
|
return {
|
|
6117
6844
|
name: CABANE_NATIVE_RUNTIME_NAME,
|
|
6118
|
-
//
|
|
6119
|
-
//
|
|
6120
|
-
//
|
|
6121
|
-
//
|
|
6122
|
-
//
|
|
6123
|
-
//
|
|
6124
|
-
|
|
6845
|
+
// CT666: the native runtime's addendum is now SURFACE-aware. Code mode (the
|
|
6846
|
+
// default In-Cabane surface) mounts the single `sdk` tool, so it's taught the
|
|
6847
|
+
// code-mode addendum (act by writing a `cabane` SDK program; the `cabane_*`
|
|
6848
|
+
// names don't exist here); classic (the retained CT663 10-tool fallback) is
|
|
6849
|
+
// taught the granular `cabane_*` listing. The caller (turn-context) passes
|
|
6850
|
+
// `codeMode` off the SAME lifted surface value the loop mounts tools from, so
|
|
6851
|
+
// the prompt and the mounted surface can't drift. (Supersedes CT614's
|
|
6852
|
+
// flag-independent addendum: native no longer ignores this arg.)
|
|
6853
|
+
promptAddendum: (codeMode) => codeMode ? CABANE_NATIVE_ADDENDUM_CODE_MODE : CABANE_NATIVE_ADDENDUM,
|
|
6125
6854
|
dialectSchema: cabaneNativeDialectSchema,
|
|
6126
6855
|
async *runTurn(req, signal) {
|
|
6127
6856
|
if (!provider) {
|
|
@@ -6195,8 +6924,8 @@ var ConnectorHealthStore = class {
|
|
|
6195
6924
|
return this.byRuntime.get(runtime);
|
|
6196
6925
|
}
|
|
6197
6926
|
// The per-connector reports to attach to a heartbeat — one entry per runtime the
|
|
6198
|
-
//
|
|
6199
|
-
// so a
|
|
6927
|
+
// companion has an observation for. Empty until the first classified failure/heal,
|
|
6928
|
+
// so a companion that has seen nothing sends no `connectors[]` and the server's
|
|
6200
6929
|
// manifest synthesis (status-less rows) is unaffected.
|
|
6201
6930
|
reports() {
|
|
6202
6931
|
return [...this.byRuntime.entries()].map(([runtime, h]) => ({
|
|
@@ -6210,22 +6939,23 @@ var ConnectorHealthStore = class {
|
|
|
6210
6939
|
|
|
6211
6940
|
// src/dispatcher.ts
|
|
6212
6941
|
import { randomUUID } from "crypto";
|
|
6213
|
-
import { existsSync as existsSync9 } from "fs";
|
|
6942
|
+
import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync9 } from "fs";
|
|
6943
|
+
import { join as join12 } from "path";
|
|
6214
6944
|
|
|
6215
6945
|
// src/summon.ts
|
|
6216
6946
|
import { z as z14 } from "zod";
|
|
6217
|
-
var
|
|
6947
|
+
var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
|
|
6218
6948
|
var SUMMON_AGENT_TOOL = "summon_agent";
|
|
6219
|
-
var SUMMON_AGENT_TOOL_NAME = `mcp__${
|
|
6220
|
-
var
|
|
6949
|
+
var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
|
|
6950
|
+
var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
|
|
6221
6951
|
var SKIP_TURN_TOOL = "skip_turn";
|
|
6222
|
-
var SKIP_TURN_TOOL_NAME = `mcp__${
|
|
6952
|
+
var SKIP_TURN_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SKIP_TURN_TOOL}`;
|
|
6223
6953
|
var ASK_TOOL = "ask";
|
|
6224
|
-
var ASK_TOOL_NAME = `mcp__${
|
|
6954
|
+
var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
|
|
6225
6955
|
var SUB_AGENT_TOOL = "sub_agent";
|
|
6226
|
-
var SUB_AGENT_TOOL_NAME = `mcp__${
|
|
6956
|
+
var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
|
|
6227
6957
|
var WAKE_ME_TOOL = "wake_me";
|
|
6228
|
-
var WAKE_ME_TOOL_NAME = `mcp__${
|
|
6958
|
+
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
6229
6959
|
function createSummonState() {
|
|
6230
6960
|
return { agentId: null };
|
|
6231
6961
|
}
|
|
@@ -6240,7 +6970,7 @@ function createWakeState() {
|
|
|
6240
6970
|
}
|
|
6241
6971
|
function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
|
|
6242
6972
|
return createSdkMcpServer({
|
|
6243
|
-
name:
|
|
6973
|
+
name: COMPANION_LOCAL_MCP_SERVER,
|
|
6244
6974
|
version: "0.0.0",
|
|
6245
6975
|
tools: [
|
|
6246
6976
|
tool(
|
|
@@ -6442,7 +7172,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6442
7172
|
function cabaneMcpUrl(baseUrl) {
|
|
6443
7173
|
return `${trimSlash3(baseUrl)}/api/mcp`;
|
|
6444
7174
|
}
|
|
6445
|
-
function
|
|
7175
|
+
function turnControlMcpUrl(baseUrl) {
|
|
7176
|
+
return `${trimSlash3(baseUrl)}/api/turn-control`;
|
|
7177
|
+
}
|
|
7178
|
+
function buildCompanionTurnRequest(params) {
|
|
6446
7179
|
const { turnContext: t } = params;
|
|
6447
7180
|
return {
|
|
6448
7181
|
systemPrompt: t.systemPrompt,
|
|
@@ -6453,23 +7186,35 @@ function buildBridgeTurnRequest(params) {
|
|
|
6453
7186
|
session: t.session,
|
|
6454
7187
|
cabane: {
|
|
6455
7188
|
mcpUrl: cabaneMcpUrl(params.baseUrl),
|
|
6456
|
-
// CT306: prefer the per-turn OBO credential; fall back to the
|
|
7189
|
+
// CT306: prefer the per-turn OBO credential; fall back to the companion PAT
|
|
6457
7190
|
// when the API didn't mint one (older API / unresolvable delegation).
|
|
6458
7191
|
bearer: params.turnToken ?? params.agentPat,
|
|
6459
7192
|
activeConversationId: params.activeConversationId,
|
|
6460
|
-
workspaceId: params.workspaceId
|
|
7193
|
+
workspaceId: params.workspaceId,
|
|
7194
|
+
...t.workspaceToolSurface ? { workspaceToolSurface: t.workspaceToolSurface } : {},
|
|
7195
|
+
// CT714: mount the turn-control surface ONLY when a real turn token backs
|
|
7196
|
+
// this turn — the surface admits `turn_token` auth exclusively, so a
|
|
7197
|
+
// PAT-fallback bearer (older API / unresolved delegation) would be rejected
|
|
7198
|
+
// there. Absent it, external adapters simply don't mount it that turn (the
|
|
7199
|
+
// same graceful degrade as the rest of the OBO path).
|
|
7200
|
+
...params.turnToken ? { turnControlUrl: turnControlMcpUrl(params.baseUrl) } : {}
|
|
6461
7201
|
},
|
|
6462
7202
|
local: {
|
|
6463
7203
|
...params.cwd ? { cwd: params.cwd } : {},
|
|
6464
7204
|
...params.env ? { env: params.env } : {},
|
|
7205
|
+
...params.nativeWorkAssignment ? { nativeWorkAssignment: params.nativeWorkAssignment } : {},
|
|
6465
7206
|
// User MCP servers (already `${PLACEHOLDER}`-resolved). Structurally the
|
|
6466
7207
|
// adapter's `ResolvedMcpServers`.
|
|
6467
7208
|
...Object.keys(params.mcpServers).length > 0 ? { mcpServers: params.mcpServers } : {},
|
|
6468
7209
|
// CT289: the auto-memory escape hatch, when the operator set it.
|
|
6469
7210
|
...params.claudeCode ? { claudeCode: params.claudeCode } : {}
|
|
6470
7211
|
},
|
|
6471
|
-
// Host-injected: the
|
|
6472
|
-
|
|
7212
|
+
// Host-injected: the companion-local summon server (for the subprocess adapters,
|
|
7213
|
+
// under its own namespace) + the cabane-native turn-control handler (CT666).
|
|
7214
|
+
extra: {
|
|
7215
|
+
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.summonServer },
|
|
7216
|
+
turnControl: params.turnControl
|
|
7217
|
+
}
|
|
6473
7218
|
};
|
|
6474
7219
|
}
|
|
6475
7220
|
function trimSlash3(s) {
|
|
@@ -6482,18 +7227,26 @@ import { join as join9 } from "path";
|
|
|
6482
7227
|
function dirFor(workspaceId) {
|
|
6483
7228
|
return join9(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
|
|
6484
7229
|
}
|
|
6485
|
-
function
|
|
6486
|
-
return join9(dirFor(workspaceId),
|
|
7230
|
+
function conversationDir(workspaceId, conversationId) {
|
|
7231
|
+
return join9(dirFor(workspaceId), encodeURIComponent(conversationId));
|
|
6487
7232
|
}
|
|
6488
|
-
function
|
|
6489
|
-
const
|
|
7233
|
+
function pathFor3(workspaceId, conversationId, agentId, assignmentKey) {
|
|
7234
|
+
const suffix = assignmentKey ? `--${encodeURIComponent(assignmentKey)}` : "";
|
|
7235
|
+
return join9(
|
|
7236
|
+
conversationDir(workspaceId, conversationId),
|
|
7237
|
+
`${encodeURIComponent(agentId)}${suffix}.json`
|
|
7238
|
+
);
|
|
7239
|
+
}
|
|
7240
|
+
function readPrepared(workspaceId, conversationId, agentId, assignmentKey) {
|
|
7241
|
+
const path3 = pathFor3(workspaceId, conversationId, agentId, assignmentKey);
|
|
6490
7242
|
if (!existsSync7(path3)) return null;
|
|
6491
7243
|
try {
|
|
6492
7244
|
const parsed = JSON.parse(readFileSync6(path3, "utf8"));
|
|
6493
7245
|
if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
|
|
6494
7246
|
return {
|
|
6495
7247
|
cwd: parsed.cwd,
|
|
6496
|
-
...parsed.env && typeof parsed.env === "object" ? { env: parsed.env } : {}
|
|
7248
|
+
...parsed.env && typeof parsed.env === "object" ? { env: parsed.env } : {},
|
|
7249
|
+
...parsed.nativeWorkAssignment && typeof parsed.nativeWorkAssignment === "object" ? { nativeWorkAssignment: parsed.nativeWorkAssignment } : {}
|
|
6497
7250
|
};
|
|
6498
7251
|
}
|
|
6499
7252
|
return null;
|
|
@@ -6501,9 +7254,13 @@ function readPrepared(workspaceId, conversationId) {
|
|
|
6501
7254
|
return null;
|
|
6502
7255
|
}
|
|
6503
7256
|
}
|
|
6504
|
-
function writePrepared(workspaceId, conversationId, result) {
|
|
6505
|
-
mkdirSync7(
|
|
6506
|
-
writeFileSync6(
|
|
7257
|
+
function writePrepared(workspaceId, conversationId, agentId, result, assignmentKey) {
|
|
7258
|
+
mkdirSync7(conversationDir(workspaceId, conversationId), { recursive: true });
|
|
7259
|
+
writeFileSync6(
|
|
7260
|
+
pathFor3(workspaceId, conversationId, agentId, assignmentKey),
|
|
7261
|
+
JSON.stringify(result) + "\n",
|
|
7262
|
+
"utf8"
|
|
7263
|
+
);
|
|
6507
7264
|
}
|
|
6508
7265
|
|
|
6509
7266
|
// src/secrets.ts
|
|
@@ -6521,18 +7278,18 @@ function loadSecretStore() {
|
|
|
6521
7278
|
let raw;
|
|
6522
7279
|
try {
|
|
6523
7280
|
raw = readFileSync7(path3, "utf8");
|
|
6524
|
-
} catch (
|
|
7281
|
+
} catch (err2) {
|
|
6525
7282
|
throw new ConfigError(
|
|
6526
|
-
`couldn't read ${path3}: ${
|
|
7283
|
+
`couldn't read ${path3}: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
6527
7284
|
);
|
|
6528
7285
|
}
|
|
6529
7286
|
if (raw.trim().length === 0) return makeStore({});
|
|
6530
7287
|
let parsed;
|
|
6531
7288
|
try {
|
|
6532
7289
|
parsed = JSON.parse(raw);
|
|
6533
|
-
} catch (
|
|
7290
|
+
} catch (err2) {
|
|
6534
7291
|
throw new ConfigError(
|
|
6535
|
-
`${path3} is not valid JSON: ${
|
|
7292
|
+
`${path3} is not valid JSON: ${err2 instanceof Error ? err2.message : String(err2)}. It must be a flat object of "NAME": "value" secret pairs.`
|
|
6536
7293
|
);
|
|
6537
7294
|
}
|
|
6538
7295
|
const result = secretStoreSchema.safeParse(parsed);
|
|
@@ -6546,8 +7303,8 @@ function loadSecretStore() {
|
|
|
6546
7303
|
function loadSecretStoreTolerant(onWarn) {
|
|
6547
7304
|
try {
|
|
6548
7305
|
return loadSecretStore();
|
|
6549
|
-
} catch (
|
|
6550
|
-
onWarn?.(
|
|
7306
|
+
} catch (err2) {
|
|
7307
|
+
onWarn?.(err2 instanceof Error ? err2.message : String(err2));
|
|
6551
7308
|
return makeStore({});
|
|
6552
7309
|
}
|
|
6553
7310
|
}
|
|
@@ -6618,8 +7375,8 @@ var TranscriptWriter = class {
|
|
|
6618
7375
|
} catch {
|
|
6619
7376
|
}
|
|
6620
7377
|
pruneOld(dir2, RETAIN);
|
|
6621
|
-
} catch (
|
|
6622
|
-
this.fail(
|
|
7378
|
+
} catch (err2) {
|
|
7379
|
+
this.fail(err2);
|
|
6623
7380
|
}
|
|
6624
7381
|
this.line({ type: "_meta", ...meta });
|
|
6625
7382
|
}
|
|
@@ -6635,14 +7392,14 @@ var TranscriptWriter = class {
|
|
|
6635
7392
|
if (this.broken) return;
|
|
6636
7393
|
try {
|
|
6637
7394
|
appendFileSync(this.path, JSON.stringify(obj) + "\n", { mode: 384 });
|
|
6638
|
-
} catch (
|
|
6639
|
-
this.fail(
|
|
7395
|
+
} catch (err2) {
|
|
7396
|
+
this.fail(err2);
|
|
6640
7397
|
}
|
|
6641
7398
|
}
|
|
6642
|
-
fail(
|
|
7399
|
+
fail(err2) {
|
|
6643
7400
|
if (this.broken) return;
|
|
6644
7401
|
this.broken = true;
|
|
6645
|
-
this.onWarn?.(`transcript write failed (${
|
|
7402
|
+
this.onWarn?.(`transcript write failed (${err2 instanceof Error ? err2.message : String(err2)})`);
|
|
6646
7403
|
}
|
|
6647
7404
|
};
|
|
6648
7405
|
function fileName(meta) {
|
|
@@ -6680,9 +7437,9 @@ var EMPTY_FINAL_BODY = "Done \u2014 see the changes above.";
|
|
|
6680
7437
|
var TurnCommitter = class {
|
|
6681
7438
|
constructor(deps) {
|
|
6682
7439
|
this.deps = deps;
|
|
6683
|
-
this.onError = (
|
|
7440
|
+
this.onError = (err2, hook) => {
|
|
6684
7441
|
deps.log.warn(
|
|
6685
|
-
{ err:
|
|
7442
|
+
{ err: err2 instanceof Error ? err2.message : String(err2), hook },
|
|
6686
7443
|
"dispatcher: transcript callback failed"
|
|
6687
7444
|
);
|
|
6688
7445
|
};
|
|
@@ -6747,9 +7504,9 @@ var TurnCommitter = class {
|
|
|
6747
7504
|
signal: deps.signal,
|
|
6748
7505
|
nextSeq: deps.nextSeq,
|
|
6749
7506
|
emptyFinalBody: EMPTY_FINAL_BODY,
|
|
6750
|
-
onError: (
|
|
7507
|
+
onError: (err2) => {
|
|
6751
7508
|
deps.log.warn(
|
|
6752
|
-
{ err:
|
|
7509
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
6753
7510
|
"dispatcher: empty-final commit failed"
|
|
6754
7511
|
);
|
|
6755
7512
|
}
|
|
@@ -6772,8 +7529,8 @@ var TurnCommitter = class {
|
|
|
6772
7529
|
if (event.type === "session" || event.type === "result") return;
|
|
6773
7530
|
try {
|
|
6774
7531
|
await this.emit(event);
|
|
6775
|
-
} catch (
|
|
6776
|
-
this.onError(
|
|
7532
|
+
} catch (err2) {
|
|
7533
|
+
this.onError(err2, event.type);
|
|
6777
7534
|
}
|
|
6778
7535
|
}
|
|
6779
7536
|
// End-of-turn empty-final promotion. The held-text flush is now the adapter's
|
|
@@ -6844,13 +7601,128 @@ var TurnCommitter = class {
|
|
|
6844
7601
|
}
|
|
6845
7602
|
};
|
|
6846
7603
|
|
|
7604
|
+
// src/workspace-readiness.ts
|
|
7605
|
+
var CLASSIC_REQUIRED = ["read", "list", "search", "write", "edit"];
|
|
7606
|
+
async function proveWorkspaceTools(req, runtime, opts = {}) {
|
|
7607
|
+
const base = {
|
|
7608
|
+
ok: false,
|
|
7609
|
+
proofType: "authenticated_mcp_tools_list",
|
|
7610
|
+
runtime,
|
|
7611
|
+
harnessFingerprint: opts.harnessFingerprint ?? runtime,
|
|
7612
|
+
endpoint: safeEndpoint(req.cabane.mcpUrl),
|
|
7613
|
+
initialized: false,
|
|
7614
|
+
authenticated: false,
|
|
7615
|
+
discoveredTools: [],
|
|
7616
|
+
requiredTools: [],
|
|
7617
|
+
acceptedNames: ["sdk", "mcp__cabane__sdk"],
|
|
7618
|
+
failedCapability: null,
|
|
7619
|
+
detail: null
|
|
7620
|
+
};
|
|
7621
|
+
if (!req.cabane.mcpUrl) return fail(base, "server_not_configured", "Cabane MCP URL absent");
|
|
7622
|
+
if (!req.cabane.bearer) return fail(base, "authentication_failed", "Cabane bearer absent");
|
|
7623
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
7624
|
+
const headers = {
|
|
7625
|
+
authorization: `Bearer ${req.cabane.bearer}`,
|
|
7626
|
+
accept: "application/json, text/event-stream",
|
|
7627
|
+
"content-type": "application/json",
|
|
7628
|
+
"x-cabane-active-conversation": req.cabane.activeConversationId
|
|
7629
|
+
};
|
|
7630
|
+
try {
|
|
7631
|
+
const initialized = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
|
|
7632
|
+
jsonrpc: "2.0",
|
|
7633
|
+
id: 1,
|
|
7634
|
+
method: "initialize",
|
|
7635
|
+
params: {
|
|
7636
|
+
protocolVersion: "2025-03-26",
|
|
7637
|
+
capabilities: {},
|
|
7638
|
+
clientInfo: { name: "cabane-companion-readiness", version: "1" }
|
|
7639
|
+
}
|
|
7640
|
+
});
|
|
7641
|
+
if (initialized.status === 401 || initialized.status === 403)
|
|
7642
|
+
return fail(base, "authentication_failed", `initialize returned HTTP ${initialized.status}`);
|
|
7643
|
+
if (!initialized.ok) return fail(base, "initialization_failed", initialized.detail);
|
|
7644
|
+
base.initialized = true;
|
|
7645
|
+
base.authenticated = true;
|
|
7646
|
+
if (initialized.sessionId) headers["mcp-session-id"] = initialized.sessionId;
|
|
7647
|
+
const listed = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
|
|
7648
|
+
jsonrpc: "2.0",
|
|
7649
|
+
id: 2,
|
|
7650
|
+
method: "tools/list",
|
|
7651
|
+
params: {}
|
|
7652
|
+
});
|
|
7653
|
+
if (listed.status === 401 || listed.status === 403)
|
|
7654
|
+
return fail(base, "authentication_failed", `tools/list returned HTTP ${listed.status}`);
|
|
7655
|
+
if (!listed.ok) return fail(base, "tool_discovery_failed", listed.detail);
|
|
7656
|
+
const result = asRecord3(asRecord3(listed.value)?.result);
|
|
7657
|
+
const tools = Array.isArray(result?.tools) ? result.tools : null;
|
|
7658
|
+
if (!tools) return fail(base, "tool_discovery_failed", "tools/list returned no tool inventory");
|
|
7659
|
+
base.discoveredTools = tools.map(
|
|
7660
|
+
(tool2) => tool2 && typeof tool2 === "object" && typeof tool2.name === "string" ? tool2.name : null
|
|
7661
|
+
).filter((name) => name !== null).sort();
|
|
7662
|
+
if (!req.cabane.workspaceToolSurface)
|
|
7663
|
+
return fail(base, "required_tool_missing", "resolved workspace tool surface absent");
|
|
7664
|
+
base.requiredTools = req.cabane.workspaceToolSurface === "code" ? ["sdk"] : CLASSIC_REQUIRED;
|
|
7665
|
+
const missing = base.requiredTools.filter((name) => !base.discoveredTools.includes(name));
|
|
7666
|
+
if (missing.length > 0)
|
|
7667
|
+
return fail(
|
|
7668
|
+
base,
|
|
7669
|
+
"required_tool_missing",
|
|
7670
|
+
`missing initialized tools: ${missing.join(", ")}`
|
|
7671
|
+
);
|
|
7672
|
+
base.ok = true;
|
|
7673
|
+
return base;
|
|
7674
|
+
} catch (error) {
|
|
7675
|
+
return fail(
|
|
7676
|
+
base,
|
|
7677
|
+
"initialization_failed",
|
|
7678
|
+
error instanceof Error ? error.message : String(error)
|
|
7679
|
+
);
|
|
7680
|
+
}
|
|
7681
|
+
}
|
|
7682
|
+
function fail(proof, capability, detail) {
|
|
7683
|
+
proof.failedCapability = capability;
|
|
7684
|
+
proof.detail = detail.slice(0, 300);
|
|
7685
|
+
return proof;
|
|
7686
|
+
}
|
|
7687
|
+
function safeEndpoint(value) {
|
|
7688
|
+
try {
|
|
7689
|
+
const url = new URL(value);
|
|
7690
|
+
return `${url.origin}${url.pathname}`;
|
|
7691
|
+
} catch {
|
|
7692
|
+
return null;
|
|
7693
|
+
}
|
|
7694
|
+
}
|
|
7695
|
+
async function rpc(fetchImpl, url, headers, body) {
|
|
7696
|
+
const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
7697
|
+
const text = await response.text();
|
|
7698
|
+
const value = parseRpcBody(text);
|
|
7699
|
+
return {
|
|
7700
|
+
ok: response.ok && !!value && !value.error,
|
|
7701
|
+
status: response.status,
|
|
7702
|
+
sessionId: response.headers.get("mcp-session-id"),
|
|
7703
|
+
value,
|
|
7704
|
+
detail: typeof asRecord3(value?.error)?.message === "string" ? String(asRecord3(value?.error)?.message) : `HTTP ${response.status}`
|
|
7705
|
+
};
|
|
7706
|
+
}
|
|
7707
|
+
function parseRpcBody(text) {
|
|
7708
|
+
const trimmed = text.trim();
|
|
7709
|
+
if (trimmed.startsWith("{")) return JSON.parse(trimmed);
|
|
7710
|
+
for (const line of trimmed.split("\n")) {
|
|
7711
|
+
if (line.startsWith("data:")) return JSON.parse(line.slice(5).trim());
|
|
7712
|
+
}
|
|
7713
|
+
return null;
|
|
7714
|
+
}
|
|
7715
|
+
function asRecord3(value) {
|
|
7716
|
+
return value !== null && typeof value === "object" ? value : null;
|
|
7717
|
+
}
|
|
7718
|
+
|
|
6847
7719
|
// src/dispatcher.ts
|
|
6848
7720
|
var PREPARING_TOOL_NAME = "preparing";
|
|
6849
7721
|
var PREPARE_FAILED_PREFIX = "**Couldn't prepare your environment.** I wasn't able to provision a working directory for this conversation, so I can't run this turn. The provisioning command reported:";
|
|
6850
|
-
var MISSING_SECRET_PREFIX = "**Missing secret on this
|
|
7722
|
+
var MISSING_SECRET_PREFIX = "**Missing secret on this companion.** This agent's tools need a credential this device hasn't been given, so I can't run this turn safely. Declare it in this companion\u2019s secret store (`~/.cabane/secrets.json`) and try again. Missing:";
|
|
6851
7723
|
var STOPPED_MARKER_BODY = "(stopped)";
|
|
6852
|
-
var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this
|
|
6853
|
-
var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this
|
|
7724
|
+
var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
|
|
7725
|
+
var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
|
|
6854
7726
|
var SKIPPED_MARKER_BODY = "(skipped)";
|
|
6855
7727
|
var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
|
|
6856
7728
|
var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
|
|
@@ -6858,23 +7730,6 @@ var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 45 * 6e4;
|
|
|
6858
7730
|
function runKey(conversationId, agentId) {
|
|
6859
7731
|
return `${conversationId}|${agentId}`;
|
|
6860
7732
|
}
|
|
6861
|
-
function describeSubAgentError(status, body) {
|
|
6862
|
-
const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
|
|
6863
|
-
switch (code) {
|
|
6864
|
-
case "callout_cap_exceeded":
|
|
6865
|
-
return "sub_agent: you already have the maximum open sub-agents for this thread. Wait for some to return \u2014 you're woken once they're all back \u2014 before spawning more.";
|
|
6866
|
-
case "callout_depth_exceeded":
|
|
6867
|
-
return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
|
|
6868
|
-
case "dispatch_agent_not_found":
|
|
6869
|
-
return "sub_agent: no live agent in this workspace matches that `agentId`. Check `list_agents`, or omit `agentId` to spawn yourself.";
|
|
6870
|
-
case "dispatch_return_requires_turn":
|
|
6871
|
-
case "dispatch_return_requires_agent":
|
|
6872
|
-
case "dispatch_return_requires_dispatch":
|
|
6873
|
-
return `sub_agent: the spawn was rejected (${code}). This is a turn-context problem, not something to retry blindly \u2014 report it rather than looping.`;
|
|
6874
|
-
default:
|
|
6875
|
-
return `sub_agent: the spawn failed (${code ?? `HTTP ${status}`}).`;
|
|
6876
|
-
}
|
|
6877
|
-
}
|
|
6878
7733
|
var Dispatcher = class {
|
|
6879
7734
|
constructor(opts) {
|
|
6880
7735
|
this.opts = opts;
|
|
@@ -6895,9 +7750,9 @@ var Dispatcher = class {
|
|
|
6895
7750
|
}
|
|
6896
7751
|
}
|
|
6897
7752
|
// CT138: shared pre-run teardown for every early-return that happens BEFORE
|
|
6898
|
-
// the active-run flag is flipped (the `
|
|
7753
|
+
// the active-run flag is flipped (the `setActiveRun` working-flip below).
|
|
6899
7754
|
// The server lights the "X is replying…" indicator eagerly at dispatch
|
|
6900
|
-
// (chat-dispatch.ts `scheduleRun`), and from that point only the
|
|
7755
|
+
// (chat-dispatch.ts `scheduleRun`), and from that point only the companion can
|
|
6901
7756
|
// clear it — the SJ383 `finally` after the SDK loop is the one clear, and
|
|
6902
7757
|
// every pre-run exit returns before reaching it. So each pre-run failure has
|
|
6903
7758
|
// to clear `active_run_started_at` itself, mirroring that `finally`, or the
|
|
@@ -6915,15 +7770,15 @@ var Dispatcher = class {
|
|
|
6915
7770
|
const body = { activeRunStartedAt: null };
|
|
6916
7771
|
if (errorReason) body.errorReason = errorReason.slice(0, 200);
|
|
6917
7772
|
try {
|
|
6918
|
-
await this.opts.api.
|
|
7773
|
+
await this.opts.api.setActiveRun(
|
|
6919
7774
|
this.opts.workspaceId,
|
|
6920
7775
|
payload.conversationId,
|
|
6921
7776
|
payload.agentId,
|
|
6922
7777
|
body
|
|
6923
7778
|
);
|
|
6924
|
-
} catch (
|
|
7779
|
+
} catch (err2) {
|
|
6925
7780
|
turnLog.warn(
|
|
6926
|
-
{ err:
|
|
7781
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
6927
7782
|
"dispatcher: pre-run active-run clear failed terminally; server age-sweep is the backstop"
|
|
6928
7783
|
);
|
|
6929
7784
|
}
|
|
@@ -6941,19 +7796,24 @@ var Dispatcher = class {
|
|
|
6941
7796
|
agentId: payload.agentId,
|
|
6942
7797
|
messageId: payload.messageId
|
|
6943
7798
|
});
|
|
7799
|
+
const turnId = randomUUID();
|
|
6944
7800
|
let turnContext;
|
|
6945
7801
|
try {
|
|
6946
|
-
turnContext = await this.opts.api.getTurnContext(
|
|
6947
|
-
|
|
6948
|
-
|
|
7802
|
+
turnContext = await this.opts.api.getTurnContext(
|
|
7803
|
+
payload.conversationId,
|
|
7804
|
+
payload.messageId,
|
|
7805
|
+
turnId
|
|
7806
|
+
);
|
|
7807
|
+
} catch (err2) {
|
|
7808
|
+
const status = err2 instanceof ApiError ? err2.status : 0;
|
|
6949
7809
|
if (status === 404) {
|
|
6950
7810
|
turnLog.warn(
|
|
6951
|
-
{ err:
|
|
7811
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
6952
7812
|
"dispatcher: turn-context 404 (stale event \u2014 conversation/message/participant gone); skipping"
|
|
6953
7813
|
);
|
|
6954
7814
|
return this.concludeBeforeRun(payload, turnLog, startedAt, "turn_context_not_found");
|
|
6955
7815
|
}
|
|
6956
|
-
const reason =
|
|
7816
|
+
const reason = err2 instanceof Error ? err2.message : String(err2);
|
|
6957
7817
|
turnLog.error({ err: reason }, "dispatcher: failed to fetch turn context");
|
|
6958
7818
|
const fetchReason = `fetch_failed: ${reason}`;
|
|
6959
7819
|
return this.concludeBeforeRun(payload, turnLog, startedAt, fetchReason, fetchReason);
|
|
@@ -6984,7 +7844,7 @@ var Dispatcher = class {
|
|
|
6984
7844
|
);
|
|
6985
7845
|
if (missing.length > 0) {
|
|
6986
7846
|
const list = missing.map((n) => `\`${n}\``).join(", ");
|
|
6987
|
-
turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this
|
|
7847
|
+
turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this companion");
|
|
6988
7848
|
try {
|
|
6989
7849
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
6990
7850
|
body: `${MISSING_SECRET_PREFIX} ${list}`,
|
|
@@ -7005,7 +7865,6 @@ var Dispatcher = class {
|
|
|
7005
7865
|
const localCwd = this.opts.local.cwd;
|
|
7006
7866
|
const prepareHook = this.opts.local.prepareHook;
|
|
7007
7867
|
const cabaneCwd = turnContext.cwd;
|
|
7008
|
-
const turnId = randomUUID();
|
|
7009
7868
|
let seqCounter = 0;
|
|
7010
7869
|
const nextSeq = () => ++seqCounter;
|
|
7011
7870
|
let effectiveCwd = localCwd ?? cabaneCwd;
|
|
@@ -7017,11 +7876,20 @@ var Dispatcher = class {
|
|
|
7017
7876
|
effectiveCwd = void 0;
|
|
7018
7877
|
}
|
|
7019
7878
|
let hookEnv;
|
|
7879
|
+
let preparedNativeAssignment;
|
|
7020
7880
|
if (prepareHook) {
|
|
7021
|
-
const
|
|
7881
|
+
const assignment = turnContext.conversation.nativeWorkAssignment;
|
|
7882
|
+
const assignmentKey = assignment ? `${assignment.executionId}:${assignment.activationEpoch}` : void 0;
|
|
7883
|
+
const cached2 = readPrepared(
|
|
7884
|
+
workspaceId,
|
|
7885
|
+
payload.conversationId,
|
|
7886
|
+
payload.agentId,
|
|
7887
|
+
assignmentKey
|
|
7888
|
+
);
|
|
7022
7889
|
if (cached2) {
|
|
7023
7890
|
effectiveCwd = cached2.cwd;
|
|
7024
7891
|
hookEnv = cached2.env;
|
|
7892
|
+
preparedNativeAssignment = cached2.nativeWorkAssignment;
|
|
7025
7893
|
} else {
|
|
7026
7894
|
const delayMs = this.opts.preparingRowDelayMs ?? DEFAULT_PREPARING_ROW_DELAY_MS;
|
|
7027
7895
|
let preparingStarted = false;
|
|
@@ -7036,9 +7904,9 @@ var Dispatcher = class {
|
|
|
7036
7904
|
summary: "",
|
|
7037
7905
|
phase,
|
|
7038
7906
|
seq
|
|
7039
|
-
}).catch((
|
|
7907
|
+
}).catch((err2) => {
|
|
7040
7908
|
turnLog.warn(
|
|
7041
|
-
{ err:
|
|
7909
|
+
{ err: err2 instanceof Error ? err2.message : String(err2), phase },
|
|
7042
7910
|
"dispatcher: preparing-activity report failed (continuing with the hook)"
|
|
7043
7911
|
);
|
|
7044
7912
|
});
|
|
@@ -7055,21 +7923,30 @@ var Dispatcher = class {
|
|
|
7055
7923
|
conversationId: payload.conversationId,
|
|
7056
7924
|
agentId: payload.agentId,
|
|
7057
7925
|
agentUsername: this.opts.agentUsername,
|
|
7926
|
+
runtime: turnContext.runtime,
|
|
7058
7927
|
// CT317/CT319: the trigger message's referenced-entry paths — what the
|
|
7059
7928
|
// tasker prepare hook keys its per-task env off. Defaults to `[]` for
|
|
7060
7929
|
// an older API. The conversation anchor is gone (CT319).
|
|
7061
7930
|
triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
|
|
7931
|
+
...turnContext.conversation.nativeWorkAssignment ? { nativeWorkAssignment: turnContext.conversation.nativeWorkAssignment } : {},
|
|
7062
7932
|
title: turnContext.conversation.title
|
|
7063
7933
|
});
|
|
7064
7934
|
clearTimeout(preparingTimer);
|
|
7065
7935
|
if (preparingStarted) reportPreparing("done");
|
|
7066
|
-
writePrepared(
|
|
7936
|
+
writePrepared(
|
|
7937
|
+
workspaceId,
|
|
7938
|
+
payload.conversationId,
|
|
7939
|
+
payload.agentId,
|
|
7940
|
+
result,
|
|
7941
|
+
assignmentKey
|
|
7942
|
+
);
|
|
7067
7943
|
effectiveCwd = result.cwd;
|
|
7068
7944
|
hookEnv = result.env;
|
|
7069
|
-
|
|
7945
|
+
preparedNativeAssignment = result.nativeWorkAssignment;
|
|
7946
|
+
} catch (err2) {
|
|
7070
7947
|
clearTimeout(preparingTimer);
|
|
7071
7948
|
if (preparingStarted) reportPreparing("error");
|
|
7072
|
-
const reason =
|
|
7949
|
+
const reason = err2 instanceof Error ? err2.message : String(err2);
|
|
7073
7950
|
turnLog.error({ err: reason }, "dispatcher: prepare hook failed");
|
|
7074
7951
|
try {
|
|
7075
7952
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
@@ -7097,7 +7974,7 @@ ${reason}`,
|
|
|
7097
7974
|
this.aborts.set(key, abortController);
|
|
7098
7975
|
let timeoutReason = null;
|
|
7099
7976
|
try {
|
|
7100
|
-
await this.opts.api.
|
|
7977
|
+
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
7101
7978
|
activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7102
7979
|
// CT33: hand the server this turn's id so the new-run chokepoint's
|
|
7103
7980
|
// `closeAbandonedTurns` sweep excludes it. The prepare hook may have
|
|
@@ -7106,9 +7983,9 @@ ${reason}`,
|
|
|
7106
7983
|
// this live turn for an abandoned one and close it with a `stopped`.
|
|
7107
7984
|
turnId
|
|
7108
7985
|
});
|
|
7109
|
-
} catch (
|
|
7986
|
+
} catch (err2) {
|
|
7110
7987
|
turnLog.warn(
|
|
7111
|
-
{ err:
|
|
7988
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7112
7989
|
"dispatcher: active-run flag set failed terminally; proceeding"
|
|
7113
7990
|
);
|
|
7114
7991
|
}
|
|
@@ -7145,18 +8022,49 @@ ${reason}`,
|
|
|
7145
8022
|
subAgentCreate,
|
|
7146
8023
|
wakeState
|
|
7147
8024
|
);
|
|
7148
|
-
const
|
|
8025
|
+
const nativeTurnControl = {
|
|
8026
|
+
summon: (agentId) => {
|
|
8027
|
+
summonState.agentId = agentId;
|
|
8028
|
+
},
|
|
8029
|
+
skip: (reason) => {
|
|
8030
|
+
skipState.skipped = true;
|
|
8031
|
+
skipState.reason = reason;
|
|
8032
|
+
},
|
|
8033
|
+
ask: (p) => {
|
|
8034
|
+
askState.targetUserId = p.targetUserId;
|
|
8035
|
+
if (p.questions && p.questions.length > 0) {
|
|
8036
|
+
askState.questions = p.questions;
|
|
8037
|
+
askState.question = null;
|
|
8038
|
+
askState.headline = null;
|
|
8039
|
+
askState.options = null;
|
|
8040
|
+
} else {
|
|
8041
|
+
askState.question = p.question ?? null;
|
|
8042
|
+
askState.headline = p.headline ?? null;
|
|
8043
|
+
askState.options = p.options ?? null;
|
|
8044
|
+
askState.questions = null;
|
|
8045
|
+
}
|
|
8046
|
+
},
|
|
8047
|
+
wake: (p) => {
|
|
8048
|
+
wakeState.afterSeconds = p.afterSeconds ?? null;
|
|
8049
|
+
wakeState.at = p.at ?? null;
|
|
8050
|
+
wakeState.note = p.note;
|
|
8051
|
+
},
|
|
8052
|
+
subAgent: subAgentCreate
|
|
8053
|
+
};
|
|
8054
|
+
const request = buildCompanionTurnRequest({
|
|
7149
8055
|
turnContext,
|
|
7150
8056
|
baseUrl: this.opts.baseUrl,
|
|
7151
8057
|
agentPat: this.opts.credential,
|
|
7152
8058
|
// CT306: the per-turn OBO credential when the API minted one; falls back to
|
|
7153
|
-
// the
|
|
8059
|
+
// the companion PAT (`agentPat`) inside `buildCompanionTurnRequest` otherwise.
|
|
7154
8060
|
...turnContext.turnToken ? { turnToken: turnContext.turnToken } : {},
|
|
7155
8061
|
// SJ524: the hook-resolved cwd overrides the static local cwd.
|
|
7156
8062
|
...effectiveCwd ? { cwd: effectiveCwd } : {},
|
|
7157
8063
|
...hookEnv ? { env: hookEnv } : {},
|
|
8064
|
+
...preparedNativeAssignment ? { nativeWorkAssignment: preparedNativeAssignment } : {},
|
|
7158
8065
|
mcpServers: resolvedMcpServers,
|
|
7159
8066
|
summonServer,
|
|
8067
|
+
turnControl: nativeTurnControl,
|
|
7160
8068
|
// CT238: this turn's conversation, forwarded as the active-conversation
|
|
7161
8069
|
// header so a cross-thread post/spawn stamps its origin.
|
|
7162
8070
|
activeConversationId: payload.conversationId,
|
|
@@ -7181,15 +8089,15 @@ ${reason}`,
|
|
|
7181
8089
|
let adapter;
|
|
7182
8090
|
try {
|
|
7183
8091
|
adapter = selectAdapter(registry, turnContext.runtime);
|
|
7184
|
-
} catch (
|
|
7185
|
-
if (!(
|
|
8092
|
+
} catch (err2) {
|
|
8093
|
+
if (!(err2 instanceof RuntimeUnavailableError)) throw err2;
|
|
7186
8094
|
turnLog.error(
|
|
7187
|
-
{ runtime:
|
|
8095
|
+
{ runtime: err2.runtime, available: err2.available },
|
|
7188
8096
|
"dispatcher: turn runtime not available on this device"
|
|
7189
8097
|
);
|
|
7190
8098
|
try {
|
|
7191
8099
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7192
|
-
body: `${RUNTIME_UNAVAILABLE_PREFIX} ${
|
|
8100
|
+
body: `${RUNTIME_UNAVAILABLE_PREFIX} ${err2.message}`,
|
|
7193
8101
|
kind: "final",
|
|
7194
8102
|
turnId,
|
|
7195
8103
|
parentMessageId: payload.messageId
|
|
@@ -7204,8 +8112,60 @@ ${reason}`,
|
|
|
7204
8112
|
payload,
|
|
7205
8113
|
turnLog,
|
|
7206
8114
|
startedAt,
|
|
7207
|
-
`runtime_unavailable:${
|
|
8115
|
+
`runtime_unavailable:${err2.runtime}`
|
|
8116
|
+
);
|
|
8117
|
+
}
|
|
8118
|
+
if (prepareHook && hookEnv?.CABANE_TASK_ID) {
|
|
8119
|
+
const proof = await proveWorkspaceTools(request, adapter.name, {
|
|
8120
|
+
...this.opts.workspaceProofFetch ? { fetchImpl: this.opts.workspaceProofFetch } : {},
|
|
8121
|
+
harnessFingerprint: turnContext.runtime
|
|
8122
|
+
});
|
|
8123
|
+
turnLog[proof.ok ? "info" : "error"](
|
|
8124
|
+
{ workspaceProof: proof, checkout: effectiveCwd ?? null },
|
|
8125
|
+
`dispatcher: workspace tool proof ${proof.ok ? "passed" : "failed"}`
|
|
7208
8126
|
);
|
|
8127
|
+
if (effectiveCwd) {
|
|
8128
|
+
try {
|
|
8129
|
+
const diagnosticDir = join12(effectiveCwd, ".git", "cabane");
|
|
8130
|
+
mkdirSync9(diagnosticDir, { recursive: true });
|
|
8131
|
+
appendFileSync2(
|
|
8132
|
+
join12(diagnosticDir, "readiness.jsonl"),
|
|
8133
|
+
`${JSON.stringify({
|
|
8134
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8135
|
+
taskId: hookEnv.CABANE_TASK_ID,
|
|
8136
|
+
binding: hookEnv.CABANE_TASK_BINDING ?? null,
|
|
8137
|
+
checkout: effectiveCwd,
|
|
8138
|
+
classification: proof.ok ? "ready" : "workspace_tools_missing",
|
|
8139
|
+
failedCapability: proof.failedCapability,
|
|
8140
|
+
workspaceTools: proof
|
|
8141
|
+
})}
|
|
8142
|
+
`,
|
|
8143
|
+
{ mode: 384 }
|
|
8144
|
+
);
|
|
8145
|
+
} catch (error) {
|
|
8146
|
+
turnLog.warn(
|
|
8147
|
+
{ err: error instanceof Error ? error.message : String(error) },
|
|
8148
|
+
"dispatcher: workspace-proof diagnostic write failed"
|
|
8149
|
+
);
|
|
8150
|
+
}
|
|
8151
|
+
}
|
|
8152
|
+
if (!proof.ok) {
|
|
8153
|
+
const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd ?? "unknown"}; runtime=${adapter.name}; recovery=restart the connector after restoring the Cabane workspace tool mount`;
|
|
8154
|
+
try {
|
|
8155
|
+
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
8156
|
+
body: `**Couldn't prepare your environment.** ${reason}`,
|
|
8157
|
+
kind: "final",
|
|
8158
|
+
turnId,
|
|
8159
|
+
parentMessageId: payload.messageId
|
|
8160
|
+
});
|
|
8161
|
+
} catch (postErr) {
|
|
8162
|
+
turnLog.warn(
|
|
8163
|
+
{ err: postErr instanceof Error ? postErr.message : String(postErr) },
|
|
8164
|
+
"dispatcher: workspace-proof failure notice post failed"
|
|
8165
|
+
);
|
|
8166
|
+
}
|
|
8167
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
|
|
8168
|
+
}
|
|
7209
8169
|
}
|
|
7210
8170
|
const transcript = this.opts.transcriptDir ? new TranscriptWriter(
|
|
7211
8171
|
this.opts.transcriptDir,
|
|
@@ -7250,6 +8210,49 @@ ${reason}`,
|
|
|
7250
8210
|
// server arms the wake schedule atomically with the reply it rode on.
|
|
7251
8211
|
wakeState
|
|
7252
8212
|
});
|
|
8213
|
+
const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
|
|
8214
|
+
let turnControlIntentFetched = false;
|
|
8215
|
+
const applyRecordedTurnControlIntent = async () => {
|
|
8216
|
+
if (turnControlIntentFetched || !usesHttpTurnControl || !turnContext.turnToken) return;
|
|
8217
|
+
turnControlIntentFetched = true;
|
|
8218
|
+
try {
|
|
8219
|
+
const intent = await this.opts.api.getTurnIntent(
|
|
8220
|
+
workspaceId,
|
|
8221
|
+
payload.conversationId,
|
|
8222
|
+
payload.agentId,
|
|
8223
|
+
turnId
|
|
8224
|
+
);
|
|
8225
|
+
if (intent.ask) {
|
|
8226
|
+
askState.targetUserId = intent.ask.targetUserId;
|
|
8227
|
+
if (intent.ask.questions && intent.ask.questions.length > 0) {
|
|
8228
|
+
askState.questions = intent.ask.questions;
|
|
8229
|
+
askState.question = null;
|
|
8230
|
+
askState.headline = null;
|
|
8231
|
+
askState.options = null;
|
|
8232
|
+
} else {
|
|
8233
|
+
askState.question = intent.ask.question ?? null;
|
|
8234
|
+
askState.headline = intent.ask.headline ?? null;
|
|
8235
|
+
askState.options = intent.ask.options ?? null;
|
|
8236
|
+
askState.questions = null;
|
|
8237
|
+
}
|
|
8238
|
+
}
|
|
8239
|
+
if (intent.wake) {
|
|
8240
|
+
wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
|
|
8241
|
+
wakeState.at = intent.wake.at ?? null;
|
|
8242
|
+
wakeState.note = intent.wake.note;
|
|
8243
|
+
}
|
|
8244
|
+
if (intent.summonAgentId) summonState.agentId = intent.summonAgentId;
|
|
8245
|
+
if (intent.skipped) {
|
|
8246
|
+
skipState.skipped = true;
|
|
8247
|
+
skipState.reason = intent.skipReason;
|
|
8248
|
+
}
|
|
8249
|
+
} catch (err2) {
|
|
8250
|
+
turnLog.warn(
|
|
8251
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
8252
|
+
"dispatcher: turn-control intent fetch failed; turn-control effects for this turn are dropped"
|
|
8253
|
+
);
|
|
8254
|
+
}
|
|
8255
|
+
};
|
|
7253
8256
|
const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
|
|
7254
8257
|
const totalTimeoutMs = this.opts.totalTimeoutMs ?? DEFAULT_AGENT_TOTAL_TIMEOUT_MS;
|
|
7255
8258
|
const fireTimeout = (reason) => {
|
|
@@ -7285,15 +8288,15 @@ ${reason}`,
|
|
|
7285
8288
|
if (!sessionWritten) {
|
|
7286
8289
|
sessionWritten = true;
|
|
7287
8290
|
try {
|
|
7288
|
-
await this.opts.api.
|
|
8291
|
+
await this.opts.api.setActiveRun(
|
|
7289
8292
|
workspaceId,
|
|
7290
8293
|
payload.conversationId,
|
|
7291
8294
|
payload.agentId,
|
|
7292
8295
|
{ agentSessionId: event.state }
|
|
7293
8296
|
);
|
|
7294
|
-
} catch (
|
|
8297
|
+
} catch (err2) {
|
|
7295
8298
|
turnLog.warn(
|
|
7296
|
-
{ err:
|
|
8299
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7297
8300
|
"dispatcher: session-id write failed (will retry next turn)"
|
|
7298
8301
|
);
|
|
7299
8302
|
}
|
|
@@ -7306,6 +8309,9 @@ ${reason}`,
|
|
|
7306
8309
|
turnResolvedConfig = event.resolvedConfig;
|
|
7307
8310
|
} else if (event.type === "text" && skipState.skipped) {
|
|
7308
8311
|
} else {
|
|
8312
|
+
if (event.type === "text" && event.terminal) {
|
|
8313
|
+
await applyRecordedTurnControlIntent();
|
|
8314
|
+
}
|
|
7309
8315
|
await committer.ingestEvent(event);
|
|
7310
8316
|
}
|
|
7311
8317
|
}
|
|
@@ -7316,31 +8322,41 @@ ${reason}`,
|
|
|
7316
8322
|
if (!okResult && !resultReason) {
|
|
7317
8323
|
resultReason = "no_result";
|
|
7318
8324
|
}
|
|
8325
|
+
if (!abortController.signal.aborted) {
|
|
8326
|
+
await applyRecordedTurnControlIntent();
|
|
8327
|
+
}
|
|
7319
8328
|
if (!abortController.signal.aborted && skipState.skipped) {
|
|
7320
8329
|
turnLog.info(
|
|
7321
8330
|
{ reason: skipState.reason, turnId, ok: okResult },
|
|
7322
8331
|
"agent skipped turn (skip_turn)"
|
|
7323
8332
|
);
|
|
8333
|
+
const { afterSeconds: wakeAfter, at: wakeAt, note: wakeNote } = wakeState;
|
|
8334
|
+
const skipWake = wakeNote && (wakeAfter !== null || wakeAt !== null) ? {
|
|
8335
|
+
...wakeAfter !== null ? { afterSeconds: wakeAfter } : {},
|
|
8336
|
+
...wakeAt !== null ? { at: wakeAt } : {},
|
|
8337
|
+
note: wakeNote
|
|
8338
|
+
} : void 0;
|
|
7324
8339
|
try {
|
|
7325
8340
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7326
8341
|
body: SKIPPED_MARKER_BODY,
|
|
7327
8342
|
kind: "skipped",
|
|
7328
8343
|
turnId,
|
|
7329
8344
|
seq: nextSeq(),
|
|
7330
|
-
parentMessageId: payload.messageId
|
|
8345
|
+
parentMessageId: payload.messageId,
|
|
8346
|
+
...skipWake ? { wake: skipWake } : {}
|
|
7331
8347
|
});
|
|
7332
|
-
} catch (
|
|
8348
|
+
} catch (err2) {
|
|
7333
8349
|
turnLog.warn(
|
|
7334
|
-
{ err:
|
|
8350
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7335
8351
|
"dispatcher: skipped-marker commit failed"
|
|
7336
8352
|
);
|
|
7337
8353
|
}
|
|
7338
8354
|
} else {
|
|
7339
8355
|
await committer.finalize(okResult);
|
|
7340
8356
|
}
|
|
7341
|
-
} catch (
|
|
8357
|
+
} catch (err2) {
|
|
7342
8358
|
okResult = false;
|
|
7343
|
-
resultReason =
|
|
8359
|
+
resultReason = err2 instanceof Error ? err2.message : String(err2);
|
|
7344
8360
|
turnLog.error({ err: resultReason }, "dispatcher: SDK query threw");
|
|
7345
8361
|
} finally {
|
|
7346
8362
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -7376,9 +8392,9 @@ ${reason}`,
|
|
|
7376
8392
|
// CT113: the stopped marker is still "about" the triggering message.
|
|
7377
8393
|
parentMessageId: payload.messageId
|
|
7378
8394
|
});
|
|
7379
|
-
} catch (
|
|
8395
|
+
} catch (err2) {
|
|
7380
8396
|
turnLog.warn(
|
|
7381
|
-
{ err:
|
|
8397
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7382
8398
|
"dispatcher: stopped-marker commit failed"
|
|
7383
8399
|
);
|
|
7384
8400
|
}
|
|
@@ -7413,15 +8429,15 @@ ${reason}`,
|
|
|
7413
8429
|
errorReason: body.errorReason ?? null
|
|
7414
8430
|
});
|
|
7415
8431
|
try {
|
|
7416
|
-
await this.opts.api.
|
|
8432
|
+
await this.opts.api.setActiveRun(
|
|
7417
8433
|
workspaceId,
|
|
7418
8434
|
payload.conversationId,
|
|
7419
8435
|
payload.agentId,
|
|
7420
8436
|
body
|
|
7421
8437
|
);
|
|
7422
|
-
} catch (
|
|
8438
|
+
} catch (err2) {
|
|
7423
8439
|
turnLog.warn(
|
|
7424
|
-
{ err:
|
|
8440
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7425
8441
|
"dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
|
|
7426
8442
|
);
|
|
7427
8443
|
}
|
|
@@ -7465,7 +8481,7 @@ ${reason}`,
|
|
|
7465
8481
|
};
|
|
7466
8482
|
}
|
|
7467
8483
|
// SJ383: cancel a specific (conversation, agent) run if one is in flight in
|
|
7468
|
-
// THIS
|
|
8484
|
+
// THIS companion process. Returns true if an in-flight run was aborted.
|
|
7469
8485
|
cancel(conversationId, agentId) {
|
|
7470
8486
|
const key = runKey(conversationId, agentId);
|
|
7471
8487
|
const ac = this.aborts.get(key);
|
|
@@ -7479,27 +8495,18 @@ ${reason}`,
|
|
|
7479
8495
|
};
|
|
7480
8496
|
|
|
7481
8497
|
// src/manifest.ts
|
|
7482
|
-
var
|
|
8498
|
+
var DEVICE_MANIFEST = {
|
|
7483
8499
|
runtimes: [{ name: "claude-code", version: null }],
|
|
7484
8500
|
capabilities: { hostFs: true, browser: true, userMcp: true }
|
|
7485
8501
|
};
|
|
7486
|
-
|
|
7487
|
-
runtimes: [{ name: "claude-code", version: null }],
|
|
7488
|
-
capabilities: { hostFs: false, browser: false, userMcp: false }
|
|
7489
|
-
};
|
|
7490
|
-
function buildBridgeManifest(opts) {
|
|
7491
|
-
if (process.env.CABANE_BRIDGE_CLASS === "house") {
|
|
7492
|
-
const runtimes2 = [{ name: "claude-code", version: null }];
|
|
7493
|
-
if (opts.cabaneNative) runtimes2.push({ name: "cabane-native", version: null });
|
|
7494
|
-
return { runtimes: runtimes2, capabilities: { ...HOUSE_MANIFEST.capabilities } };
|
|
7495
|
-
}
|
|
8502
|
+
function buildCompanionManifest(opts) {
|
|
7496
8503
|
const v = opts.versions ?? {};
|
|
7497
8504
|
const runtimes = [];
|
|
7498
8505
|
if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
|
|
7499
8506
|
if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
|
|
7500
8507
|
if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
|
|
7501
8508
|
if (opts.cabaneNative) runtimes.push({ name: "cabane-native", version: null });
|
|
7502
|
-
return { runtimes, capabilities: { ...
|
|
8509
|
+
return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
|
|
7503
8510
|
}
|
|
7504
8511
|
|
|
7505
8512
|
// src/harness-status.ts
|
|
@@ -7510,7 +8517,7 @@ var LABELS = {
|
|
|
7510
8517
|
};
|
|
7511
8518
|
function deriveHarnessSnapshot(signals) {
|
|
7512
8519
|
const advertised = new Set(
|
|
7513
|
-
|
|
8520
|
+
buildCompanionManifest({
|
|
7514
8521
|
claudeCode: signals.claudeOnPath,
|
|
7515
8522
|
opencode: signals.opencodeConfigured,
|
|
7516
8523
|
codex: signals.codexEnabled
|
|
@@ -7700,14 +8707,14 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
|
|
|
7700
8707
|
// src/outbox.ts
|
|
7701
8708
|
import {
|
|
7702
8709
|
existsSync as existsSync10,
|
|
7703
|
-
mkdirSync as
|
|
8710
|
+
mkdirSync as mkdirSync10,
|
|
7704
8711
|
readdirSync as readdirSync2,
|
|
7705
8712
|
readFileSync as readFileSync8,
|
|
7706
8713
|
renameSync as renameSync3,
|
|
7707
8714
|
rmSync as rmSync5,
|
|
7708
8715
|
writeFileSync as writeFileSync7
|
|
7709
8716
|
} from "fs";
|
|
7710
|
-
import { join as
|
|
8717
|
+
import { join as join13 } from "path";
|
|
7711
8718
|
var MAX_ENTRIES = 2e3;
|
|
7712
8719
|
var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
7713
8720
|
var Outbox = class {
|
|
@@ -7720,30 +8727,30 @@ var Outbox = class {
|
|
|
7720
8727
|
// Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
|
|
7721
8728
|
// cases route writes at the right tmpdir.
|
|
7722
8729
|
dir() {
|
|
7723
|
-
return
|
|
8730
|
+
return join13(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
|
|
7724
8731
|
}
|
|
7725
8732
|
fileFor(turnId, seq) {
|
|
7726
|
-
return
|
|
8733
|
+
return join13(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
|
|
7727
8734
|
}
|
|
7728
8735
|
// Persist a commit for later draining. Atomic (temp file + rename) so a
|
|
7729
8736
|
// concurrent `list()` never reads a half-written entry, then enforces the
|
|
7730
8737
|
// per-workspace bounds.
|
|
7731
8738
|
persist(entry) {
|
|
7732
8739
|
const dir2 = this.dir();
|
|
7733
|
-
|
|
8740
|
+
mkdirSync10(dir2, { recursive: true });
|
|
7734
8741
|
const target = this.fileFor(entry.turnId, entry.seq);
|
|
7735
8742
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
7736
8743
|
try {
|
|
7737
8744
|
writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
|
|
7738
8745
|
renameSync3(tmp, target);
|
|
7739
|
-
} catch (
|
|
8746
|
+
} catch (err2) {
|
|
7740
8747
|
try {
|
|
7741
8748
|
rmSync5(tmp, { force: true });
|
|
7742
8749
|
} catch {
|
|
7743
8750
|
}
|
|
7744
8751
|
this.log?.warn(
|
|
7745
|
-
{ workspaceId: this.workspaceId, err:
|
|
7746
|
-
"
|
|
8752
|
+
{ workspaceId: this.workspaceId, err: err2 instanceof Error ? err2.message : String(err2) },
|
|
8753
|
+
"companion outbox: failed to persist entry"
|
|
7747
8754
|
);
|
|
7748
8755
|
return;
|
|
7749
8756
|
}
|
|
@@ -7765,7 +8772,7 @@ var Outbox = class {
|
|
|
7765
8772
|
const entries = [];
|
|
7766
8773
|
for (const name of names) {
|
|
7767
8774
|
if (!name.endsWith(".json")) continue;
|
|
7768
|
-
const full =
|
|
8775
|
+
const full = join13(dir2, name);
|
|
7769
8776
|
try {
|
|
7770
8777
|
const parsed = JSON.parse(readFileSync8(full, "utf8"));
|
|
7771
8778
|
if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
|
|
@@ -7801,7 +8808,7 @@ var Outbox = class {
|
|
|
7801
8808
|
dropCorrupt(full) {
|
|
7802
8809
|
this.log?.warn(
|
|
7803
8810
|
{ workspaceId: this.workspaceId, file: full },
|
|
7804
|
-
"
|
|
8811
|
+
"companion outbox: dropping unreadable entry"
|
|
7805
8812
|
);
|
|
7806
8813
|
try {
|
|
7807
8814
|
rmSync5(full, { force: true });
|
|
@@ -7818,7 +8825,7 @@ var Outbox = class {
|
|
|
7818
8825
|
if (now - e.enqueuedAt > MAX_AGE_MS) {
|
|
7819
8826
|
this.log?.warn(
|
|
7820
8827
|
{ workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
|
|
7821
|
-
"
|
|
8828
|
+
"companion outbox: evicting entry past max age (undeliverable)"
|
|
7822
8829
|
);
|
|
7823
8830
|
this.remove(e.turnId, e.seq);
|
|
7824
8831
|
} else {
|
|
@@ -7830,7 +8837,7 @@ var Outbox = class {
|
|
|
7830
8837
|
for (const e of survivors.slice(0, overflow)) {
|
|
7831
8838
|
this.log?.warn(
|
|
7832
8839
|
{ workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
|
|
7833
|
-
"
|
|
8840
|
+
"companion outbox: evicting oldest entry past max size"
|
|
7834
8841
|
);
|
|
7835
8842
|
this.remove(e.turnId, e.seq);
|
|
7836
8843
|
}
|
|
@@ -7917,20 +8924,20 @@ var SseSubscriber = class {
|
|
|
7917
8924
|
try {
|
|
7918
8925
|
await this.connect();
|
|
7919
8926
|
backoff = 500;
|
|
7920
|
-
} catch (
|
|
8927
|
+
} catch (err2) {
|
|
7921
8928
|
if (this.aborted) return;
|
|
7922
|
-
if (
|
|
8929
|
+
if (err2 instanceof ApiError && (err2.status === 401 || err2.status === 403)) {
|
|
7923
8930
|
this.opts.log.error(
|
|
7924
|
-
{ workspaceId: this.opts.workspaceId, status:
|
|
8931
|
+
{ workspaceId: this.opts.workspaceId, status: err2.status },
|
|
7925
8932
|
"SSE auth failed \u2014 tearing down this workspace subscriber"
|
|
7926
8933
|
);
|
|
7927
|
-
this.opts.onAuthFailure(
|
|
8934
|
+
this.opts.onAuthFailure(err2.status);
|
|
7928
8935
|
return;
|
|
7929
8936
|
}
|
|
7930
8937
|
this.opts.log.warn(
|
|
7931
8938
|
{
|
|
7932
8939
|
workspaceId: this.opts.workspaceId,
|
|
7933
|
-
err:
|
|
8940
|
+
err: err2 instanceof Error ? err2.message : String(err2),
|
|
7934
8941
|
backoff
|
|
7935
8942
|
},
|
|
7936
8943
|
"SSE disconnected; reconnecting"
|
|
@@ -7993,7 +9000,7 @@ function sleep2(ms) {
|
|
|
7993
9000
|
// src/version.ts
|
|
7994
9001
|
import { createRequire as createRequire2 } from "module";
|
|
7995
9002
|
var pkg = createRequire2(import.meta.url)("../package.json");
|
|
7996
|
-
var
|
|
9003
|
+
var COMPANION_VERSION = pkg.version;
|
|
7997
9004
|
|
|
7998
9005
|
// src/supervisor.ts
|
|
7999
9006
|
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
@@ -8007,7 +9014,7 @@ var ASSIGNMENTS_POLL_MS = 6e4;
|
|
|
8007
9014
|
var DRAIN_BASE_MS = 1e3;
|
|
8008
9015
|
var DRAIN_MAX_MS = 3e4;
|
|
8009
9016
|
var DRAIN_IDLE_MS = 15e3;
|
|
8010
|
-
var
|
|
9017
|
+
var CompanionSupervisor = class {
|
|
8011
9018
|
workspaces = /* @__PURE__ */ new Map();
|
|
8012
9019
|
config;
|
|
8013
9020
|
log;
|
|
@@ -8031,10 +9038,12 @@ var BridgeSupervisor = class {
|
|
|
8031
9038
|
dispatcherFactory;
|
|
8032
9039
|
deviceApi = null;
|
|
8033
9040
|
heartbeatTimer = null;
|
|
9041
|
+
inFlightHeartbeat = null;
|
|
8034
9042
|
pollTimer = null;
|
|
8035
9043
|
refreshing = false;
|
|
8036
9044
|
stopped = false;
|
|
8037
|
-
|
|
9045
|
+
draining = false;
|
|
9046
|
+
// CT484: latch so the companion/server version-skew warning is logged once, not
|
|
8038
9047
|
// on every 30s heartbeat.
|
|
8039
9048
|
versionSkewWarned = false;
|
|
8040
9049
|
// This device's id, captured from the heartbeat / assignments response. The SSE
|
|
@@ -8060,18 +9069,18 @@ var BridgeSupervisor = class {
|
|
|
8060
9069
|
this.dispatcherFactory = opts.dispatcherFactory;
|
|
8061
9070
|
}
|
|
8062
9071
|
// Stand up the data plane: pair check, initial assignments pull, then the
|
|
8063
|
-
// heartbeat + poll loops. A
|
|
9072
|
+
// heartbeat + poll loops. A companion with no device token (logged out) does
|
|
8064
9073
|
// nothing but say so.
|
|
8065
9074
|
async start() {
|
|
8066
9075
|
this.log.info(
|
|
8067
|
-
{ protocolVersion: TURN_PROTOCOL_VERSION, version:
|
|
8068
|
-
"
|
|
9076
|
+
{ protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
|
|
9077
|
+
"companion: starting"
|
|
8069
9078
|
);
|
|
8070
9079
|
void this.refreshHarnessStatuses();
|
|
8071
9080
|
if (!this.config.deviceToken) {
|
|
8072
|
-
this.log.warn("
|
|
9081
|
+
this.log.warn("companion: not paired (no device token) \u2014 run `cabane-companion pair`");
|
|
8073
9082
|
process.stdout.write(
|
|
8074
|
-
"
|
|
9083
|
+
"companion: this device is not paired \u2014 run `cabane-companion pair` and paste the string from the cabane app.\n"
|
|
8075
9084
|
);
|
|
8076
9085
|
return;
|
|
8077
9086
|
}
|
|
@@ -8080,8 +9089,8 @@ var BridgeSupervisor = class {
|
|
|
8080
9089
|
deviceToken: this.config.deviceToken
|
|
8081
9090
|
});
|
|
8082
9091
|
await this.refreshAssignments();
|
|
8083
|
-
|
|
8084
|
-
this.heartbeatTimer = setInterval(() =>
|
|
9092
|
+
this.kickHeartbeat();
|
|
9093
|
+
this.heartbeatTimer = setInterval(() => this.kickHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
|
8085
9094
|
this.heartbeatTimer.unref?.();
|
|
8086
9095
|
this.pollTimer = setInterval(() => void this.refreshAssignments(), ASSIGNMENTS_POLL_MS);
|
|
8087
9096
|
this.pollTimer.unref?.();
|
|
@@ -8094,20 +9103,28 @@ var BridgeSupervisor = class {
|
|
|
8094
9103
|
return [...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : []);
|
|
8095
9104
|
}
|
|
8096
9105
|
// ---- device-level loops ----
|
|
9106
|
+
kickHeartbeat() {
|
|
9107
|
+
if (this.draining || this.inFlightHeartbeat) return;
|
|
9108
|
+
const pending = this.sendHeartbeat();
|
|
9109
|
+
this.inFlightHeartbeat = pending;
|
|
9110
|
+
void pending.finally(() => {
|
|
9111
|
+
if (this.inFlightHeartbeat === pending) this.inFlightHeartbeat = null;
|
|
9112
|
+
});
|
|
9113
|
+
}
|
|
8097
9114
|
async sendHeartbeat() {
|
|
8098
9115
|
if (!this.deviceApi) return;
|
|
8099
9116
|
await this.refreshHarnessStatuses();
|
|
8100
9117
|
try {
|
|
8101
|
-
const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "
|
|
9118
|
+
const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "companion: secrets"));
|
|
8102
9119
|
const connectorReports = this.connectorHealth.reports();
|
|
8103
9120
|
const opencodeModels = this.config.opencode?.serverUrl ? await enumerateOpencodeModels(this.config.opencode.serverUrl) : null;
|
|
8104
9121
|
const res = await this.deviceApi.heartbeat({
|
|
8105
|
-
version:
|
|
9122
|
+
version: COMPANION_VERSION,
|
|
8106
9123
|
exposedSecretNames: store.names(),
|
|
8107
9124
|
// Report each runtime only when this device can actually run it: CT309
|
|
8108
9125
|
// claude-code when `claude` is on PATH, CT270 opencode when the operator
|
|
8109
9126
|
// configured an `opencode serve`.
|
|
8110
|
-
manifest:
|
|
9127
|
+
manifest: buildCompanionManifest({
|
|
8111
9128
|
// CT586: prefer the live re-probe's presence; fall back to the boot probe
|
|
8112
9129
|
// until the first re-probe lands. Same exit-0 `claude --version` signal
|
|
8113
9130
|
// either way, so the manifest's claude-code advertising is unchanged in
|
|
@@ -8118,17 +9135,16 @@ var BridgeSupervisor = class {
|
|
|
8118
9135
|
// like opencode — the CLI's presence is the operator's responsibility;
|
|
8119
9136
|
// a misconfigured device fails the turn loudly, never silently).
|
|
8120
9137
|
codex: isCodexEnabled(this.config),
|
|
8121
|
-
// CT598: advertise the native runtime when an OpenRouter key is set
|
|
8122
|
-
//
|
|
8123
|
-
// user). Key absent → not advertised, so a native turn never routes here.
|
|
9138
|
+
// CT598: advertise the native runtime when an OpenRouter key is set. Key
|
|
9139
|
+
// absent → not advertised, so a native turn never routes here.
|
|
8124
9140
|
cabaneNative: isCabaneNativeEnabled(),
|
|
8125
9141
|
// CT571/CT586: each runtime's `version` from the latest harness probe
|
|
8126
9142
|
// (fail-soft to null). Informational only — the server matches on name.
|
|
8127
9143
|
versions: this.harnessVersions
|
|
8128
9144
|
}),
|
|
8129
9145
|
// CT566: echo the last classified credential state per runtime, when the
|
|
8130
|
-
//
|
|
8131
|
-
// failure/heal, so a fresh
|
|
9146
|
+
// companion has seen any. Omitted (undefined) until the first observed
|
|
9147
|
+
// failure/heal, so a fresh companion's beat is unchanged and the server's
|
|
8132
9148
|
// manifest synthesis (status-less rows) still runs.
|
|
8133
9149
|
...connectorReports.length > 0 ? { connectors: connectorReports } : {},
|
|
8134
9150
|
// CT584: include enumerated models only when the probe SUCCEEDED (non-null).
|
|
@@ -8139,25 +9155,25 @@ var BridgeSupervisor = class {
|
|
|
8139
9155
|
this.hub.setDevice({ deviceId: res.deviceId });
|
|
8140
9156
|
this.deviceId = res.deviceId;
|
|
8141
9157
|
this.checkVersionSkew(res.serverVersion);
|
|
8142
|
-
} catch (
|
|
9158
|
+
} catch (err2) {
|
|
8143
9159
|
this.log.warn(
|
|
8144
|
-
{ err:
|
|
8145
|
-
"
|
|
9160
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
9161
|
+
"companion: device heartbeat failed (will retry on next tick)"
|
|
8146
9162
|
);
|
|
8147
9163
|
}
|
|
8148
9164
|
}
|
|
8149
|
-
// CT484: belt-and-suspenders beside shipping the
|
|
9165
|
+
// CT484: belt-and-suspenders beside shipping the companion in lockstep inside the
|
|
8150
9166
|
// artifact — if the server reports a build version that differs from this
|
|
8151
|
-
//
|
|
8152
|
-
// an npm-pinned
|
|
8153
|
-
//
|
|
9167
|
+
// companion's, warn loudly (once). This is exactly the skew that bit the M1 walk:
|
|
9168
|
+
// an npm-pinned companion lagging a from-develop server. The elimination (bundled
|
|
9169
|
+
// companion) makes it match by construction; this catches a companion run out of band.
|
|
8154
9170
|
checkVersionSkew(serverVersion) {
|
|
8155
9171
|
if (this.versionSkewWarned) return;
|
|
8156
|
-
if (!serverVersion || serverVersion ===
|
|
9172
|
+
if (!serverVersion || serverVersion === COMPANION_VERSION) return;
|
|
8157
9173
|
this.versionSkewWarned = true;
|
|
8158
9174
|
this.log.warn(
|
|
8159
|
-
{
|
|
8160
|
-
"
|
|
9175
|
+
{ companionVersion: COMPANION_VERSION, serverVersion },
|
|
9176
|
+
"companion: VERSION SKEW \u2014 this companion and its server were built from different versions. Turns may misbehave. The self-host artifact ships a matching companion; run `cabane update` so the companion matches its server."
|
|
8161
9177
|
);
|
|
8162
9178
|
}
|
|
8163
9179
|
// Pull assignments and reconcile the live runner set against them. Re-entrancy
|
|
@@ -8177,12 +9193,12 @@ var BridgeSupervisor = class {
|
|
|
8177
9193
|
const resp = await this.deviceApi.getAssignments();
|
|
8178
9194
|
items = resp.assignments;
|
|
8179
9195
|
device = resp.device;
|
|
8180
|
-
} catch (
|
|
9196
|
+
} catch (err2) {
|
|
8181
9197
|
this.log.error(
|
|
8182
|
-
{ err:
|
|
8183
|
-
"
|
|
9198
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
9199
|
+
"companion: assignments pull failed \u2014 check the device is still active in the cabane app"
|
|
8184
9200
|
);
|
|
8185
|
-
this.hub.setDeviceError(
|
|
9201
|
+
this.hub.setDeviceError(err2 instanceof Error ? err2.message : String(err2));
|
|
8186
9202
|
return;
|
|
8187
9203
|
}
|
|
8188
9204
|
this.hub.setDevice({ deviceId: device.id, deviceLabel: device.label });
|
|
@@ -8245,14 +9261,14 @@ var BridgeSupervisor = class {
|
|
|
8245
9261
|
const credential = it.credential ?? getCredential(it.agentId);
|
|
8246
9262
|
const runConfig = parseRunConfig(
|
|
8247
9263
|
it.runConfig,
|
|
8248
|
-
(m) => this.log.warn({ agentId: it.agentId, msg: m }, "
|
|
9264
|
+
(m) => this.log.warn({ agentId: it.agentId, msg: m }, "companion: run-config")
|
|
8249
9265
|
);
|
|
8250
9266
|
const required = requiredSecretNames(runConfig.mcpServers);
|
|
8251
9267
|
const missing = required.filter((n) => !exposed.has(n));
|
|
8252
9268
|
if (!credential) {
|
|
8253
9269
|
this.log.error(
|
|
8254
9270
|
{ workspaceId, agentId: it.agentId, username: it.agentUsername },
|
|
8255
|
-
"
|
|
9271
|
+
"companion: agent assigned but no credential on this device \u2014 re-assign it in the cabane app"
|
|
8256
9272
|
);
|
|
8257
9273
|
this.removeAgent(wr, it.agentId);
|
|
8258
9274
|
this.hub.setAgent(workspaceId, {
|
|
@@ -8286,7 +9302,7 @@ var BridgeSupervisor = class {
|
|
|
8286
9302
|
if (missing.length > 0) {
|
|
8287
9303
|
this.log.warn(
|
|
8288
9304
|
{ workspaceId, agentId: it.agentId, missing },
|
|
8289
|
-
"
|
|
9305
|
+
"companion: agent needs secrets this device does not expose (turns using them will fail)"
|
|
8290
9306
|
);
|
|
8291
9307
|
}
|
|
8292
9308
|
}
|
|
@@ -8325,7 +9341,7 @@ var BridgeSupervisor = class {
|
|
|
8325
9341
|
drain2.kick();
|
|
8326
9342
|
this.log.info(
|
|
8327
9343
|
{ workspaceId: it.workspaceId, agentId: it.agentId, username: it.agentUsername },
|
|
8328
|
-
"
|
|
9344
|
+
"companion: running agent"
|
|
8329
9345
|
);
|
|
8330
9346
|
}
|
|
8331
9347
|
removeAgent(wr, agentId) {
|
|
@@ -8334,7 +9350,10 @@ var BridgeSupervisor = class {
|
|
|
8334
9350
|
runner.cancelDrain();
|
|
8335
9351
|
wr.agents.delete(agentId);
|
|
8336
9352
|
this.hub.removeAgent(wr.workspaceId, agentId);
|
|
8337
|
-
this.log.info(
|
|
9353
|
+
this.log.info(
|
|
9354
|
+
{ workspaceId: wr.workspaceId, agentId },
|
|
9355
|
+
"companion: stopped agent (unassigned)"
|
|
9356
|
+
);
|
|
8338
9357
|
}
|
|
8339
9358
|
buildDispatcher(ctx) {
|
|
8340
9359
|
if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
|
|
@@ -8363,7 +9382,7 @@ var BridgeSupervisor = class {
|
|
|
8363
9382
|
// CT598: register the cabane-native adapter when an OpenRouter key is set;
|
|
8364
9383
|
// unset leaves a `cabane-native/…` turn to fail loudly (no silent fallback).
|
|
8365
9384
|
...cabaneNativeApiKey() ? { cabaneNativeApiKey: cabaneNativeApiKey() } : {},
|
|
8366
|
-
// CT556: per-turn timeout watchdog windows, from the
|
|
9385
|
+
// CT556: per-turn timeout watchdog windows, from the companion's own env
|
|
8367
9386
|
// (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
|
|
8368
9387
|
// dispatcher's baked-in defaults (10 min idle / 45 min total).
|
|
8369
9388
|
...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
|
|
@@ -8402,14 +9421,14 @@ var BridgeSupervisor = class {
|
|
|
8402
9421
|
this.hub.setAuthFailed(wr.workspaceId);
|
|
8403
9422
|
this.log.error(
|
|
8404
9423
|
{ workspaceId: wr.workspaceId, status, sseAgentId: wr.sseAgentId },
|
|
8405
|
-
"
|
|
9424
|
+
"companion: workspace stream auth failed \u2014 re-pulling assignments"
|
|
8406
9425
|
);
|
|
8407
9426
|
wr.sseAgentId = null;
|
|
8408
9427
|
void this.refreshAssignments();
|
|
8409
9428
|
}
|
|
8410
9429
|
});
|
|
8411
9430
|
wr.sub.start();
|
|
8412
|
-
this.log.info({ workspaceId: wr.workspaceId }, "
|
|
9431
|
+
this.log.info({ workspaceId: wr.workspaceId }, "companion: subscribed");
|
|
8413
9432
|
}
|
|
8414
9433
|
async removeWorkspace(workspaceId) {
|
|
8415
9434
|
const wr = this.workspaces.get(workspaceId);
|
|
@@ -8432,17 +9451,17 @@ var BridgeSupervisor = class {
|
|
|
8432
9451
|
let wire;
|
|
8433
9452
|
try {
|
|
8434
9453
|
wire = JSON.parse(ev.data);
|
|
8435
|
-
} catch (
|
|
9454
|
+
} catch (err2) {
|
|
8436
9455
|
this.log.warn(
|
|
8437
|
-
{ err:
|
|
9456
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
8438
9457
|
"malformed SSE payload"
|
|
8439
9458
|
);
|
|
8440
9459
|
return;
|
|
8441
9460
|
}
|
|
8442
9461
|
if (ev.id) wr.cursor.observe(ev.id);
|
|
8443
|
-
if (wire.type === "
|
|
9462
|
+
if (wire.type === "device:cancel_requested") {
|
|
8444
9463
|
const payload2 = {
|
|
8445
|
-
type: "
|
|
9464
|
+
type: "device:cancel_requested",
|
|
8446
9465
|
...wire.payload
|
|
8447
9466
|
};
|
|
8448
9467
|
const agent2 = wr.agents.get(payload2.agentId);
|
|
@@ -8462,12 +9481,13 @@ var BridgeSupervisor = class {
|
|
|
8462
9481
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
8463
9482
|
return;
|
|
8464
9483
|
}
|
|
8465
|
-
if (wire.type !== "
|
|
9484
|
+
if (wire.type !== "device:dispatch_requested") {
|
|
8466
9485
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
8467
9486
|
return;
|
|
8468
9487
|
}
|
|
9488
|
+
if (this.draining) return;
|
|
8469
9489
|
const payload = {
|
|
8470
|
-
type: "
|
|
9490
|
+
type: "device:dispatch_requested",
|
|
8471
9491
|
...wire.payload
|
|
8472
9492
|
};
|
|
8473
9493
|
let agent = wr.agents.get(payload.agentId);
|
|
@@ -8480,15 +9500,15 @@ var BridgeSupervisor = class {
|
|
|
8480
9500
|
}
|
|
8481
9501
|
const chainKey = `${payload.conversationId}|${payload.agentId}`;
|
|
8482
9502
|
const prev = wr.chains.get(chainKey) ?? Promise.resolve();
|
|
8483
|
-
const tail = prev.then(() => this.runDispatch(wr, ev, payload, agent)).catch((
|
|
9503
|
+
const tail = prev.then(() => this.runDispatch(wr, ev, payload, agent)).catch((err2) => {
|
|
8484
9504
|
this.log.warn(
|
|
8485
9505
|
{
|
|
8486
9506
|
workspaceId: wr.workspaceId,
|
|
8487
9507
|
conversationId: payload.conversationId,
|
|
8488
9508
|
agentId: payload.agentId,
|
|
8489
|
-
err:
|
|
9509
|
+
err: err2 instanceof Error ? err2.message : String(err2)
|
|
8490
9510
|
},
|
|
8491
|
-
"
|
|
9511
|
+
"companion: conversation turn handler threw"
|
|
8492
9512
|
);
|
|
8493
9513
|
});
|
|
8494
9514
|
wr.chains.set(chainKey, tail);
|
|
@@ -8516,7 +9536,7 @@ var BridgeSupervisor = class {
|
|
|
8516
9536
|
if (hasCompleted(workspaceId, ev.id)) {
|
|
8517
9537
|
this.log.info(
|
|
8518
9538
|
{ workspaceId, eventId: ev.id },
|
|
8519
|
-
"
|
|
9539
|
+
"companion: skipping already-completed event (resume after restart)"
|
|
8520
9540
|
);
|
|
8521
9541
|
wr.cursor.settle(ev.id);
|
|
8522
9542
|
return;
|
|
@@ -8524,7 +9544,7 @@ var BridgeSupervisor = class {
|
|
|
8524
9544
|
if (noResume()) {
|
|
8525
9545
|
this.log.warn(
|
|
8526
9546
|
{ workspaceId, eventId: ev.id },
|
|
8527
|
-
"
|
|
9547
|
+
"companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
|
|
8528
9548
|
);
|
|
8529
9549
|
markCompleted(workspaceId, ev.id);
|
|
8530
9550
|
wr.cursor.settle(ev.id);
|
|
@@ -8534,7 +9554,7 @@ var BridgeSupervisor = class {
|
|
|
8534
9554
|
if (attempt > MAX_RESUME_ATTEMPTS) {
|
|
8535
9555
|
this.log.error(
|
|
8536
9556
|
{ workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
8537
|
-
"
|
|
9557
|
+
"companion: giving up on an interrupted turn after too many resume attempts \u2014 retiring it so boot is never wedged (run `cabane companion reset` to clear resume state)"
|
|
8538
9558
|
);
|
|
8539
9559
|
markCompleted(workspaceId, ev.id);
|
|
8540
9560
|
wr.cursor.settle(ev.id);
|
|
@@ -8542,7 +9562,7 @@ var BridgeSupervisor = class {
|
|
|
8542
9562
|
}
|
|
8543
9563
|
this.log.info(
|
|
8544
9564
|
{ workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
8545
|
-
"
|
|
9565
|
+
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
8546
9566
|
);
|
|
8547
9567
|
}
|
|
8548
9568
|
if (ev.id) markDispatched(workspaceId, ev.id);
|
|
@@ -8578,11 +9598,11 @@ var BridgeSupervisor = class {
|
|
|
8578
9598
|
} else {
|
|
8579
9599
|
drainDelay = DRAIN_BASE_MS;
|
|
8580
9600
|
}
|
|
8581
|
-
} catch (
|
|
9601
|
+
} catch (err2) {
|
|
8582
9602
|
drainDelay = Math.min(drainDelay * 2, DRAIN_MAX_MS);
|
|
8583
9603
|
this.log.warn(
|
|
8584
|
-
{ agentId, err:
|
|
8585
|
-
"
|
|
9604
|
+
{ agentId, err: err2 instanceof Error ? err2.message : String(err2) },
|
|
9605
|
+
"companion: outbox drain pass threw (will retry with backoff)"
|
|
8586
9606
|
);
|
|
8587
9607
|
} finally {
|
|
8588
9608
|
if (!drainStopped) {
|
|
@@ -8648,10 +9668,10 @@ var BridgeSupervisor = class {
|
|
|
8648
9668
|
codex: signals.codexVersion
|
|
8649
9669
|
};
|
|
8650
9670
|
this.hub.setHarnesses(deriveHarnessSnapshot(signals).harnesses);
|
|
8651
|
-
} catch (
|
|
9671
|
+
} catch (err2) {
|
|
8652
9672
|
this.log.warn(
|
|
8653
|
-
{ err:
|
|
8654
|
-
"
|
|
9673
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
9674
|
+
"companion: harness probe failed (will retry on next beat)"
|
|
8655
9675
|
);
|
|
8656
9676
|
}
|
|
8657
9677
|
}
|
|
@@ -8676,7 +9696,7 @@ var BridgeSupervisor = class {
|
|
|
8676
9696
|
next = { ...this.config, codex: { enabled: true } };
|
|
8677
9697
|
} else {
|
|
8678
9698
|
const serverUrl = input.serverUrl.trim();
|
|
8679
|
-
const parsed =
|
|
9699
|
+
const parsed = companionConfigSchema.shape.opencode.safeParse({ serverUrl });
|
|
8680
9700
|
if (!parsed.success) {
|
|
8681
9701
|
return {
|
|
8682
9702
|
ok: false,
|
|
@@ -8697,7 +9717,7 @@ var BridgeSupervisor = class {
|
|
|
8697
9717
|
saveConfig(next);
|
|
8698
9718
|
this.rebuildDispatchers();
|
|
8699
9719
|
await this.refreshHarnessStatuses();
|
|
8700
|
-
|
|
9720
|
+
this.kickHeartbeat();
|
|
8701
9721
|
return { ok: true };
|
|
8702
9722
|
}
|
|
8703
9723
|
// Re-create every running agent's Dispatcher from the CURRENT config, keeping
|
|
@@ -8733,6 +9753,38 @@ var BridgeSupervisor = class {
|
|
|
8733
9753
|
[...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
|
|
8734
9754
|
);
|
|
8735
9755
|
}
|
|
9756
|
+
async drainForRestart(graceMs) {
|
|
9757
|
+
this.draining = true;
|
|
9758
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
9759
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
9760
|
+
if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
|
|
9761
|
+
if (!this.deviceApi) throw new Error("cannot establish deploy drain before pairing");
|
|
9762
|
+
await this.deviceApi.beginDrain();
|
|
9763
|
+
for (const wr of this.workspaces.values()) wr.sub?.stop();
|
|
9764
|
+
const turns = [...this.workspaces.values()].flatMap((wr) => [...wr.chains.values()]);
|
|
9765
|
+
let timedOut = false;
|
|
9766
|
+
if (turns.length > 0) {
|
|
9767
|
+
let timer;
|
|
9768
|
+
await Promise.race([
|
|
9769
|
+
Promise.allSettled(turns),
|
|
9770
|
+
new Promise((resolve) => {
|
|
9771
|
+
timer = setTimeout(() => {
|
|
9772
|
+
timedOut = true;
|
|
9773
|
+
resolve();
|
|
9774
|
+
}, Math.max(0, graceMs));
|
|
9775
|
+
timer.unref?.();
|
|
9776
|
+
})
|
|
9777
|
+
]);
|
|
9778
|
+
if (timer) clearTimeout(timer);
|
|
9779
|
+
}
|
|
9780
|
+
await Promise.allSettled(
|
|
9781
|
+
[...this.workspaces.values()].flatMap(
|
|
9782
|
+
(wr) => [...wr.agents.values()].map((agent) => agent.api.drainOutbox())
|
|
9783
|
+
)
|
|
9784
|
+
);
|
|
9785
|
+
await this.shutdown();
|
|
9786
|
+
return { drained: !timedOut };
|
|
9787
|
+
}
|
|
8736
9788
|
async requestStop() {
|
|
8737
9789
|
await this.shutdown();
|
|
8738
9790
|
this.exitFn(0);
|
|
@@ -8773,17 +9825,17 @@ var RECOVERABLE_CODES = /* @__PURE__ */ new Set([
|
|
|
8773
9825
|
"ERR_STREAM_DESTROYED",
|
|
8774
9826
|
"ERR_STREAM_WRITE_AFTER_END"
|
|
8775
9827
|
]);
|
|
8776
|
-
function errorCode(
|
|
8777
|
-
if (
|
|
8778
|
-
const code =
|
|
9828
|
+
function errorCode(err2) {
|
|
9829
|
+
if (err2 && typeof err2 === "object" && "code" in err2) {
|
|
9830
|
+
const code = err2.code;
|
|
8779
9831
|
if (typeof code === "string") return code;
|
|
8780
9832
|
}
|
|
8781
9833
|
return void 0;
|
|
8782
9834
|
}
|
|
8783
|
-
function isRecoverableSocketError(
|
|
8784
|
-
const code = errorCode(
|
|
9835
|
+
function isRecoverableSocketError(err2) {
|
|
9836
|
+
const code = errorCode(err2);
|
|
8785
9837
|
if (code && RECOVERABLE_CODES.has(code)) return true;
|
|
8786
|
-
const message =
|
|
9838
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
8787
9839
|
return /\bEPIPE\b|\bECONNRESET\b/.test(message);
|
|
8788
9840
|
}
|
|
8789
9841
|
function installProcessSafetyNet(log, opts = {}) {
|
|
@@ -8792,37 +9844,37 @@ function installProcessSafetyNet(log, opts = {}) {
|
|
|
8792
9844
|
stream.on("error", () => {
|
|
8793
9845
|
});
|
|
8794
9846
|
}
|
|
8795
|
-
proc.on("uncaughtException", (
|
|
9847
|
+
proc.on("uncaughtException", (err2) => handleUncaught(log, err2, "uncaughtException"));
|
|
8796
9848
|
proc.on(
|
|
8797
9849
|
"unhandledRejection",
|
|
8798
9850
|
(reason) => handleUncaught(log, reason, "unhandledRejection")
|
|
8799
9851
|
);
|
|
8800
9852
|
}
|
|
8801
|
-
function handleUncaught(log,
|
|
8802
|
-
const message =
|
|
8803
|
-
const code = errorCode(
|
|
8804
|
-
if (isRecoverableSocketError(
|
|
9853
|
+
function handleUncaught(log, err2, origin) {
|
|
9854
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
9855
|
+
const code = errorCode(err2);
|
|
9856
|
+
if (isRecoverableSocketError(err2)) {
|
|
8805
9857
|
log.warn(
|
|
8806
9858
|
{ origin, code, err: message },
|
|
8807
|
-
"
|
|
9859
|
+
"companion: recovered from a socket error (kept running \u2014 a broken pipe never kills the companion)"
|
|
8808
9860
|
);
|
|
8809
9861
|
return;
|
|
8810
9862
|
}
|
|
8811
9863
|
log.error(
|
|
8812
|
-
{ origin, code, err: message, stack:
|
|
8813
|
-
"
|
|
9864
|
+
{ origin, code, err: message, stack: err2 instanceof Error ? err2.stack : void 0 },
|
|
9865
|
+
"companion: uncaught error (kept running \u2014 see the stack above)"
|
|
8814
9866
|
);
|
|
8815
9867
|
}
|
|
8816
9868
|
|
|
8817
9869
|
// src/crash-marker.ts
|
|
8818
|
-
import { existsSync as existsSync11, mkdirSync as
|
|
8819
|
-
import { join as
|
|
9870
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
|
|
9871
|
+
import { join as join14 } from "path";
|
|
8820
9872
|
function crashMarkerPath() {
|
|
8821
|
-
return
|
|
9873
|
+
return join14(cabaneDir(), "last-error.json");
|
|
8822
9874
|
}
|
|
8823
9875
|
function recordCrash(rec) {
|
|
8824
9876
|
try {
|
|
8825
|
-
|
|
9877
|
+
mkdirSync11(cabaneDir(), { recursive: true });
|
|
8826
9878
|
writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
|
|
8827
9879
|
} catch {
|
|
8828
9880
|
}
|
|
@@ -8836,7 +9888,7 @@ function clearCrash() {
|
|
|
8836
9888
|
}
|
|
8837
9889
|
|
|
8838
9890
|
// src/runtime.ts
|
|
8839
|
-
async function
|
|
9891
|
+
async function createCompanionRuntime(opts = {}) {
|
|
8840
9892
|
const log = getLogger();
|
|
8841
9893
|
installProcessSafetyNet(log);
|
|
8842
9894
|
const probeClaude = opts.probeClaude ?? claudeOnPath;
|
|
@@ -8846,14 +9898,14 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
8846
9898
|
cfg = requireConfig();
|
|
8847
9899
|
claudeCode = await probeClaude();
|
|
8848
9900
|
await ensureRuntimeAvailable(cfg, { probeClaude: async () => claudeCode });
|
|
8849
|
-
} catch (
|
|
9901
|
+
} catch (err2) {
|
|
8850
9902
|
recordCrash({
|
|
8851
|
-
reason:
|
|
8852
|
-
...errorCode(
|
|
9903
|
+
reason: err2 instanceof Error ? err2.message : String(err2),
|
|
9904
|
+
...errorCode(err2) ? { code: errorCode(err2) } : {},
|
|
8853
9905
|
origin: "startup",
|
|
8854
9906
|
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
8855
9907
|
});
|
|
8856
|
-
throw
|
|
9908
|
+
throw err2;
|
|
8857
9909
|
}
|
|
8858
9910
|
if (cfg.logLevel) log.level = cfg.logLevel;
|
|
8859
9911
|
const harnessVersions = await probeHarnessVersions({
|
|
@@ -8870,23 +9922,29 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
8870
9922
|
url: "",
|
|
8871
9923
|
port: 0,
|
|
8872
9924
|
startedAt,
|
|
8873
|
-
daemon: process.env.
|
|
9925
|
+
daemon: process.env.CABANE_COMPANION_DAEMON === "1",
|
|
8874
9926
|
instanceId
|
|
8875
9927
|
});
|
|
8876
9928
|
if (!claim.acquired) {
|
|
8877
9929
|
return { ok: false, reason: "already-running", existing: claim.existing ?? null };
|
|
8878
9930
|
}
|
|
8879
9931
|
process.on("exit", () => clearRuntimeState());
|
|
8880
|
-
const hub = new
|
|
9932
|
+
const hub = new CompanionStateHub({
|
|
8881
9933
|
// CT29: one device, one base URL — the cabane instance this device is paired
|
|
8882
9934
|
// with. The dashboard's connection line shows it.
|
|
8883
9935
|
baseUrl: cfg.baseUrl,
|
|
8884
|
-
|
|
9936
|
+
companionVersion: COMPANION_VERSION,
|
|
8885
9937
|
// SJ516 F4: surfaced on `/api/status` so `stop`/`status` can confirm the
|
|
8886
|
-
// process behind the marker pid is this
|
|
9938
|
+
// process behind the marker pid is this companion (not a recycled pid).
|
|
8887
9939
|
instanceId
|
|
8888
9940
|
});
|
|
8889
|
-
const supervisor = new
|
|
9941
|
+
const supervisor = new CompanionSupervisor({
|
|
9942
|
+
config: cfg,
|
|
9943
|
+
log,
|
|
9944
|
+
hub,
|
|
9945
|
+
claudeCode,
|
|
9946
|
+
harnessVersions
|
|
9947
|
+
});
|
|
8890
9948
|
await supervisor.start();
|
|
8891
9949
|
const preferredPort = opts.port ?? cfg.dashboardPort;
|
|
8892
9950
|
const dashboard = await startDashboard({
|
|
@@ -8901,9 +9959,9 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
8901
9959
|
port: dashboard.port,
|
|
8902
9960
|
startedAt,
|
|
8903
9961
|
// SJ495: the daemon launcher sets this env on the detached child, so the
|
|
8904
|
-
// marker records whether this
|
|
9962
|
+
// marker records whether this companion is backgrounded (foreground start
|
|
8905
9963
|
// leaves it unset → false).
|
|
8906
|
-
daemon: process.env.
|
|
9964
|
+
daemon: process.env.CABANE_COMPANION_DAEMON === "1",
|
|
8907
9965
|
instanceId
|
|
8908
9966
|
});
|
|
8909
9967
|
clearCrash();
|
|
@@ -8920,9 +9978,21 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
8920
9978
|
};
|
|
8921
9979
|
return {
|
|
8922
9980
|
ok: true,
|
|
8923
|
-
runtime: {
|
|
9981
|
+
runtime: {
|
|
9982
|
+
url: dashboard.url,
|
|
9983
|
+
port: dashboard.port,
|
|
9984
|
+
config: cfg,
|
|
9985
|
+
stop,
|
|
9986
|
+
drainForRestart: async (graceMs) => {
|
|
9987
|
+
clearRuntimeState();
|
|
9988
|
+
const result = await supervisor.drainForRestart(graceMs);
|
|
9989
|
+
await dashboard.close();
|
|
9990
|
+
stopped = true;
|
|
9991
|
+
return result;
|
|
9992
|
+
}
|
|
9993
|
+
}
|
|
8924
9994
|
};
|
|
8925
9995
|
}
|
|
8926
9996
|
export {
|
|
8927
|
-
|
|
9997
|
+
createCompanionRuntime
|
|
8928
9998
|
};
|