@cabane/companion 0.5.0 → 0.6.1
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 +17 -9
- package/dist/cli.js +1150 -1226
- package/dist/pairing-config.js +58 -77
- package/dist/runtime.js +1008 -1043
- package/dist/static/app.js +22 -19
- package/dist/static/index.html +6 -7
- package/dist/static/styles.css +1 -1
- package/package.json +3 -4
package/dist/runtime.js
CHANGED
|
@@ -13,16 +13,16 @@ import {
|
|
|
13
13
|
} from "fs";
|
|
14
14
|
import { homedir, userInfo } from "os";
|
|
15
15
|
import { dirname, join } from "path";
|
|
16
|
-
import { z as
|
|
16
|
+
import { z as z2 } 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";
|
|
@@ -46,9 +46,6 @@ var PrepareHookError = class extends BridgeError {
|
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
// src/pairing.ts
|
|
49
|
-
import { z } from "zod";
|
|
50
|
-
var PAIRING_VERSION = 1;
|
|
51
|
-
var DEVICE_TOKEN_PREFIX = "cabdev_";
|
|
52
49
|
function isAllowedBaseUrl(raw) {
|
|
53
50
|
let url;
|
|
54
51
|
try {
|
|
@@ -63,40 +60,24 @@ function isAllowedBaseUrl(raw) {
|
|
|
63
60
|
}
|
|
64
61
|
return false;
|
|
65
62
|
}
|
|
66
|
-
var pairingSchema = z.object({
|
|
67
|
-
// Bumped if the wire shape changes incompatibly. We only accept v1.
|
|
68
|
-
v: z.literal(PAIRING_VERSION),
|
|
69
|
-
baseUrl: z.string().url().refine(isAllowedBaseUrl, {
|
|
70
|
-
message: "baseUrl must be https (loopback http is allowed for local dev only)"
|
|
71
|
-
}),
|
|
72
|
-
// The `cabdev_` device token plaintext — the bridge's one durable credential.
|
|
73
|
-
deviceToken: z.string().min(1).startsWith(DEVICE_TOKEN_PREFIX, {
|
|
74
|
-
message: 'deviceToken must be a cabane device token (starts with "cabdev_")'
|
|
75
|
-
}),
|
|
76
|
-
// Optional identity hints the app may include for nicer local display. The
|
|
77
|
-
// bridge also learns these from the first assignments pull, so they're not
|
|
78
|
-
// required.
|
|
79
|
-
deviceId: z.string().min(1).optional(),
|
|
80
|
-
deviceLabel: z.string().min(1).optional()
|
|
81
|
-
});
|
|
82
63
|
|
|
83
64
|
// src/prepare-hook.ts
|
|
84
65
|
import { spawn } from "child_process";
|
|
85
|
-
import { z
|
|
86
|
-
var prepareHookSchema =
|
|
87
|
-
command:
|
|
88
|
-
args:
|
|
66
|
+
import { z } from "zod";
|
|
67
|
+
var prepareHookSchema = z.object({
|
|
68
|
+
command: z.string().min(1),
|
|
69
|
+
args: z.array(z.string()).optional(),
|
|
89
70
|
// Extra env handed to the hook process itself (merged over process.env).
|
|
90
|
-
env:
|
|
71
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
91
72
|
// Wall-clock cap for the hook. Provisioning is slow (minutes), so the
|
|
92
73
|
// 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
|
|
94
|
-
timeoutMs:
|
|
74
|
+
// fails with a clear timeout message rather than pinning the companion.
|
|
75
|
+
timeoutMs: z.number().int().positive().optional()
|
|
95
76
|
}).strict();
|
|
96
77
|
var DEFAULT_TIMEOUT_MS = 10 * 6e4;
|
|
97
|
-
var prepareResultSchema =
|
|
98
|
-
cwd:
|
|
99
|
-
env:
|
|
78
|
+
var prepareResultSchema = z.object({
|
|
79
|
+
cwd: z.string().min(1),
|
|
80
|
+
env: z.record(z.string(), z.string()).optional()
|
|
100
81
|
});
|
|
101
82
|
function parsePrepareOutput(stdout) {
|
|
102
83
|
const last = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).at(-1);
|
|
@@ -115,10 +96,13 @@ function parsePrepareOutput(stdout) {
|
|
|
115
96
|
const r = prepareResultSchema.safeParse(parsed);
|
|
116
97
|
if (!r.success) {
|
|
117
98
|
throw new PrepareHookError(
|
|
118
|
-
'prepare hook JSON must carry a non-empty string "cwd" (
|
|
99
|
+
'prepare hook JSON must carry a non-empty string "cwd" (plus optional "env")'
|
|
119
100
|
);
|
|
120
101
|
}
|
|
121
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
cwd: r.data.cwd,
|
|
104
|
+
...r.data.env ? { env: r.data.env } : {}
|
|
105
|
+
};
|
|
122
106
|
}
|
|
123
107
|
return { cwd: last };
|
|
124
108
|
}
|
|
@@ -143,6 +127,7 @@ var runPrepareHook = (hook, input) => {
|
|
|
143
127
|
CABANE_CONVERSATION_ID: input.conversationId,
|
|
144
128
|
CABANE_AGENT_ID: input.agentId,
|
|
145
129
|
CABANE_AGENT_USERNAME: input.agentUsername,
|
|
130
|
+
CABANE_RUNTIME: input.runtime ?? "",
|
|
146
131
|
// CT319: the conversation anchor is gone. These are kept as DEPRECATED
|
|
147
132
|
// back-compat constants so an old user prepare script that still reads
|
|
148
133
|
// them doesn't crash; a hook should key off CABANE_TRIGGER_ENTRY_PATHS.
|
|
@@ -217,12 +202,12 @@ function realAccountHome() {
|
|
|
217
202
|
return realHomeCache;
|
|
218
203
|
}
|
|
219
204
|
function cabaneDir() {
|
|
220
|
-
const dir2 = join(process.env.
|
|
205
|
+
const dir2 = join(process.env.CABANE_COMPANION_HOME || homedir(), ".cabane");
|
|
221
206
|
if (process.env.VITEST) {
|
|
222
207
|
const real = realAccountHome();
|
|
223
208
|
if (real && dir2 === join(real, ".cabane")) {
|
|
224
209
|
throw new Error(
|
|
225
|
-
`cabaneDir() resolved to the real ${dir2} during a test run.
|
|
210
|
+
`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
211
|
);
|
|
227
212
|
}
|
|
228
213
|
}
|
|
@@ -231,55 +216,55 @@ function cabaneDir() {
|
|
|
231
216
|
function configPath() {
|
|
232
217
|
return join(cabaneDir(), "config.json");
|
|
233
218
|
}
|
|
234
|
-
var localAgentConfigSchema =
|
|
235
|
-
cwd:
|
|
219
|
+
var localAgentConfigSchema = z2.object({
|
|
220
|
+
cwd: z2.string().optional(),
|
|
236
221
|
prepareHook: prepareHookSchema.optional(),
|
|
237
222
|
// CT289: the Claude Code auto-memory escape hatch. Auto-memory is forced OFF
|
|
238
|
-
// by default on every
|
|
239
|
-
//
|
|
240
|
-
// On a
|
|
223
|
+
// by default on every companion (memory belongs in the Cabane workspace, and a
|
|
224
|
+
// shared companion would otherwise pool one cwd-keyed memory dir across users).
|
|
225
|
+
// On a companion you run yourself, set `claudeCode: { autoMemory: true }` to hand
|
|
241
226
|
// auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
|
|
242
227
|
// injecting the off switch and your normal Claude Code memory workflow applies
|
|
243
228
|
// (in coding mode, where the checkout's project settings are read).
|
|
244
|
-
claudeCode:
|
|
229
|
+
claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
|
|
245
230
|
}).strict();
|
|
246
|
-
var
|
|
231
|
+
var companionConfigSchema = z2.object({
|
|
247
232
|
// The cabane instance this device is paired with. SJ515: https-enforced
|
|
248
233
|
// (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
|
|
249
234
|
// base URL onto the MITM-able channel the device token + prompt ride.
|
|
250
|
-
baseUrl:
|
|
235
|
+
baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
|
|
251
236
|
message: "baseUrl must be https (loopback http is allowed for local dev only)"
|
|
252
237
|
}),
|
|
253
|
-
// The `cabdev_` device token plaintext — the
|
|
238
|
+
// The `cabdev_` device token plaintext — the companion's one durable credential,
|
|
254
239
|
// presented as `Authorization: Bearer <token>` on the device-facing pull +
|
|
255
240
|
// heartbeat endpoints. Optional so `cabane-companion logout` can strip it (a
|
|
256
241
|
// "paired but logged out" state the supervisor refuses to run) while keeping
|
|
257
242
|
// the rest of the config; `pair` always writes one.
|
|
258
|
-
deviceToken:
|
|
259
|
-
// Identity hints, learned from the
|
|
243
|
+
deviceToken: z2.string().optional(),
|
|
244
|
+
// Identity hints, learned from the device flow and refreshed on the first
|
|
260
245
|
// assignments pull. Cosmetic — used for `status`/dashboard display only.
|
|
261
|
-
deviceId:
|
|
262
|
-
deviceLabel:
|
|
246
|
+
deviceId: z2.string().optional(),
|
|
247
|
+
deviceLabel: z2.string().optional(),
|
|
263
248
|
// Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
|
|
264
|
-
// agentId / username / `slug/username`. Hand-added by the operator; the
|
|
249
|
+
// agentId / username / `slug/username`. Hand-added by the operator; the companion
|
|
265
250
|
// never writes this (it only persists credentials + the device, elsewhere).
|
|
266
|
-
agents:
|
|
251
|
+
agents: z2.record(z2.string(), localAgentConfigSchema).optional(),
|
|
267
252
|
// Dashboard settings (all optional). dashboardPort: preferred bind port (next
|
|
268
253
|
// free one if taken); autoOpen: whether `start` opens the browser (the
|
|
269
|
-
// `--no-open` flag / `
|
|
254
|
+
// `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
|
|
270
255
|
// level, live-editable from the dashboard settings panel.
|
|
271
|
-
dashboardPort:
|
|
272
|
-
autoOpen:
|
|
273
|
-
logLevel:
|
|
256
|
+
dashboardPort: z2.number().int().min(1).max(65535).optional(),
|
|
257
|
+
autoOpen: z2.boolean().optional(),
|
|
258
|
+
logLevel: z2.enum(["warn", "info", "debug"]).optional(),
|
|
274
259
|
// CT270: the opencode runtime, when the operator runs one on this machine. The
|
|
275
260
|
// operator installs opencode, starts `opencode serve` (auth via opencode's own
|
|
276
|
-
// `/connect` — Cabane never sees provider keys), and points the
|
|
261
|
+
// `/connect` — Cabane never sees provider keys), and points the companion at it
|
|
277
262
|
// here. Setting this makes the device advertise the `opencode` runtime on its
|
|
278
263
|
// heartbeat manifest (so the server offers DeepSeek/opencode models here and
|
|
279
264
|
// routes those turns to this device) AND registers the opencode adapter in the
|
|
280
265
|
// dispatcher. Absent → the device is claude-code-only, exactly as before.
|
|
281
|
-
opencode:
|
|
282
|
-
serverUrl:
|
|
266
|
+
opencode: z2.object({
|
|
267
|
+
serverUrl: z2.string().url()
|
|
283
268
|
}).strict().optional(),
|
|
284
269
|
// CT481: the codex runtime, when the operator runs Codex on this machine. Unlike
|
|
285
270
|
// opencode (a long-lived `opencode serve` addressed by URL), Codex is a local CLI
|
|
@@ -290,20 +275,13 @@ var bridgeConfigSchema = z3.object({
|
|
|
290
275
|
// keeps the block but turns it off). Enabling makes the device advertise the
|
|
291
276
|
// `codex` runtime on its heartbeat manifest AND registers the codex adapter in
|
|
292
277
|
// the dispatcher. Absent → the device doesn't offer codex, exactly as before.
|
|
293
|
-
codex:
|
|
294
|
-
enabled:
|
|
278
|
+
codex: z2.object({
|
|
279
|
+
enabled: z2.boolean().optional()
|
|
295
280
|
}).strict().optional()
|
|
296
281
|
});
|
|
297
282
|
function isCodexEnabled(cfg) {
|
|
298
283
|
return !!cfg.codex && cfg.codex.enabled !== false;
|
|
299
284
|
}
|
|
300
|
-
function cabaneNativeApiKey() {
|
|
301
|
-
const key = process.env.OPENROUTER_API_KEY?.trim();
|
|
302
|
-
return key ? key : void 0;
|
|
303
|
-
}
|
|
304
|
-
function isCabaneNativeEnabled() {
|
|
305
|
-
return cabaneNativeApiKey() !== void 0;
|
|
306
|
-
}
|
|
307
285
|
function localAgentConfig(cfg, agent) {
|
|
308
286
|
const map = cfg.agents ?? {};
|
|
309
287
|
return map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
|
|
@@ -328,7 +306,7 @@ function loadConfig() {
|
|
|
328
306
|
`${path3} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
329
307
|
);
|
|
330
308
|
}
|
|
331
|
-
const result =
|
|
309
|
+
const result = companionConfigSchema.safeParse(parsed);
|
|
332
310
|
if (!result.success) {
|
|
333
311
|
const agentIssue = result.error.issues.find((i) => i.path[0] === "agents");
|
|
334
312
|
if (agentIssue) {
|
|
@@ -337,7 +315,7 @@ function loadConfig() {
|
|
|
337
315
|
);
|
|
338
316
|
}
|
|
339
317
|
throw new ConfigError(
|
|
340
|
-
`${path3} is from an incompatible or older version of the
|
|
318
|
+
`${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
319
|
);
|
|
342
320
|
}
|
|
343
321
|
return result.data;
|
|
@@ -369,7 +347,7 @@ function requireConfig() {
|
|
|
369
347
|
const cfg = loadConfig();
|
|
370
348
|
if (!cfg) {
|
|
371
349
|
throw new ConfigError(
|
|
372
|
-
"this
|
|
350
|
+
"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
351
|
);
|
|
374
352
|
}
|
|
375
353
|
return cfg;
|
|
@@ -392,8 +370,8 @@ import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
|
|
|
392
370
|
import { dirname as dirname2, join as join2 } from "path";
|
|
393
371
|
import pino from "pino";
|
|
394
372
|
import pretty from "pino-pretty";
|
|
395
|
-
function
|
|
396
|
-
return join2(cabaneDir(), "
|
|
373
|
+
function companionLogPath() {
|
|
374
|
+
return join2(cabaneDir(), "companion.log");
|
|
397
375
|
}
|
|
398
376
|
var CONSOLE_IGNORE = [
|
|
399
377
|
"pid",
|
|
@@ -403,7 +381,7 @@ var CONSOLE_IGNORE = [
|
|
|
403
381
|
"agentId",
|
|
404
382
|
"messageId",
|
|
405
383
|
"sessionId",
|
|
406
|
-
"
|
|
384
|
+
"companionId"
|
|
407
385
|
].join(",");
|
|
408
386
|
function consoleShortId(log) {
|
|
409
387
|
const id = log.conversationId ?? log.workspaceId;
|
|
@@ -417,10 +395,10 @@ function consoleMessageFormat(log, messageKey) {
|
|
|
417
395
|
var cached = null;
|
|
418
396
|
function getLogger() {
|
|
419
397
|
if (cached) return cached;
|
|
420
|
-
const path3 =
|
|
398
|
+
const path3 = companionLogPath();
|
|
421
399
|
mkdirSync2(dirname2(path3), { recursive: true });
|
|
422
400
|
const streams = [];
|
|
423
|
-
if (process.env.
|
|
401
|
+
if (process.env.CABANE_COMPANION_DAEMON !== "1") {
|
|
424
402
|
const consoleStream = pretty({
|
|
425
403
|
colorize: true,
|
|
426
404
|
ignore: CONSOLE_IGNORE,
|
|
@@ -452,7 +430,7 @@ var MAX_DISPATCHES = 50;
|
|
|
452
430
|
function today() {
|
|
453
431
|
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
454
432
|
}
|
|
455
|
-
var
|
|
433
|
+
var CompanionStateHub = class {
|
|
456
434
|
constructor(opts) {
|
|
457
435
|
this.opts = opts;
|
|
458
436
|
this.emitter.setMaxListeners(0);
|
|
@@ -466,7 +444,7 @@ var BridgeStateHub = class {
|
|
|
466
444
|
deviceLabel = null;
|
|
467
445
|
deviceError = null;
|
|
468
446
|
// CT586: the latest harness snapshot the supervisor probed, or null before the
|
|
469
|
-
// first probe (see
|
|
447
|
+
// first probe (see CompanionStatusJson.harnesses).
|
|
470
448
|
harnesses = null;
|
|
471
449
|
// ---- subscription (SSE) ----
|
|
472
450
|
on(listener) {
|
|
@@ -658,7 +636,7 @@ var BridgeStateHub = class {
|
|
|
658
636
|
})),
|
|
659
637
|
last_event_at_overall: lastOverall,
|
|
660
638
|
dashboard_url: this.dashboardUrl,
|
|
661
|
-
|
|
639
|
+
companion_version: this.opts.companionVersion,
|
|
662
640
|
instance_id: this.opts.instanceId ?? null,
|
|
663
641
|
harnesses: this.harnesses
|
|
664
642
|
};
|
|
@@ -699,7 +677,7 @@ function registerRoutes(app, deps) {
|
|
|
699
677
|
});
|
|
700
678
|
app.get("/api/logs", async (c) => {
|
|
701
679
|
const lines = clampLimit(c.req.query("lines"), 200, 1e3);
|
|
702
|
-
return c.json({ lines: tailFile(
|
|
680
|
+
return c.json({ lines: tailFile(companionLogPath(), lines) });
|
|
703
681
|
});
|
|
704
682
|
app.post("/api/settings", async (c) => {
|
|
705
683
|
const body = await readJson(c);
|
|
@@ -840,7 +818,7 @@ function buildDashboardApp(deps) {
|
|
|
840
818
|
const status = err.status >= 400 && err.status < 600 ? err.status : 502;
|
|
841
819
|
return c.json({ error: err.message }, status);
|
|
842
820
|
}
|
|
843
|
-
if (err instanceof
|
|
821
|
+
if (err instanceof CompanionError) {
|
|
844
822
|
return c.json({ error: err.message }, 400);
|
|
845
823
|
}
|
|
846
824
|
return c.json({ error: err instanceof Error ? err.message : "internal error" }, 500);
|
|
@@ -872,7 +850,7 @@ async function startDashboard(opts) {
|
|
|
872
850
|
throw err;
|
|
873
851
|
}
|
|
874
852
|
}
|
|
875
|
-
throw new
|
|
853
|
+
throw new CompanionError(
|
|
876
854
|
`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
855
|
);
|
|
878
856
|
}
|
|
@@ -1018,8 +996,8 @@ async function ensureRuntimeAvailable(cfg, deps = {}) {
|
|
|
1018
996
|
);
|
|
1019
997
|
return;
|
|
1020
998
|
}
|
|
1021
|
-
throw new
|
|
1022
|
-
"Claude Code, the
|
|
999
|
+
throw new CompanionError(
|
|
1000
|
+
"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
1001
|
);
|
|
1024
1002
|
}
|
|
1025
1003
|
|
|
@@ -1208,7 +1186,7 @@ var CabaneApi = class {
|
|
|
1208
1186
|
outbox.persist({ enqueuedAt: Date.now(), turnId, seq, method: "POST", path: path3, body, kind });
|
|
1209
1187
|
this.opts.log?.warn(
|
|
1210
1188
|
{ kind, turnId, seq, err: err instanceof Error ? err.message : String(err) },
|
|
1211
|
-
"
|
|
1189
|
+
"companion: commit queued to outbox after transient failure (will drain when the API returns)"
|
|
1212
1190
|
);
|
|
1213
1191
|
}
|
|
1214
1192
|
}
|
|
@@ -1235,7 +1213,7 @@ var CabaneApi = class {
|
|
|
1235
1213
|
if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
|
|
1236
1214
|
this.opts.log?.warn(
|
|
1237
1215
|
{ kind: entry.kind, turnId: entry.turnId, seq: entry.seq, status: err.status },
|
|
1238
|
-
"
|
|
1216
|
+
"companion outbox: discarding entry on terminal 4xx (will never land)"
|
|
1239
1217
|
);
|
|
1240
1218
|
outbox.remove(entry.turnId, entry.seq);
|
|
1241
1219
|
progressed = true;
|
|
@@ -1262,7 +1240,7 @@ var CabaneApi = class {
|
|
|
1262
1240
|
// the machinery the `sub_agent` turn-control tool is sugar over. Two things make
|
|
1263
1241
|
// it distinct from an ordinary `request` call, so it does its own `fetch`:
|
|
1264
1242
|
// - a PER-CALL bearer — the turn's OBO token when the API minted one, else the
|
|
1265
|
-
//
|
|
1243
|
+
// companion PAT — so the spawn carries the same authority as the agent's other
|
|
1266
1244
|
// cabane calls this turn (mirrors build-options' `turnToken ?? agentPat`);
|
|
1267
1245
|
// - the `x-cabane-active-conversation` header naming the caller's turn, which
|
|
1268
1246
|
// the server verifies against the live run to resolve the caller pair for the
|
|
@@ -1295,15 +1273,15 @@ var CabaneApi = class {
|
|
|
1295
1273
|
}
|
|
1296
1274
|
return { status: res.status, body: parsed };
|
|
1297
1275
|
}
|
|
1298
|
-
// SJ383:
|
|
1276
|
+
// SJ383: companion-only participant ops. All three authenticate with the
|
|
1299
1277
|
// workspace's agent-bound PAT (passed as `token` on this client) — never
|
|
1300
1278
|
// the user PAT.
|
|
1301
|
-
// Recovery-path read: returns the
|
|
1279
|
+
// Recovery-path read: returns the companion's `agentSessionId` for this
|
|
1302
1280
|
// conversation so the dispatcher can pass it as `Options.resume`, plus
|
|
1303
|
-
// the current `activeRunStartedAt` so a freshly-reconnected
|
|
1281
|
+
// the current `activeRunStartedAt` so a freshly-reconnected companion can
|
|
1304
1282
|
// see whether a prior run is still flagged in-flight.
|
|
1305
1283
|
// SJ383: recovery-path read for a participant row — the `agentSessionId` a
|
|
1306
|
-
// freshly-reconnected
|
|
1284
|
+
// freshly-reconnected companion resumes on. CT262: the per-turn piggybacks this
|
|
1307
1285
|
// fetch grew (agentRules / visionBlocks / channel / members / conversationContext)
|
|
1308
1286
|
// are gone — `getTurnContext` composes the whole turn now — so this is back to
|
|
1309
1287
|
// the plain recovery read, with no `messageId` param.
|
|
@@ -1313,17 +1291,37 @@ var CabaneApi = class {
|
|
|
1313
1291
|
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}`
|
|
1314
1292
|
);
|
|
1315
1293
|
}
|
|
1316
|
-
// CT262: the ONE turn-context fetch. Collapses the
|
|
1294
|
+
// CT262: the ONE turn-context fetch. Collapses the companion's old four-fetch
|
|
1317
1295
|
// choreography (getConversation + getMessage + getAgentSelf + getParticipantAgent)
|
|
1318
1296
|
// into a single call: the server composes the whole server portion of the
|
|
1319
1297
|
// `TurnRequest` — systemPrompt, per-turn prompt + vision content, effective
|
|
1320
1298
|
// run-config, `HostPolicy`, prior session, the user MCP definitions to resolve
|
|
1321
1299
|
// locally, plus the anchor/title + trigger-message summary the host needs.
|
|
1322
1300
|
// Agent-PAT authed; the workspace is implied by the PAT.
|
|
1323
|
-
|
|
1324
|
-
|
|
1301
|
+
// CT714: `turnId` is the host-minted id for THIS turn, passed so the server can
|
|
1302
|
+
// bind the minted turn token to it — the turn-control surface then rejects a
|
|
1303
|
+
// token whose turn has ended. The dispatcher mints it before this call and
|
|
1304
|
+
// reuses the same value on its active-run PATCH, so the token's turn id and the
|
|
1305
|
+
// pair's `active_turn_id` agree.
|
|
1306
|
+
getTurnContext(conversationId, messageId2, turnId) {
|
|
1307
|
+
const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
|
|
1325
1308
|
return this.request("GET", `/api/agent/turn-context?${q}`);
|
|
1326
1309
|
}
|
|
1310
|
+
// CT714: read a turn's recorded turn-control intent (ask/wake/summon/skip). An
|
|
1311
|
+
// EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
|
|
1312
|
+
// `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
|
|
1313
|
+
// in-memory closures, so the dispatcher fetches this once at settle — by
|
|
1314
|
+
// `turnId` — and populates those closures, letting the unchanged settle path
|
|
1315
|
+
// materialize the effects identically to claude-code. Agent-PAT authed +
|
|
1316
|
+
// self-scoped (`:agentId` must match the PAT's agent). A turn that recorded no
|
|
1317
|
+
// control verb returns all-empty fields.
|
|
1318
|
+
getTurnIntent(workspaceId, conversationId, agentId, turnId) {
|
|
1319
|
+
const q = `turnId=${encodeURIComponent(turnId)}`;
|
|
1320
|
+
return this.request(
|
|
1321
|
+
"GET",
|
|
1322
|
+
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-intent?${q}`
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1327
1325
|
// Flips `active_run_started_at` and optionally captures the SDK session
|
|
1328
1326
|
// id. The dispatcher hits this twice per turn (now() before the SDK
|
|
1329
1327
|
// loop; null when it settles) plus once with the session id on the
|
|
@@ -1336,7 +1334,7 @@ var CabaneApi = class {
|
|
|
1336
1334
|
// `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
|
|
1337
1335
|
// left best-effort: it's lower-stakes and self-heals on the next turn, so it
|
|
1338
1336
|
// stays a single-shot PATCH and is deliberately out of CT93's scope.
|
|
1339
|
-
|
|
1337
|
+
setActiveRun(workspaceId, conversationId, agentId, body) {
|
|
1340
1338
|
const path3 = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
|
|
1341
1339
|
const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
|
|
1342
1340
|
if (touchesFlag && this.opts.outbox) {
|
|
@@ -1379,16 +1377,16 @@ var CabaneApi = class {
|
|
|
1379
1377
|
});
|
|
1380
1378
|
this.opts.log?.warn(
|
|
1381
1379
|
{ conversationId, agentId, err: err instanceof Error ? err.message : String(err) },
|
|
1382
|
-
"
|
|
1380
|
+
"companion: active-run write queued to outbox after transient failure (will drain when the API returns)"
|
|
1383
1381
|
);
|
|
1384
1382
|
}
|
|
1385
1383
|
}
|
|
1386
1384
|
// CT29: per-device liveness moved off the per-workspace agent PAT and onto the
|
|
1387
1385
|
// device token — see `DeviceApi.heartbeat`. There is no agent-PAT heartbeat
|
|
1388
1386
|
// anymore.
|
|
1389
|
-
// SJ477: commit one row of the
|
|
1387
|
+
// SJ477: commit one row of the companion's turn (a `progress` interim note or
|
|
1390
1388
|
// the `final` reply), derived from its own SDK transcript. Posts to the same
|
|
1391
|
-
// public messages endpoint a user hits — the
|
|
1389
|
+
// public messages endpoint a user hits — the companion holds an agent-bound
|
|
1392
1390
|
// PAT, so the server attributes the row to this agent (role `agent`) and
|
|
1393
1391
|
// won't re-dispatch (the route gates re-dispatch on role `user`). `turnId`
|
|
1394
1392
|
// groups every row of one turn so the chat drawer renders them as a single
|
|
@@ -1403,12 +1401,12 @@ var CabaneApi = class {
|
|
|
1403
1401
|
// CT11: `kind` now includes `'stopped'` for the terminal marker the
|
|
1404
1402
|
// dispatcher writes when a turn is cancelled — same wire shape as
|
|
1405
1403
|
// `progress`/`final`, distinguished only by `kind` so the chat drawer's
|
|
1406
|
-
// turn-group renderer treats it as a closing row. `seq` is the
|
|
1404
|
+
// turn-group renderer treats it as a closing row. `seq` is the companion's
|
|
1407
1405
|
// per-turn monotonic counter, stamped on the row so the merged timeline
|
|
1408
1406
|
// orders the commit deterministically against the persisted tool/thinking
|
|
1409
|
-
// rows. Both fields are optional on the wire — an older
|
|
1407
|
+
// rows. Both fields are optional on the wire — an older companion that didn't
|
|
1410
1408
|
// mint seq still validates (the server defaults to 0); `stopped` is only
|
|
1411
|
-
// emitted by post-CT11
|
|
1409
|
+
// emitted by post-CT11 companions.
|
|
1412
1410
|
postTurnMessage(workspaceId, conversationId, body, signal) {
|
|
1413
1411
|
return this.durableCommit(
|
|
1414
1412
|
"message",
|
|
@@ -1449,11 +1447,11 @@ var CabaneApi = class {
|
|
|
1449
1447
|
);
|
|
1450
1448
|
}
|
|
1451
1449
|
// SJ493: fetch the agent's self-view — identity + operating context. The
|
|
1452
|
-
//
|
|
1450
|
+
// companion calls this on each dispatch to get its `systemPrompt` (composed
|
|
1453
1451
|
// server-side from the bundled default + the agent's charter) rather than
|
|
1454
1452
|
// baking a copy of the prompt into the download. Cabane is the control plane
|
|
1455
1453
|
// for the prompt, so changing it (or the per-agent charter) takes effect
|
|
1456
|
-
// without shipping a new
|
|
1454
|
+
// without shipping a new companion. Agent-PAT authed; the workspace is implied
|
|
1457
1455
|
// by the PAT, so no workspace arg.
|
|
1458
1456
|
// CT245: pass the triggering turn's `conversationId` so the server returns the
|
|
1459
1457
|
// run-config RESOLVED for this conversation (agent default + that
|
|
@@ -1464,10 +1462,10 @@ var CabaneApi = class {
|
|
|
1464
1462
|
const path3 = conversationId ? `/api/agent/me?conversationId=${encodeURIComponent(conversationId)}` : `/api/agent/me`;
|
|
1465
1463
|
return this.request("GET", path3);
|
|
1466
1464
|
}
|
|
1467
|
-
// The
|
|
1465
|
+
// The companion fetches the triggering message body by listing the
|
|
1468
1466
|
// conversation's messages and finding the one with `id === messageId`.
|
|
1469
1467
|
// Cabane has no single-message GET endpoint; for v0 this is fine because
|
|
1470
|
-
// the
|
|
1468
|
+
// the companion only reaches for the specific row immediately after the
|
|
1471
1469
|
// event fires (the thread is small at that point).
|
|
1472
1470
|
async getMessage(workspaceId, conversationId, messageId2) {
|
|
1473
1471
|
const res = await this.request(
|
|
@@ -1546,7 +1544,10 @@ var DeviceApi = class {
|
|
|
1546
1544
|
getAssignments() {
|
|
1547
1545
|
return this.request("GET", "/api/companion/assignments");
|
|
1548
1546
|
}
|
|
1549
|
-
|
|
1547
|
+
beginDrain() {
|
|
1548
|
+
return this.request("POST", "/api/companion/drain", {});
|
|
1549
|
+
}
|
|
1550
|
+
// Per-device liveness ping. Reports the companion build version and the env-var
|
|
1550
1551
|
// names the operator's secret store exposes (never values), so CT30's UI can
|
|
1551
1552
|
// warn pre-emptively about an agent that needs a secret this device lacks.
|
|
1552
1553
|
heartbeat(body) {
|
|
@@ -1573,11 +1574,11 @@ import {
|
|
|
1573
1574
|
writeFileSync as writeFileSync3
|
|
1574
1575
|
} from "fs";
|
|
1575
1576
|
import { dirname as dirname4, join as join6 } from "path";
|
|
1576
|
-
import { z as
|
|
1577
|
+
import { z as z3 } from "zod";
|
|
1577
1578
|
function credentialsPath() {
|
|
1578
1579
|
return join6(cabaneDir(), "credentials.json");
|
|
1579
1580
|
}
|
|
1580
|
-
var credentialStoreSchema =
|
|
1581
|
+
var credentialStoreSchema = z3.record(z3.string(), z3.string());
|
|
1581
1582
|
function load() {
|
|
1582
1583
|
const path3 = credentialsPath();
|
|
1583
1584
|
if (!existsSync4(path3)) return {};
|
|
@@ -1781,54 +1782,55 @@ function bumpResumeAttempt(workspaceId, eventId) {
|
|
|
1781
1782
|
return next;
|
|
1782
1783
|
}
|
|
1783
1784
|
function noResume() {
|
|
1784
|
-
return process.env.
|
|
1785
|
+
return process.env.CABANE_COMPANION_NO_RESUME === "1";
|
|
1785
1786
|
}
|
|
1786
1787
|
|
|
1787
1788
|
// packages/agent-runtime/src/version.ts
|
|
1788
1789
|
var TURN_PROTOCOL_VERSION = 1;
|
|
1789
1790
|
|
|
1790
1791
|
// packages/agent-runtime/src/host-policy.ts
|
|
1791
|
-
import { z as
|
|
1792
|
-
var hostPolicySchema =
|
|
1792
|
+
import { z as z4 } from "zod";
|
|
1793
|
+
var hostPolicySchema = z4.object({
|
|
1793
1794
|
// Host filesystem + shell: `Bash`/`Read`/`Write`/`Edit`/`Glob`/`Grep`, the
|
|
1794
1795
|
// notebook read/write pair, git-worktree tools, and the `REPL` host code-exec
|
|
1795
1796
|
// tool. Off under the locked assistant surface (today's `DISALLOWED_TOOLS`);
|
|
1796
1797
|
// on under `coding` mode.
|
|
1797
|
-
hostFs:
|
|
1798
|
+
hostFs: z4.boolean(),
|
|
1798
1799
|
// Public web (today's `WebSearch` / `WebFetch`, the `DEFAULT_WEB_TOOLS`). Public
|
|
1799
1800
|
// web, not host reach — granted by default today, but expressible as a grant.
|
|
1800
|
-
web:
|
|
1801
|
-
// Browser automation (the Playwright MCP surface). Varies by host: a
|
|
1801
|
+
web: z4.boolean(),
|
|
1802
|
+
// Browser automation (the Playwright MCP surface). Varies by host: a companion has
|
|
1802
1803
|
// it, the house executor does not (CT230).
|
|
1803
|
-
browser:
|
|
1804
|
+
browser: z4.boolean(),
|
|
1804
1805
|
// User-configured MCP servers permitted. False for the house executor
|
|
1805
|
-
// (CT227: Cabane agents run no user MCP servers), true for a personal
|
|
1806
|
-
userMcp:
|
|
1806
|
+
// (CT227: Cabane agents run no user MCP servers), true for a personal companion.
|
|
1807
|
+
userMcp: z4.boolean(),
|
|
1807
1808
|
// Subagent / sub-dispatch (`Task` / `Agent`). A per-host GRANT (see the CT261
|
|
1808
1809
|
// amendment above): `false` on the locked assistant/house surface (banned via
|
|
1809
1810
|
// `DISALLOWED_TOOLS`), `true` under coding / custom where the fan-out is left to
|
|
1810
|
-
//
|
|
1811
|
+
// the coding allow-hook (which reaches sub-agent tool calls, CT680) or the user
|
|
1812
|
+
// allowlist. The subagent completes within the turn, so
|
|
1811
1813
|
// it's not the turn-model invariant `scheduling` is.
|
|
1812
|
-
subagents:
|
|
1814
|
+
subagents: z4.boolean(),
|
|
1813
1815
|
// ── Hard platform invariants — always denied, never granted ────────────────
|
|
1814
1816
|
// Deferred re-invocation / scheduling (`ScheduleWakeup`, the `Cron*` / `Task*`
|
|
1815
1817
|
// families, `Monitor`, `Workflow`, …). A turn is one query that resolves when
|
|
1816
1818
|
// `result` fires; a scheduled callback fires after the reply window has closed
|
|
1817
1819
|
// and strands the agent (the CT155/CT156 rule).
|
|
1818
|
-
scheduling:
|
|
1820
|
+
scheduling: z4.literal("never"),
|
|
1819
1821
|
// Human-facing UI prompts (`AskUserQuestion`). A Cabane conversation has no
|
|
1820
1822
|
// handler to answer a structured prompt, so the call hangs the turn
|
|
1821
1823
|
// (`UNSUPPORTED_TOOLS`). The agent asks in its reply instead.
|
|
1822
|
-
uiPrompts:
|
|
1824
|
+
uiPrompts: z4.literal("never")
|
|
1823
1825
|
});
|
|
1824
1826
|
|
|
1825
1827
|
// packages/agent-runtime/src/turn-event.ts
|
|
1826
|
-
import { z as
|
|
1827
|
-
var turnEventSchema =
|
|
1828
|
+
import { z as z5 } from "zod";
|
|
1829
|
+
var turnEventSchema = z5.discriminatedUnion("type", [
|
|
1828
1830
|
// The runtime's opaque session state, emitted when the adapter learns it (e.g.
|
|
1829
1831
|
// the SDK `system/init` frame). The platform stores `state` verbatim per
|
|
1830
1832
|
// (conversation, agent) and hands it back on the next turn; only the adapter
|
|
1831
|
-
// knows what it means. Today's
|
|
1833
|
+
// knows what it means. Today's companion captures the raw SDK session id here; a
|
|
1832
1834
|
// future adapter may encode more (e.g. `{sdkSessionId, cwd}`) — still one
|
|
1833
1835
|
// opaque string to the platform.
|
|
1834
1836
|
//
|
|
@@ -1841,22 +1843,22 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1841
1843
|
// so the mark now over-reaches an empty session. The host relays `degraded`
|
|
1842
1844
|
// on settle and the server rewinds the mark, so the NEXT turn rebuilds a full
|
|
1843
1845
|
// catch-up (this failing turn is unavoidably lossy — the degrade is only known
|
|
1844
|
-
// on the
|
|
1846
|
+
// on the companion, after the server committed the manifest). Runtime-neutral: a
|
|
1845
1847
|
// plain boolean, not a runtime-specific reason string (that stays in the
|
|
1846
1848
|
// adapter's `onWarn` log). Additive + optional — an old receiver ignores it.
|
|
1847
|
-
|
|
1848
|
-
type:
|
|
1849
|
-
state:
|
|
1850
|
-
degraded:
|
|
1849
|
+
z5.object({
|
|
1850
|
+
type: z5.literal("session"),
|
|
1851
|
+
state: z5.string(),
|
|
1852
|
+
degraded: z5.boolean().optional()
|
|
1851
1853
|
}),
|
|
1852
1854
|
// One readable thinking summary. Maps `onThinking({ text })`. Transient —
|
|
1853
1855
|
// surfaced live, never persisted as durable content.
|
|
1854
|
-
|
|
1856
|
+
z5.object({ type: z5.literal("thinking"), text: z5.string() }),
|
|
1855
1857
|
// Assistant text. Maps `onAssistantText({ text, final })` — `text`→`body`,
|
|
1856
1858
|
// `final`→`terminal`. `terminal: false` is interim narration (commits as a
|
|
1857
1859
|
// `progress` row); `terminal: true` is the turn's closing reply (commits as
|
|
1858
1860
|
// the `final` row).
|
|
1859
|
-
|
|
1861
|
+
z5.object({ type: z5.literal("text"), body: z5.string(), terminal: z5.boolean() }),
|
|
1860
1862
|
// A tool-activity transition. Maps `onToolActivity` — `toolUseId`→`id`,
|
|
1861
1863
|
// `toolName`→`name` (already prefix-stripped: `cabane_read`, not
|
|
1862
1864
|
// `mcp__cabane__cabane_read`), `summary` is the short card label. `phase`
|
|
@@ -1871,15 +1873,15 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1871
1873
|
// dropped the prefix; null for a host / built-in tool. The client tags Cabane
|
|
1872
1874
|
// MCP calls (`=== 'cabane'`) with a subtle glyph. Nullable + optional so a
|
|
1873
1875
|
// pre-CT496 producer that never sets it is unaffected (treated as null).
|
|
1874
|
-
|
|
1875
|
-
type:
|
|
1876
|
-
id:
|
|
1877
|
-
name:
|
|
1878
|
-
phase:
|
|
1879
|
-
summary:
|
|
1880
|
-
input:
|
|
1881
|
-
result:
|
|
1882
|
-
mcpServer:
|
|
1876
|
+
z5.object({
|
|
1877
|
+
type: z5.literal("tool"),
|
|
1878
|
+
id: z5.string(),
|
|
1879
|
+
name: z5.string(),
|
|
1880
|
+
phase: z5.enum(["start", "done", "error"]),
|
|
1881
|
+
summary: z5.string(),
|
|
1882
|
+
input: z5.unknown().optional(),
|
|
1883
|
+
result: z5.unknown().optional(),
|
|
1884
|
+
mcpServer: z5.string().nullable().optional()
|
|
1883
1885
|
}),
|
|
1884
1886
|
// The turn's terminal outcome. Maps the `{ ok, reason }` both loops return
|
|
1885
1887
|
// inline. `ok:false` carries a machine reason (`no_session`, an error code);
|
|
@@ -1889,7 +1891,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1889
1891
|
// dropped on the floor before. `inputTokens` is the full context the model saw
|
|
1890
1892
|
// (uncached + cache-read + cache-creation input), so it doubles as the
|
|
1891
1893
|
// context-window cost; `outputTokens` the generated tokens. Optional ⇒
|
|
1892
|
-
// backward-compatible: an old
|
|
1894
|
+
// backward-compatible: an old companion / adapter that never sets it, and a
|
|
1893
1895
|
// receiver that never reads it, are unaffected (the turn's token columns stay
|
|
1894
1896
|
// null → the UI shows `—`).
|
|
1895
1897
|
//
|
|
@@ -1899,6 +1901,19 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1899
1901
|
// the server just leaves the cache columns null. Captured now because honest
|
|
1900
1902
|
// costing later prices a cache-read token far below a fresh input token.
|
|
1901
1903
|
//
|
|
1904
|
+
// CT699: `inputTokens` (and the cache slice) is a BILLING quantity — for
|
|
1905
|
+
// claude-code/codex it's the runtime's CUMULATIVE total summed across every model
|
|
1906
|
+
// request in the agentic turn, so it grows with the tool-call count and is NOT
|
|
1907
|
+
// "how full is the window." `contextTokens` is the distinct CONTEXT-OCCUPANCY
|
|
1908
|
+
// read: the input the model saw on its FINAL request of the turn (uncached +
|
|
1909
|
+
// cache, since cached tokens still occupy the window) — the number the composer
|
|
1910
|
+
// gauge wants. `contextWindow` is the model's true window in tokens when the
|
|
1911
|
+
// runtime reports it (claude-code's SDK does, per model) — a real denominator so
|
|
1912
|
+
// the gauge can show a fraction. Both optional: a runtime that can't source a
|
|
1913
|
+
// clean final-request figure (codex's cumulative-only usage) omits `contextTokens`
|
|
1914
|
+
// and the gauge falls back to the raw count; `contextWindow` falls back to the
|
|
1915
|
+
// model catalog.
|
|
1916
|
+
//
|
|
1902
1917
|
// CT601: two more optional carry-homes on the terminal result, alongside
|
|
1903
1918
|
// `usage`. `resolvedModel` is the CONCRETE model the runtime actually ran —
|
|
1904
1919
|
// claude-code learns it from the `system/init` frame mid-run (even for a
|
|
@@ -1908,34 +1923,36 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
1908
1923
|
// for claude-code today). Both only known after the run streams — so they ride
|
|
1909
1924
|
// the terminal event home, the host relays them on settle, and the server writes
|
|
1910
1925
|
// `message_turn_metadata.resolved_model` / `resolved_config`. Optional ⇒
|
|
1911
|
-
// backward-compatible: an old adapter/
|
|
1926
|
+
// backward-compatible: an old adapter/companion omits them, a cancel has no result
|
|
1912
1927
|
// event at all, and the columns stay null → the UI shows `—`.
|
|
1913
|
-
|
|
1914
|
-
type:
|
|
1915
|
-
ok:
|
|
1916
|
-
reason:
|
|
1917
|
-
usage:
|
|
1918
|
-
inputTokens:
|
|
1919
|
-
outputTokens:
|
|
1920
|
-
cacheReadTokens:
|
|
1921
|
-
cacheCreationTokens:
|
|
1928
|
+
z5.object({
|
|
1929
|
+
type: z5.literal("result"),
|
|
1930
|
+
ok: z5.boolean(),
|
|
1931
|
+
reason: z5.string().optional(),
|
|
1932
|
+
usage: z5.object({
|
|
1933
|
+
inputTokens: z5.number(),
|
|
1934
|
+
outputTokens: z5.number(),
|
|
1935
|
+
cacheReadTokens: z5.number().optional(),
|
|
1936
|
+
cacheCreationTokens: z5.number().optional(),
|
|
1937
|
+
contextTokens: z5.number().optional(),
|
|
1938
|
+
contextWindow: z5.number().optional()
|
|
1922
1939
|
}).optional(),
|
|
1923
|
-
resolvedModel:
|
|
1924
|
-
resolvedConfig:
|
|
1925
|
-
effort:
|
|
1926
|
-
thinking:
|
|
1927
|
-
reasoningEffort:
|
|
1940
|
+
resolvedModel: z5.string().optional(),
|
|
1941
|
+
resolvedConfig: z5.object({
|
|
1942
|
+
effort: z5.string().optional(),
|
|
1943
|
+
thinking: z5.string().optional(),
|
|
1944
|
+
reasoningEffort: z5.string().optional()
|
|
1928
1945
|
}).optional()
|
|
1929
1946
|
})
|
|
1930
1947
|
]);
|
|
1931
1948
|
|
|
1932
1949
|
// packages/agent-runtime/src/failure.ts
|
|
1933
|
-
import { z as
|
|
1934
|
-
var turnFailureSchema =
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1950
|
+
import { z as z6 } from "zod";
|
|
1951
|
+
var turnFailureSchema = z6.discriminatedUnion("kind", [
|
|
1952
|
+
z6.object({ kind: z6.literal("usage_capped"), resetsAt: z6.string().optional() }),
|
|
1953
|
+
z6.object({ kind: z6.literal("rate_limited") }),
|
|
1954
|
+
z6.object({ kind: z6.literal("server_error") }),
|
|
1955
|
+
z6.object({ kind: z6.literal("auth_expired") })
|
|
1939
1956
|
]);
|
|
1940
1957
|
var USAGE_CAPPED = "usage_capped";
|
|
1941
1958
|
var RATE_LIMITED = "rate_limited";
|
|
@@ -2006,7 +2023,8 @@ function classifyErrorText(text) {
|
|
|
2006
2023
|
const t = text.toLowerCase();
|
|
2007
2024
|
if (AUTH_PATTERNS.some((re) => re.test(t))) return { kind: "auth_expired" };
|
|
2008
2025
|
if (SERVER_PATTERNS.some((re) => re.test(t))) return { kind: "server_error" };
|
|
2009
|
-
const
|
|
2026
|
+
const withoutNegatedCap = t.replace(NEGATED_CAP, "");
|
|
2027
|
+
const capNoun = CAP_PATTERNS.some((re) => re.test(withoutNegatedCap));
|
|
2010
2028
|
const rateToken = RATE_PATTERNS.some((re) => re.test(t));
|
|
2011
2029
|
if (capNoun) return { kind: "usage_capped" };
|
|
2012
2030
|
if (rateToken) return { kind: "rate_limited" };
|
|
@@ -2035,105 +2053,120 @@ var SERVER_PATTERNS = [
|
|
|
2035
2053
|
/fetch failed/
|
|
2036
2054
|
];
|
|
2037
2055
|
var CAP_PATTERNS = [/usage limit/, /weekly limit/, /session limit/, /\bquota\b/];
|
|
2056
|
+
var NEGATED_CAP = /not (your|a) usage limit/g;
|
|
2038
2057
|
var RATE_PATTERNS = [/rate[_ ]?limit/, /\b429\b/, /too many requests/];
|
|
2039
2058
|
var BARE_LIMIT = /\blimit (reached|exceeded)\b/;
|
|
2040
2059
|
|
|
2041
2060
|
// packages/agent-runtime/src/turn-request.ts
|
|
2042
|
-
import { z as
|
|
2043
|
-
var contentBlockSchema =
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
type:
|
|
2047
|
-
source:
|
|
2061
|
+
import { z as z7 } from "zod";
|
|
2062
|
+
var contentBlockSchema = z7.discriminatedUnion("type", [
|
|
2063
|
+
z7.object({ type: z7.literal("text"), text: z7.string() }),
|
|
2064
|
+
z7.object({
|
|
2065
|
+
type: z7.literal("image"),
|
|
2066
|
+
source: z7.object({ type: z7.literal("url"), url: z7.string() })
|
|
2048
2067
|
}),
|
|
2049
|
-
|
|
2050
|
-
type:
|
|
2051
|
-
source:
|
|
2068
|
+
z7.object({
|
|
2069
|
+
type: z7.literal("document"),
|
|
2070
|
+
source: z7.object({ type: z7.literal("url"), url: z7.string() })
|
|
2052
2071
|
})
|
|
2053
2072
|
]);
|
|
2054
|
-
var effortLevelSchema =
|
|
2055
|
-
var resolvedRunConfigSchema =
|
|
2056
|
-
model:
|
|
2073
|
+
var effortLevelSchema = z7.enum(["low", "medium", "high", "xhigh", "max"]);
|
|
2074
|
+
var resolvedRunConfigSchema = z7.object({
|
|
2075
|
+
model: z7.string().nullable(),
|
|
2057
2076
|
effort: effortLevelSchema.optional(),
|
|
2058
|
-
runtimeOptions:
|
|
2077
|
+
runtimeOptions: z7.record(z7.string(), z7.unknown()).optional()
|
|
2059
2078
|
});
|
|
2060
|
-
var resolvedMcpServerSchema =
|
|
2061
|
-
|
|
2062
|
-
type:
|
|
2063
|
-
command:
|
|
2064
|
-
args:
|
|
2065
|
-
env:
|
|
2079
|
+
var resolvedMcpServerSchema = z7.union([
|
|
2080
|
+
z7.object({
|
|
2081
|
+
type: z7.literal("stdio").optional(),
|
|
2082
|
+
command: z7.string(),
|
|
2083
|
+
args: z7.array(z7.string()).optional(),
|
|
2084
|
+
env: z7.record(z7.string(), z7.string()).optional()
|
|
2066
2085
|
}),
|
|
2067
|
-
|
|
2068
|
-
type:
|
|
2069
|
-
url:
|
|
2070
|
-
headers:
|
|
2086
|
+
z7.object({
|
|
2087
|
+
type: z7.literal("http"),
|
|
2088
|
+
url: z7.string(),
|
|
2089
|
+
headers: z7.record(z7.string(), z7.string()).optional()
|
|
2071
2090
|
}),
|
|
2072
|
-
|
|
2073
|
-
type:
|
|
2074
|
-
url:
|
|
2075
|
-
headers:
|
|
2091
|
+
z7.object({
|
|
2092
|
+
type: z7.literal("sse"),
|
|
2093
|
+
url: z7.string(),
|
|
2094
|
+
headers: z7.record(z7.string(), z7.string()).optional()
|
|
2076
2095
|
})
|
|
2077
2096
|
]);
|
|
2078
|
-
var resolvedMcpServersSchema =
|
|
2079
|
-
var hostInjectedServersSchema =
|
|
2080
|
-
var turnRequestSchema =
|
|
2097
|
+
var resolvedMcpServersSchema = z7.record(z7.string(), resolvedMcpServerSchema);
|
|
2098
|
+
var hostInjectedServersSchema = z7.record(z7.string(), z7.unknown());
|
|
2099
|
+
var turnRequestSchema = z7.object({
|
|
2081
2100
|
// Server-composed system prompt (core + capability prose + adapter addendum +
|
|
2082
2101
|
// charter). One string to the adapter.
|
|
2083
|
-
systemPrompt:
|
|
2102
|
+
systemPrompt: z7.string(),
|
|
2084
2103
|
// Server-composed per-turn user text (anchor reminder + the triggering message).
|
|
2085
|
-
prompt:
|
|
2104
|
+
prompt: z7.string(),
|
|
2086
2105
|
// The multi-block user-message body (text + vision).
|
|
2087
|
-
content:
|
|
2106
|
+
content: z7.array(contentBlockSchema),
|
|
2088
2107
|
// Portable-or-dialect run-config (above).
|
|
2089
2108
|
config: resolvedRunConfigSchema,
|
|
2090
2109
|
// Abstract capability grants; the adapter maps them to tool names.
|
|
2091
2110
|
policy: hostPolicySchema,
|
|
2092
2111
|
// Prior opaque session state, or null for a fresh session.
|
|
2093
|
-
session:
|
|
2112
|
+
session: z7.string().nullable(),
|
|
2094
2113
|
// The cabane control-plane coordinates for this turn's MCP + post-back.
|
|
2095
|
-
cabane:
|
|
2096
|
-
mcpUrl:
|
|
2097
|
-
bearer:
|
|
2098
|
-
activeConversationId:
|
|
2114
|
+
cabane: z7.object({
|
|
2115
|
+
mcpUrl: z7.string(),
|
|
2116
|
+
bearer: z7.string(),
|
|
2117
|
+
activeConversationId: z7.string(),
|
|
2118
|
+
// CT714: the scoped TURN-CONTROL MCP endpoint (`/api/turn-control`). The
|
|
2119
|
+
// EXTERNAL adapters (Codex / opencode) mount it by URL under the key
|
|
2120
|
+
// `cabane_companion` — using the same `bearer` (the turn token) and the same
|
|
2121
|
+
// active-conversation header they send to the `cabane` server — so their
|
|
2122
|
+
// agents get `ask`/`wake_me`/`summon_agent`/`sub_agent`/`skip_turn`, the
|
|
2123
|
+
// verbs they can't get from the companion's in-process SDK server. Optional:
|
|
2124
|
+
// claude-code ignores it (it mounts the in-process instance instead), and
|
|
2125
|
+
// every existing `cabane`-block fixture keeps parsing unchanged; the
|
|
2126
|
+
// companion always populates it (`build-options.ts`).
|
|
2127
|
+
turnControlUrl: z7.string().optional(),
|
|
2099
2128
|
// CT598: the workspace this turn runs in. The claude-code/opencode/codex
|
|
2100
2129
|
// adapters never need it (they reach Cabane through the `cabane` MCP server,
|
|
2101
2130
|
// which takes `workspaceId` as a per-tool arg the model supplies); the
|
|
2102
2131
|
// native runtime's interim tool surface calls the workspace-scoped REST API
|
|
2103
2132
|
// DIRECTLY, so it needs the id host-side rather than trusting the model to
|
|
2104
2133
|
// pass it. Optional so every existing `cabane`-block constructor (the three
|
|
2105
|
-
// adapters' conformance fixtures, tests) keeps parsing unchanged — the
|
|
2134
|
+
// adapters' conformance fixtures, tests) keeps parsing unchanged — the companion
|
|
2106
2135
|
// always populates it (`build-options.ts`), and the native adapter fails the
|
|
2107
2136
|
// turn loudly when it is somehow absent rather than guessing.
|
|
2108
|
-
workspaceId:
|
|
2137
|
+
workspaceId: z7.string().optional(),
|
|
2138
|
+
// CT752: the server-resolved workspace surface this credential exposes.
|
|
2139
|
+
// Readiness uses this explicit fact to require `sdk` for code mode and the
|
|
2140
|
+
// granular floor for classic mode; inventory contents alone cannot infer it
|
|
2141
|
+
// because `sdk` is intentionally also available on the classic surface.
|
|
2142
|
+
workspaceToolSurface: z7.enum(["code", "classic"]).optional()
|
|
2109
2143
|
}),
|
|
2110
2144
|
// Machine-local resolution (host-filled): the checkout cwd, extra env from a
|
|
2111
2145
|
// prepare hook, and the resolved user MCP servers.
|
|
2112
|
-
local:
|
|
2113
|
-
cwd:
|
|
2114
|
-
env:
|
|
2146
|
+
local: z7.object({
|
|
2147
|
+
cwd: z7.string().optional(),
|
|
2148
|
+
env: z7.record(z7.string(), z7.string()).optional(),
|
|
2115
2149
|
mcpServers: resolvedMcpServersSchema.optional(),
|
|
2116
2150
|
// CT289: machine-local claude-code adapter knobs the operator sets on a
|
|
2117
|
-
//
|
|
2151
|
+
// companion they run themselves — the auto-memory escape hatch. `autoMemory:
|
|
2118
2152
|
// true` opts back into Claude Code's auto-memory (governed by the operator's
|
|
2119
2153
|
// own `.claude/settings.json`); absent/false leaves the adapter's force-off
|
|
2120
|
-
// default in place (see `buildClaudeCodeOptions`).
|
|
2121
|
-
|
|
2122
|
-
claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
|
|
2154
|
+
// default in place (see `buildClaudeCodeOptions`).
|
|
2155
|
+
claudeCode: z7.object({ autoMemory: z7.boolean().optional() }).optional()
|
|
2123
2156
|
}),
|
|
2124
2157
|
// Host-owned injected servers (host-filled) — e.g. the summon server.
|
|
2125
|
-
extra:
|
|
2158
|
+
extra: z7.object({
|
|
2126
2159
|
mcpServers: hostInjectedServersSchema
|
|
2127
2160
|
})
|
|
2128
2161
|
});
|
|
2129
2162
|
|
|
2130
2163
|
// packages/agent-runtime/src/conformance.ts
|
|
2131
|
-
import { z as
|
|
2132
|
-
var conformanceFixtureSchema =
|
|
2133
|
-
name:
|
|
2164
|
+
import { z as z8 } from "zod";
|
|
2165
|
+
var conformanceFixtureSchema = z8.object({
|
|
2166
|
+
name: z8.string(),
|
|
2134
2167
|
request: turnRequestSchema,
|
|
2135
|
-
nativeStream:
|
|
2136
|
-
expected:
|
|
2168
|
+
nativeStream: z8.array(z8.unknown()),
|
|
2169
|
+
expected: z8.array(turnEventSchema)
|
|
2137
2170
|
});
|
|
2138
2171
|
|
|
2139
2172
|
// packages/agent-runtime/src/transcript.ts
|
|
@@ -2438,7 +2471,7 @@ import {
|
|
|
2438
2471
|
var CLAUDE_CODE_ADDENDUM = "";
|
|
2439
2472
|
|
|
2440
2473
|
// packages/agent-runtime/src/claude-code/policy.ts
|
|
2441
|
-
import { z as
|
|
2474
|
+
import { z as z9 } from "zod";
|
|
2442
2475
|
var HOST_FS_TOOLS = [
|
|
2443
2476
|
// shell + local filesystem
|
|
2444
2477
|
"Bash",
|
|
@@ -2488,30 +2521,20 @@ function withThinkingSummaries(thinking) {
|
|
|
2488
2521
|
if (thinking.type === "disabled") return thinking;
|
|
2489
2522
|
return { display: "summarized", ...thinking };
|
|
2490
2523
|
}
|
|
2491
|
-
var claudeCodeDialectSchema =
|
|
2492
|
-
thinking:
|
|
2493
|
-
|
|
2494
|
-
type:
|
|
2495
|
-
display:
|
|
2524
|
+
var claudeCodeDialectSchema = z9.object({
|
|
2525
|
+
thinking: z9.discriminatedUnion("type", [
|
|
2526
|
+
z9.object({
|
|
2527
|
+
type: z9.literal("adaptive"),
|
|
2528
|
+
display: z9.enum(["summarized", "omitted"]).optional()
|
|
2496
2529
|
}),
|
|
2497
|
-
|
|
2498
|
-
type:
|
|
2499
|
-
budgetTokens:
|
|
2500
|
-
display:
|
|
2530
|
+
z9.object({
|
|
2531
|
+
type: z9.literal("enabled"),
|
|
2532
|
+
budgetTokens: z9.number().int().positive().optional(),
|
|
2533
|
+
display: z9.enum(["summarized", "omitted"]).optional()
|
|
2501
2534
|
}),
|
|
2502
|
-
|
|
2535
|
+
z9.object({ type: z9.literal("disabled") })
|
|
2503
2536
|
]).optional(),
|
|
2504
|
-
|
|
2505
|
-
disallowedTools: z10.array(z10.string()).optional(),
|
|
2506
|
-
// Which claude-code harness shape to run. `coding` switches to the
|
|
2507
|
-
// `claude_code` preset + project settings + always-allow `canUseTool`;
|
|
2508
|
-
// `custom`/`assistant` (or absent) use a plain-string prompt + bypass. This
|
|
2509
|
-
// is the claude-code-specific PRESET selector — kept distinct from
|
|
2510
|
-
// `policy.hostFs` (the host-fs BLOCK), because bridge `custom` mode wants host
|
|
2511
|
-
// fs available (via its own allowlist) WITHOUT the coding harness, and in-app
|
|
2512
|
-
// `custom` wants host fs blocked — neither of which a single `hostFs` boolean
|
|
2513
|
-
// can express alongside the preset choice.
|
|
2514
|
-
mode: z10.enum(["assistant", "coding", "custom"]).optional()
|
|
2537
|
+
hostAccess: z9.boolean().optional()
|
|
2515
2538
|
}).loose();
|
|
2516
2539
|
function readThinking(runtimeOptions) {
|
|
2517
2540
|
const dialect = runtimeOptions?.["claude-code"];
|
|
@@ -2555,10 +2578,18 @@ function decideResume(stored, currentCwd) {
|
|
|
2555
2578
|
// packages/agent-runtime/src/claude-code/options.ts
|
|
2556
2579
|
var CABANE_MCP_SERVER = "cabane";
|
|
2557
2580
|
var ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2581
|
+
async function allowEverythingHook(input) {
|
|
2582
|
+
const toolInput = "tool_input" in input && input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
|
|
2583
|
+
return {
|
|
2584
|
+
continue: true,
|
|
2585
|
+
hookSpecificOutput: {
|
|
2586
|
+
hookEventName: "PreToolUse",
|
|
2587
|
+
permissionDecision: "allow",
|
|
2588
|
+
permissionDecisionReason: "coding mode: headless never-prompt (CT680)",
|
|
2589
|
+
updatedInput: toolInput
|
|
2590
|
+
}
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2562
2593
|
function buildClaudeCodeOptions(req, augment) {
|
|
2563
2594
|
const { policy, config } = req;
|
|
2564
2595
|
const cwd = req.local.cwd;
|
|
@@ -2582,18 +2613,15 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
2582
2613
|
};
|
|
2583
2614
|
}
|
|
2584
2615
|
const dialect = claudeCodeDialectSchema.safeParse(config.runtimeOptions?.["claude-code"] ?? {});
|
|
2585
|
-
const
|
|
2586
|
-
const customDisallowed = dialect.success ? dialect.data.disallowedTools ?? [] : [];
|
|
2587
|
-
const useCodingPreset = (dialect.success ? dialect.data.mode : void 0) === "coding";
|
|
2616
|
+
const useCodingPreset = dialect.success ? dialect.data.hostAccess ?? false : false;
|
|
2588
2617
|
const cabaneGlob = `mcp__${CABANE_MCP_SERVER}__*`;
|
|
2589
2618
|
const extraServerGlobs = Object.keys(req.extra.mcpServers).map((name) => `mcp__${name}__*`);
|
|
2590
2619
|
const allowedTools = dedupe([
|
|
2591
2620
|
cabaneGlob,
|
|
2592
2621
|
...extraServerGlobs,
|
|
2593
|
-
...policy.web ? DEFAULT_WEB_TOOLS : []
|
|
2594
|
-
...customAllowed
|
|
2622
|
+
...policy.web ? DEFAULT_WEB_TOOLS : []
|
|
2595
2623
|
]);
|
|
2596
|
-
const disallowedTools = dedupe([...disallowedToolsFor(policy)
|
|
2624
|
+
const disallowedTools = dedupe([...disallowedToolsFor(policy)]);
|
|
2597
2625
|
const resumeDecision = decideResume(req.session, cwd);
|
|
2598
2626
|
const resume = "resume" in resumeDecision ? resumeDecision.resume : null;
|
|
2599
2627
|
const freshReason = "fresh" in resumeDecision ? resumeDecision.reason : void 0;
|
|
@@ -2619,7 +2647,7 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
2619
2647
|
...devControlsAutoMemory ? {} : { settings: { autoMemoryEnabled: false } },
|
|
2620
2648
|
mcpServers,
|
|
2621
2649
|
...cwd ? { cwd } : {},
|
|
2622
|
-
// Extra env (a
|
|
2650
|
+
// Extra env (a companion prepare hook's tokens/ports; the in-app's debug flags)
|
|
2623
2651
|
// merged OVER the inherited environment.
|
|
2624
2652
|
...req.local.env ? { env: { ...process.env, ...req.local.env } } : {},
|
|
2625
2653
|
...resume ? { resume } : {}
|
|
@@ -2632,7 +2660,7 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
2632
2660
|
settingSources: ["project"],
|
|
2633
2661
|
allowedTools,
|
|
2634
2662
|
disallowedTools,
|
|
2635
|
-
|
|
2663
|
+
hooks: { PreToolUse: [{ hooks: [allowEverythingHook] }] }
|
|
2636
2664
|
};
|
|
2637
2665
|
} else {
|
|
2638
2666
|
options = {
|
|
@@ -2663,9 +2691,11 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
2663
2691
|
let resultReason;
|
|
2664
2692
|
let sawResult = false;
|
|
2665
2693
|
let usage;
|
|
2694
|
+
let lastRequestContextTokens;
|
|
2666
2695
|
let resolvedModel;
|
|
2667
2696
|
let sawRejectedLimit = false;
|
|
2668
2697
|
let rateLimitResetIso;
|
|
2698
|
+
let rateLimitType;
|
|
2669
2699
|
let authError;
|
|
2670
2700
|
let lastAssistantError;
|
|
2671
2701
|
try {
|
|
@@ -2688,6 +2718,8 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
2688
2718
|
if (typeof assistantErr === "string" && assistantErr.length > 0) {
|
|
2689
2719
|
lastAssistantError = assistantErr;
|
|
2690
2720
|
}
|
|
2721
|
+
const reqContext = readRequestContextTokens(msg);
|
|
2722
|
+
if (reqContext !== void 0) lastRequestContextTokens = reqContext;
|
|
2691
2723
|
await processAssistantMessage(msg, emit, pending, buffer);
|
|
2692
2724
|
yield* drain(out);
|
|
2693
2725
|
} else if (msg.type === "user") {
|
|
@@ -2698,6 +2730,7 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
2698
2730
|
if (info?.status === "rejected") {
|
|
2699
2731
|
sawRejectedLimit = true;
|
|
2700
2732
|
rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? rateLimitResetIso;
|
|
2733
|
+
if (typeof info.rateLimitType === "string") rateLimitType = info.rateLimitType;
|
|
2701
2734
|
}
|
|
2702
2735
|
} else if (msg.type === "auth_status") {
|
|
2703
2736
|
const err = msg.error;
|
|
@@ -2705,6 +2738,12 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
2705
2738
|
} else if (msg.type === "result") {
|
|
2706
2739
|
sawResult = true;
|
|
2707
2740
|
usage = readSdkUsage(msg);
|
|
2741
|
+
if (usage) {
|
|
2742
|
+
if (lastRequestContextTokens !== void 0)
|
|
2743
|
+
usage.contextTokens = lastRequestContextTokens;
|
|
2744
|
+
const window = readContextWindow(msg, resolvedModel);
|
|
2745
|
+
if (window !== void 0) usage.contextWindow = window;
|
|
2746
|
+
}
|
|
2708
2747
|
const isError = msg.is_error === true;
|
|
2709
2748
|
if (msg.subtype === "success" && !isError) {
|
|
2710
2749
|
ok = true;
|
|
@@ -2715,7 +2754,8 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
2715
2754
|
const errorText = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(
|
|
2716
2755
|
" "
|
|
2717
2756
|
);
|
|
2718
|
-
const
|
|
2757
|
+
const rejectedIsCap = sawRejectedLimit && (rateLimitResetIso !== void 0 || isSubscriptionWindow(rateLimitType));
|
|
2758
|
+
const failure = rejectedIsCap || terminalReason === "blocking_limit" ? {
|
|
2719
2759
|
kind: "usage_capped",
|
|
2720
2760
|
...rateLimitResetIso ? { resetsAt: rateLimitResetIso } : {}
|
|
2721
2761
|
} : classifyAssistantError(lastAssistantError) ?? classifyErrorText([authError, errorText].filter(Boolean).join(" "));
|
|
@@ -2745,6 +2785,9 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
2745
2785
|
...resolvedModel ? { resolvedModel } : {}
|
|
2746
2786
|
};
|
|
2747
2787
|
}
|
|
2788
|
+
function isSubscriptionWindow(value) {
|
|
2789
|
+
return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
|
|
2790
|
+
}
|
|
2748
2791
|
function* drain(out) {
|
|
2749
2792
|
while (out.length > 0) yield out.shift();
|
|
2750
2793
|
}
|
|
@@ -2758,6 +2801,27 @@ function readSdkUsage(msg) {
|
|
|
2758
2801
|
const outputTokens = num(usage.output_tokens);
|
|
2759
2802
|
return { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens };
|
|
2760
2803
|
}
|
|
2804
|
+
function readRequestContextTokens(msg) {
|
|
2805
|
+
const usage = msg.message?.usage;
|
|
2806
|
+
if (!usage || typeof usage !== "object") return void 0;
|
|
2807
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
2808
|
+
return num(usage.input_tokens) + num(usage.cache_read_input_tokens) + num(usage.cache_creation_input_tokens);
|
|
2809
|
+
}
|
|
2810
|
+
function readContextWindow(msg, resolvedModel) {
|
|
2811
|
+
const modelUsage = msg.modelUsage;
|
|
2812
|
+
if (!modelUsage || typeof modelUsage !== "object") return void 0;
|
|
2813
|
+
const pos = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
2814
|
+
if (resolvedModel) {
|
|
2815
|
+
const direct = pos(modelUsage[resolvedModel]?.contextWindow);
|
|
2816
|
+
if (direct !== void 0) return direct;
|
|
2817
|
+
}
|
|
2818
|
+
let max;
|
|
2819
|
+
for (const entry of Object.values(modelUsage)) {
|
|
2820
|
+
const w = pos(entry?.contextWindow);
|
|
2821
|
+
if (w !== void 0 && (max === void 0 || w > max)) max = w;
|
|
2822
|
+
}
|
|
2823
|
+
return max;
|
|
2824
|
+
}
|
|
2761
2825
|
|
|
2762
2826
|
// packages/agent-runtime/src/claude-code/prompt-input.ts
|
|
2763
2827
|
function buildQueryPrompt(req) {
|
|
@@ -2875,9 +2939,13 @@ var resultErrorFull = (subtype, extra = {}) => ({
|
|
|
2875
2939
|
session_id: "s",
|
|
2876
2940
|
...extra
|
|
2877
2941
|
});
|
|
2878
|
-
var rateLimitEvent = (status, resetsAt) => ({
|
|
2942
|
+
var rateLimitEvent = (status, resetsAt, rateLimitType) => ({
|
|
2879
2943
|
type: "rate_limit_event",
|
|
2880
|
-
rate_limit_info: {
|
|
2944
|
+
rate_limit_info: {
|
|
2945
|
+
status,
|
|
2946
|
+
...resetsAt !== void 0 ? { resetsAt } : {},
|
|
2947
|
+
...rateLimitType !== void 0 ? { rateLimitType } : {}
|
|
2948
|
+
},
|
|
2881
2949
|
session_id: "s"
|
|
2882
2950
|
});
|
|
2883
2951
|
var authStatus = (error) => ({
|
|
@@ -3041,17 +3109,16 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
|
|
|
3041
3109
|
]
|
|
3042
3110
|
},
|
|
3043
3111
|
{
|
|
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.
|
|
3112
|
+
// CT558/CT592/CT731: a subscription cap. The SDK emits a rejected
|
|
3113
|
+
// `rate_limit_event` with a named subscription window; the terminal error result
|
|
3114
|
+
// then classifies as the structured `usage_capped` reason. Partial narration
|
|
3115
|
+
// lands as `progress`. The cap event is distinct from a provider 429 throttle.
|
|
3049
3116
|
name: "subscription cap \u2192 usage_capped",
|
|
3050
3117
|
request: makeRequest(),
|
|
3051
3118
|
nativeStream: [
|
|
3052
3119
|
init("s1"),
|
|
3053
3120
|
assistantText("Let me work on that."),
|
|
3054
|
-
rateLimitEvent("rejected"),
|
|
3121
|
+
rateLimitEvent("rejected", void 0, "five_hour"),
|
|
3055
3122
|
resultError("error_during_execution")
|
|
3056
3123
|
],
|
|
3057
3124
|
expected: [
|
|
@@ -3414,7 +3481,7 @@ function sealHeld(held, terminal) {
|
|
|
3414
3481
|
}
|
|
3415
3482
|
|
|
3416
3483
|
// packages/agent-runtime/src/opencode/policy.ts
|
|
3417
|
-
import { z as
|
|
3484
|
+
import { z as z10 } from "zod";
|
|
3418
3485
|
var OPENCODE_HOST_TOOLS = [
|
|
3419
3486
|
"bash",
|
|
3420
3487
|
"edit",
|
|
@@ -3441,8 +3508,8 @@ function opencodeToolPolicy(policy) {
|
|
|
3441
3508
|
deny(OPENCODE_UI_PROMPT_TOOLS);
|
|
3442
3509
|
return { tools, allowAllHostTools: policy.hostFs };
|
|
3443
3510
|
}
|
|
3444
|
-
var opencodeDialectSchema =
|
|
3445
|
-
agent:
|
|
3511
|
+
var opencodeDialectSchema = z10.object({
|
|
3512
|
+
agent: z10.string().min(1).optional()
|
|
3446
3513
|
}).loose();
|
|
3447
3514
|
function readOpencodeDialect(runtimeOptions) {
|
|
3448
3515
|
const parsed = opencodeDialectSchema.safeParse(runtimeOptions?.["opencode"] ?? {});
|
|
@@ -3458,6 +3525,7 @@ function parseOpencodeModel(model) {
|
|
|
3458
3525
|
|
|
3459
3526
|
// packages/agent-runtime/src/opencode/run-spec.ts
|
|
3460
3527
|
var CABANE_MCP_SERVER2 = "cabane";
|
|
3528
|
+
var TURN_CONTROL_MCP_SERVER = "cabane_companion";
|
|
3461
3529
|
var ACTIVE_CONVERSATION_HEADER3 = "x-cabane-active-conversation";
|
|
3462
3530
|
function buildRunSpec(req, resumeSessionId) {
|
|
3463
3531
|
const { policy, config } = req;
|
|
@@ -3519,6 +3587,17 @@ function buildMcp(req) {
|
|
|
3519
3587
|
},
|
|
3520
3588
|
enabled: true
|
|
3521
3589
|
};
|
|
3590
|
+
if (req.cabane.turnControlUrl) {
|
|
3591
|
+
mcp[TURN_CONTROL_MCP_SERVER] = {
|
|
3592
|
+
type: "remote",
|
|
3593
|
+
url: req.cabane.turnControlUrl,
|
|
3594
|
+
headers: {
|
|
3595
|
+
Authorization: `Bearer ${req.cabane.bearer}`,
|
|
3596
|
+
[ACTIVE_CONVERSATION_HEADER3]: req.cabane.activeConversationId
|
|
3597
|
+
},
|
|
3598
|
+
enabled: true
|
|
3599
|
+
};
|
|
3600
|
+
}
|
|
3522
3601
|
for (const [name, raw] of Object.entries(req.extra.mcpServers)) {
|
|
3523
3602
|
const server = raw;
|
|
3524
3603
|
if (typeof server.url === "string") {
|
|
@@ -3784,7 +3863,7 @@ var opencodeAdapter = createOpencodeAdapter();
|
|
|
3784
3863
|
// packages/agent-runtime/src/opencode/conformance.ts
|
|
3785
3864
|
var ABORT_SENTINEL2 = { __abortHere: true };
|
|
3786
3865
|
var NEW_SESSION_ID = "sess_new";
|
|
3787
|
-
var
|
|
3866
|
+
var COMPANION_POLICY = {
|
|
3788
3867
|
hostFs: false,
|
|
3789
3868
|
web: true,
|
|
3790
3869
|
browser: true,
|
|
@@ -3800,7 +3879,7 @@ function makeRequest2(overrides = {}) {
|
|
|
3800
3879
|
prompt: "hi there",
|
|
3801
3880
|
content: [{ type: "text", text: "hi there" }],
|
|
3802
3881
|
config: { model: "deepseek/deepseek-chat" },
|
|
3803
|
-
policy:
|
|
3882
|
+
policy: COMPANION_POLICY,
|
|
3804
3883
|
session: null,
|
|
3805
3884
|
cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
|
|
3806
3885
|
local: { cwd: DIR },
|
|
@@ -4148,13 +4227,19 @@ var CODEX_ADDENDUM = [
|
|
|
4148
4227
|
"as the turn\u2019s reply."
|
|
4149
4228
|
].join(" ");
|
|
4150
4229
|
var CODEX_ADDENDUM_CODE_MODE = [
|
|
4151
|
-
"Your
|
|
4152
|
-
"
|
|
4230
|
+
"Your Cabane workspace tool is exposed to Codex as the qualified MCP tool",
|
|
4231
|
+
"`mcp__cabane__sdk`. On Codex versions that defer MCP tools, locate it in the",
|
|
4232
|
+
"`functions.exec` deferred-tool inventory and invoke that exact qualified tool",
|
|
4233
|
+
"from the exec program; do not look for or call a bare top-level `sdk` tool.",
|
|
4234
|
+
"If discovery or an invocation fails, report the recorded tool error; never",
|
|
4235
|
+
"declare the SDK absent without attempting discovery and invocation. The SDK",
|
|
4236
|
+
"call runs a TypeScript program against the ambient `cabane` object. The",
|
|
4153
4237
|
"turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
|
|
4154
|
-
"
|
|
4238
|
+
"qualified `mcp__cabane_companion__\u2026` tools (and may be deferred too). There is",
|
|
4239
|
+
"no Cabane `read`/`write`/`search`/`edit` tool here \u2014 those",
|
|
4155
4240
|
"are `cabane` SDK calls inside your program, not tools. If a tool appears in this",
|
|
4156
|
-
"prompt with an `mcp__\u2026__` prefix, that
|
|
4157
|
-
"
|
|
4241
|
+
"prompt with an `mcp__\u2026__` prefix, preserve that qualified name. Write your",
|
|
4242
|
+
"closing reply as the last thing you say in the",
|
|
4158
4243
|
"turn: you can interleave narration with tool calls, but only your final message",
|
|
4159
4244
|
"is recorded as the turn\u2019s reply."
|
|
4160
4245
|
].join(" ");
|
|
@@ -4181,7 +4266,7 @@ function readItemMessage(item) {
|
|
|
4181
4266
|
function isModelMetadataError(message) {
|
|
4182
4267
|
return message.includes("Defaulting to fallback metadata");
|
|
4183
4268
|
}
|
|
4184
|
-
function readToolItem(item) {
|
|
4269
|
+
function readToolItem(item, eventType) {
|
|
4185
4270
|
const type = str(item.type);
|
|
4186
4271
|
const id = str(item.id);
|
|
4187
4272
|
if (!type || !id) return null;
|
|
@@ -4225,7 +4310,12 @@ function readToolItem(item) {
|
|
|
4225
4310
|
}
|
|
4226
4311
|
if (type === "web_search") {
|
|
4227
4312
|
const query = str(item.query) ?? "";
|
|
4228
|
-
return {
|
|
4313
|
+
return {
|
|
4314
|
+
id,
|
|
4315
|
+
name: "web_search",
|
|
4316
|
+
input: { query },
|
|
4317
|
+
status: eventType === "item.completed" ? "completed" : "in_progress"
|
|
4318
|
+
};
|
|
4229
4319
|
}
|
|
4230
4320
|
return null;
|
|
4231
4321
|
}
|
|
@@ -4343,7 +4433,7 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4343
4433
|
if (text) yield { type: "thinking", text };
|
|
4344
4434
|
continue;
|
|
4345
4435
|
}
|
|
4346
|
-
const tool2 = readToolItem(item);
|
|
4436
|
+
const tool2 = readToolItem(item, ev.type);
|
|
4347
4437
|
if (!tool2) continue;
|
|
4348
4438
|
yield* flushInterim();
|
|
4349
4439
|
const name = prettyToolName(tool2.name);
|
|
@@ -4421,20 +4511,23 @@ function sealHeld2(held, terminal) {
|
|
|
4421
4511
|
}
|
|
4422
4512
|
|
|
4423
4513
|
// packages/agent-runtime/src/codex/policy.ts
|
|
4424
|
-
import { z as
|
|
4514
|
+
import { z as z11 } from "zod";
|
|
4425
4515
|
function codexToolPolicy(policy) {
|
|
4426
|
-
return {
|
|
4427
|
-
|
|
4516
|
+
return policy.hostFs ? {
|
|
4517
|
+
permissionProfile: "cabane-coding",
|
|
4518
|
+
approvalPolicy: "never",
|
|
4519
|
+
networkAccessEnabled: policy.web
|
|
4520
|
+
} : {
|
|
4521
|
+
sandboxMode: "read-only",
|
|
4428
4522
|
// Headless: the sandbox is the boundary; never pause for a human.
|
|
4429
4523
|
approvalPolicy: "never",
|
|
4430
|
-
//
|
|
4431
|
-
// Codex's shell commands may reach the network.
|
|
4524
|
+
// Retained in the policy value for symmetry; read-only ignores it.
|
|
4432
4525
|
networkAccessEnabled: policy.web
|
|
4433
4526
|
};
|
|
4434
4527
|
}
|
|
4435
4528
|
var CODEX_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"];
|
|
4436
|
-
var codexDialectSchema =
|
|
4437
|
-
modelReasoningEffort:
|
|
4529
|
+
var codexDialectSchema = z11.object({
|
|
4530
|
+
modelReasoningEffort: z11.enum(CODEX_REASONING_EFFORTS).optional()
|
|
4438
4531
|
}).loose();
|
|
4439
4532
|
function readCodexDialect(runtimeOptions) {
|
|
4440
4533
|
const parsed = codexDialectSchema.safeParse(runtimeOptions?.["codex"] ?? {});
|
|
@@ -4442,7 +4535,7 @@ function readCodexDialect(runtimeOptions) {
|
|
|
4442
4535
|
}
|
|
4443
4536
|
|
|
4444
4537
|
// packages/agent-runtime/src/codex/model.ts
|
|
4445
|
-
var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default"
|
|
4538
|
+
var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default"]);
|
|
4446
4539
|
function parseCodexModel(model) {
|
|
4447
4540
|
const sep = model.indexOf("/");
|
|
4448
4541
|
const bare = sep === -1 || model.slice(0, sep) !== "openai" ? model : model.slice(sep + 1);
|
|
@@ -4451,6 +4544,7 @@ function parseCodexModel(model) {
|
|
|
4451
4544
|
|
|
4452
4545
|
// packages/agent-runtime/src/codex/run-spec.ts
|
|
4453
4546
|
var CABANE_MCP_SERVER3 = "cabane";
|
|
4547
|
+
var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
|
|
4454
4548
|
var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
|
|
4455
4549
|
function buildRunSpec2(req, resumeThreadId) {
|
|
4456
4550
|
const { policy, config } = req;
|
|
@@ -4481,13 +4575,15 @@ function buildConfig(req) {
|
|
|
4481
4575
|
if ("url" in server) {
|
|
4482
4576
|
mcp_servers[name] = {
|
|
4483
4577
|
url: server.url,
|
|
4484
|
-
...server.headers ? { http_headers: server.headers } : {}
|
|
4578
|
+
...server.headers ? { http_headers: server.headers } : {},
|
|
4579
|
+
default_tools_approval_mode: "approve"
|
|
4485
4580
|
};
|
|
4486
4581
|
} else if ("command" in server) {
|
|
4487
4582
|
mcp_servers[name] = {
|
|
4488
4583
|
command: server.command,
|
|
4489
4584
|
...server.args ? { args: server.args } : {},
|
|
4490
|
-
...server.env ? { env: server.env } : {}
|
|
4585
|
+
...server.env ? { env: server.env } : {},
|
|
4586
|
+
default_tools_approval_mode: "approve"
|
|
4491
4587
|
};
|
|
4492
4588
|
}
|
|
4493
4589
|
}
|
|
@@ -4496,12 +4592,14 @@ function buildConfig(req) {
|
|
|
4496
4592
|
if (typeof server.url === "string") {
|
|
4497
4593
|
mcp_servers[name] = {
|
|
4498
4594
|
url: server.url,
|
|
4499
|
-
...isStringRecord2(server.headers) ? { http_headers: server.headers } : {}
|
|
4595
|
+
...isStringRecord2(server.headers) ? { http_headers: server.headers } : {},
|
|
4596
|
+
default_tools_approval_mode: "approve"
|
|
4500
4597
|
};
|
|
4501
4598
|
} else if (typeof server.command === "string") {
|
|
4502
4599
|
mcp_servers[name] = {
|
|
4503
4600
|
command: server.command,
|
|
4504
|
-
...Array.isArray(server.args) ? { args: server.args } : {}
|
|
4601
|
+
...Array.isArray(server.args) ? { args: server.args } : {},
|
|
4602
|
+
default_tools_approval_mode: "approve"
|
|
4505
4603
|
};
|
|
4506
4604
|
}
|
|
4507
4605
|
}
|
|
@@ -4513,13 +4611,48 @@ function buildConfig(req) {
|
|
|
4513
4611
|
},
|
|
4514
4612
|
default_tools_approval_mode: "approve"
|
|
4515
4613
|
};
|
|
4516
|
-
|
|
4614
|
+
if (req.cabane.turnControlUrl) {
|
|
4615
|
+
mcp_servers[TURN_CONTROL_MCP_SERVER2] = {
|
|
4616
|
+
url: req.cabane.turnControlUrl,
|
|
4617
|
+
http_headers: {
|
|
4618
|
+
Authorization: `Bearer ${req.cabane.bearer}`,
|
|
4619
|
+
[ACTIVE_CONVERSATION_HEADER4]: req.cabane.activeConversationId
|
|
4620
|
+
},
|
|
4621
|
+
default_tools_approval_mode: "approve"
|
|
4622
|
+
};
|
|
4623
|
+
}
|
|
4624
|
+
const policy = codexToolPolicy(req.policy);
|
|
4625
|
+
const tmpDir = req.local.env?.TMPDIR;
|
|
4626
|
+
return {
|
|
4627
|
+
mcp_servers,
|
|
4628
|
+
experimental_use_rmcp_client: true,
|
|
4629
|
+
...tmpDir ? { shell_environment_policy: { set: { TMPDIR: tmpDir } } } : {},
|
|
4630
|
+
...policy.permissionProfile ? {
|
|
4631
|
+
// CT733: named permission profiles are Codex's split-filesystem path.
|
|
4632
|
+
// `:root = read` preserves coding-mode host reads; the one explicit
|
|
4633
|
+
// workspace-root write grants the checkout, and the more-specific
|
|
4634
|
+
// `.git` write reopens the metadata Codex protects by default. Neither
|
|
4635
|
+
// rule grants an adjacent directory. Do not combine this with legacy `sandbox_mode` /
|
|
4636
|
+
// `sandbox_workspace_write`, which would restore the `.git` carve-out.
|
|
4637
|
+
approval_policy: policy.approvalPolicy,
|
|
4638
|
+
default_permissions: policy.permissionProfile,
|
|
4639
|
+
permissions: {
|
|
4640
|
+
[policy.permissionProfile]: {
|
|
4641
|
+
filesystem: {
|
|
4642
|
+
":root": "read",
|
|
4643
|
+
":workspace_roots": { ".": "write", ".git": "write" }
|
|
4644
|
+
},
|
|
4645
|
+
network: { enabled: policy.networkAccessEnabled, mode: "full" }
|
|
4646
|
+
}
|
|
4647
|
+
}
|
|
4648
|
+
} : {}
|
|
4649
|
+
};
|
|
4517
4650
|
}
|
|
4518
4651
|
function isStringRecord2(v) {
|
|
4519
4652
|
return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
|
|
4520
4653
|
}
|
|
4521
4654
|
|
|
4522
|
-
// node_modules/.pnpm/@openai+codex-sdk@0.
|
|
4655
|
+
// node_modules/.pnpm/@openai+codex-sdk@0.146.0/node_modules/@openai/codex-sdk/dist/index.js
|
|
4523
4656
|
import { promises as fs } from "fs";
|
|
4524
4657
|
import os from "os";
|
|
4525
4658
|
import path from "path";
|
|
@@ -4608,6 +4741,8 @@ var Thread = class {
|
|
|
4608
4741
|
}
|
|
4609
4742
|
if (parsed.type === "thread.started") {
|
|
4610
4743
|
this._id = parsed.thread_id;
|
|
4744
|
+
} else if (parsed.type === "turn.completed") {
|
|
4745
|
+
parsed.usage.cache_write_input_tokens ??= 0;
|
|
4611
4746
|
}
|
|
4612
4747
|
yield parsed;
|
|
4613
4748
|
}
|
|
@@ -5047,6 +5182,17 @@ var Codex = class {
|
|
|
5047
5182
|
};
|
|
5048
5183
|
|
|
5049
5184
|
// packages/agent-runtime/src/codex/transport.ts
|
|
5185
|
+
function buildSdkThreadOptions(spec) {
|
|
5186
|
+
return {
|
|
5187
|
+
...spec.model ? { model: spec.model } : {},
|
|
5188
|
+
...spec.policy.sandboxMode ? { sandboxMode: spec.policy.sandboxMode } : {},
|
|
5189
|
+
workingDirectory: spec.directory,
|
|
5190
|
+
skipGitRepoCheck: spec.skipGitRepoCheck,
|
|
5191
|
+
...spec.policy.sandboxMode ? { approvalPolicy: spec.policy.approvalPolicy } : {},
|
|
5192
|
+
...spec.policy.sandboxMode === "workspace-write" ? { networkAccessEnabled: spec.policy.networkAccessEnabled } : {},
|
|
5193
|
+
...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
|
|
5194
|
+
};
|
|
5195
|
+
}
|
|
5050
5196
|
function createSdkCodexTransport(opts = {}) {
|
|
5051
5197
|
return {
|
|
5052
5198
|
async run(spec, signal) {
|
|
@@ -5060,17 +5206,7 @@ function createSdkCodexTransport(opts = {}) {
|
|
|
5060
5206
|
config: spec.config
|
|
5061
5207
|
};
|
|
5062
5208
|
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
|
-
};
|
|
5209
|
+
const threadOptions = buildSdkThreadOptions(spec);
|
|
5074
5210
|
const thread = spec.resumeThreadId ? codex.resumeThread(spec.resumeThreadId, threadOptions) : codex.startThread(threadOptions);
|
|
5075
5211
|
const streamed = await thread.runStreamed(spec.input, { signal });
|
|
5076
5212
|
return { events: streamed.events };
|
|
@@ -5127,7 +5263,7 @@ var codexAdapter = createCodexAdapter();
|
|
|
5127
5263
|
// packages/agent-runtime/src/codex/conformance.ts
|
|
5128
5264
|
var ABORT_SENTINEL3 = { __abortHere: true };
|
|
5129
5265
|
var NEW_THREAD_ID = "th_new";
|
|
5130
|
-
var
|
|
5266
|
+
var COMPANION_POLICY2 = {
|
|
5131
5267
|
hostFs: false,
|
|
5132
5268
|
web: true,
|
|
5133
5269
|
browser: true,
|
|
@@ -5148,7 +5284,7 @@ function makeRequest3(overrides = {}) {
|
|
|
5148
5284
|
// emitted → their expected results stay unchanged; a dedicated capture fixture
|
|
5149
5285
|
// sets a real model + effort.
|
|
5150
5286
|
config: { model: null },
|
|
5151
|
-
policy:
|
|
5287
|
+
policy: COMPANION_POLICY2,
|
|
5152
5288
|
session: null,
|
|
5153
5289
|
cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
|
|
5154
5290
|
local: { cwd: DIR2 },
|
|
@@ -5312,6 +5448,75 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
5312
5448
|
{ type: "result", ok: true }
|
|
5313
5449
|
]
|
|
5314
5450
|
},
|
|
5451
|
+
{
|
|
5452
|
+
// CT715: web_search LIFECYCLE. Unlike the other three tool kinds, a `web_search`
|
|
5453
|
+
// item carries NO `status` field — completion is signaled by the frame TYPE
|
|
5454
|
+
// (`item.started` → `item.completed`). The `start` fires off the first frame
|
|
5455
|
+
// (empty query, no output card); the `done` must resolve off `item.completed`
|
|
5456
|
+
// and carry the populated query the completed frame filled in. Before CT715 the
|
|
5457
|
+
// card was pinned at "running" forever (status derived from a missing field).
|
|
5458
|
+
name: "web_search lifecycle \u2014 completes off frame type, populated query on done",
|
|
5459
|
+
request: makeRequest3(),
|
|
5460
|
+
nativeStream: [
|
|
5461
|
+
threadStarted(NEW_THREAD_ID),
|
|
5462
|
+
toolFrame("item.started", { id: "ws1", type: "web_search", query: "" }),
|
|
5463
|
+
toolFrame("item.completed", { id: "ws1", type: "web_search", query: "best pizza in nyc" }),
|
|
5464
|
+
turnCompleted()
|
|
5465
|
+
],
|
|
5466
|
+
expected: [
|
|
5467
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
5468
|
+
{
|
|
5469
|
+
type: "tool",
|
|
5470
|
+
id: "ws1",
|
|
5471
|
+
name: "web_search",
|
|
5472
|
+
phase: "start",
|
|
5473
|
+
summary: "",
|
|
5474
|
+
input: { query: "" }
|
|
5475
|
+
},
|
|
5476
|
+
{
|
|
5477
|
+
type: "tool",
|
|
5478
|
+
id: "ws1",
|
|
5479
|
+
name: "web_search",
|
|
5480
|
+
phase: "done",
|
|
5481
|
+
summary: "best pizza in nyc",
|
|
5482
|
+
input: { query: "best pizza in nyc" }
|
|
5483
|
+
},
|
|
5484
|
+
{ type: "result", ok: true }
|
|
5485
|
+
]
|
|
5486
|
+
},
|
|
5487
|
+
{
|
|
5488
|
+
// CT715: a non-text web action (`action.type: "other"`) legitimately completes
|
|
5489
|
+
// with an EMPTY query — that's Codex's own data, not our bug. It must still
|
|
5490
|
+
// resolve to `done` (empty query acceptable; stuck-running is not).
|
|
5491
|
+
name: "web_search lifecycle \u2014 empty-query completion still resolves to done",
|
|
5492
|
+
request: makeRequest3(),
|
|
5493
|
+
nativeStream: [
|
|
5494
|
+
threadStarted(NEW_THREAD_ID),
|
|
5495
|
+
toolFrame("item.started", { id: "ws2", type: "web_search", query: "" }),
|
|
5496
|
+
toolFrame("item.completed", { id: "ws2", type: "web_search", query: "" }),
|
|
5497
|
+
turnCompleted()
|
|
5498
|
+
],
|
|
5499
|
+
expected: [
|
|
5500
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
5501
|
+
{
|
|
5502
|
+
type: "tool",
|
|
5503
|
+
id: "ws2",
|
|
5504
|
+
name: "web_search",
|
|
5505
|
+
phase: "start",
|
|
5506
|
+
summary: "",
|
|
5507
|
+
input: { query: "" }
|
|
5508
|
+
},
|
|
5509
|
+
{
|
|
5510
|
+
type: "tool",
|
|
5511
|
+
id: "ws2",
|
|
5512
|
+
name: "web_search",
|
|
5513
|
+
phase: "done",
|
|
5514
|
+
summary: "",
|
|
5515
|
+
input: { query: "" }
|
|
5516
|
+
},
|
|
5517
|
+
{ type: "result", ok: true }
|
|
5518
|
+
]
|
|
5519
|
+
},
|
|
5315
5520
|
{
|
|
5316
5521
|
// HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
|
|
5317
5522
|
// adapter stops before the closing reply — no final text, no `result` event.
|
|
@@ -5603,560 +5808,6 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
5603
5808
|
}
|
|
5604
5809
|
];
|
|
5605
5810
|
|
|
5606
|
-
// packages/agent-runtime/src/cabane-native/addendum.ts
|
|
5607
|
-
var CABANE_NATIVE_ADDENDUM = `## Your tools (native runtime)
|
|
5608
|
-
|
|
5609
|
-
You are running on Cabane's own agent runtime. You have a small, fixed set of workspace tools, all prefixed \`cabane_\`:
|
|
5610
|
-
|
|
5611
|
-
- \`cabane_list\` \u2014 list a folder's files and subfolders.
|
|
5612
|
-
- \`cabane_read\` \u2014 read one file's contents by path.
|
|
5613
|
-
- \`cabane_search\` \u2014 substring search across file names and contents.
|
|
5614
|
-
- \`cabane_write\` \u2014 create or overwrite a file (pass \`overwrite: true\` to replace).
|
|
5615
|
-
- \`cabane_edit\` \u2014 find/replace inside an existing file.
|
|
5616
|
-
|
|
5617
|
-
Paths are workspace-relative with a leading slash (\`/notes/todo.md\`). This is a deliberately small interim 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.`;
|
|
5618
|
-
|
|
5619
|
-
// packages/agent-runtime/src/cabane-native/context.ts
|
|
5620
|
-
var DEFAULT_HISTORY_LIMIT = 20;
|
|
5621
|
-
var DEFAULT_MAX_HISTORY_CHARS = 24e3;
|
|
5622
|
-
async function assembleMessages(systemPrompt, content, fallbackPrompt, opts) {
|
|
5623
|
-
const messages = [{ role: "system", content: systemPrompt }];
|
|
5624
|
-
const history = await fetchRecentHistory(opts);
|
|
5625
|
-
for (const m of history) messages.push(m);
|
|
5626
|
-
messages.push({ role: "user", content: currentUserText(content, fallbackPrompt) });
|
|
5627
|
-
return messages;
|
|
5628
|
-
}
|
|
5629
|
-
function currentUserText(content, fallbackPrompt) {
|
|
5630
|
-
const text = content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
|
|
5631
|
-
return text.length > 0 ? text : fallbackPrompt;
|
|
5632
|
-
}
|
|
5633
|
-
async function fetchRecentHistory(opts) {
|
|
5634
|
-
const doFetch = opts.fetchImpl ?? fetch;
|
|
5635
|
-
const limit = opts.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
5636
|
-
const url = new URL(
|
|
5637
|
-
`${opts.apiRoot}/workspaces/${opts.workspaceId}/conversations/${opts.conversationId}/messages`
|
|
5638
|
-
);
|
|
5639
|
-
url.searchParams.set("limit", String(limit));
|
|
5640
|
-
url.searchParams.set("order", "desc");
|
|
5641
|
-
let rows;
|
|
5642
|
-
try {
|
|
5643
|
-
const res = await doFetch(url.toString(), {
|
|
5644
|
-
headers: { Authorization: `Bearer ${opts.bearer}` }
|
|
5645
|
-
});
|
|
5646
|
-
if (!res.ok) return [];
|
|
5647
|
-
const body = await res.json();
|
|
5648
|
-
rows = body.messages ?? [];
|
|
5649
|
-
} catch {
|
|
5650
|
-
return [];
|
|
5651
|
-
}
|
|
5652
|
-
const chronological = [...rows].reverse();
|
|
5653
|
-
while (chronological.length > 0 && chronological[chronological.length - 1].role === "user") {
|
|
5654
|
-
chronological.pop();
|
|
5655
|
-
}
|
|
5656
|
-
const mapped = [];
|
|
5657
|
-
for (const r of chronological) {
|
|
5658
|
-
const role = r.role === "agent" ? "assistant" : r.role === "user" ? "user" : null;
|
|
5659
|
-
if (!role) continue;
|
|
5660
|
-
const body = (r.body ?? "").trim();
|
|
5661
|
-
if (body.length === 0) continue;
|
|
5662
|
-
mapped.push({ role, content: body });
|
|
5663
|
-
}
|
|
5664
|
-
return capHistory(mapped, opts.maxHistoryChars ?? DEFAULT_MAX_HISTORY_CHARS);
|
|
5665
|
-
}
|
|
5666
|
-
function capHistory(messages, maxChars) {
|
|
5667
|
-
let total = messages.reduce((n, m) => n + m.content.length, 0);
|
|
5668
|
-
let start = 0;
|
|
5669
|
-
while (total > maxChars && start < messages.length) {
|
|
5670
|
-
total -= messages[start].content.length;
|
|
5671
|
-
start += 1;
|
|
5672
|
-
}
|
|
5673
|
-
return messages.slice(start);
|
|
5674
|
-
}
|
|
5675
|
-
|
|
5676
|
-
// packages/agent-runtime/src/cabane-native/model.ts
|
|
5677
|
-
var CABANE_NATIVE_MODEL_PREFIX = "cabane-native/";
|
|
5678
|
-
function parseCabaneNativeModel(model) {
|
|
5679
|
-
return model.startsWith(CABANE_NATIVE_MODEL_PREFIX) ? model.slice(CABANE_NATIVE_MODEL_PREFIX.length) : model;
|
|
5680
|
-
}
|
|
5681
|
-
|
|
5682
|
-
// packages/agent-runtime/src/cabane-native/tools.ts
|
|
5683
|
-
var TOOL_RESULT_MAX_CHARS = 8e3;
|
|
5684
|
-
var CABANE_NATIVE_TOOLS = [
|
|
5685
|
-
{
|
|
5686
|
-
type: "function",
|
|
5687
|
-
function: {
|
|
5688
|
-
name: "cabane_list",
|
|
5689
|
-
description: "List the files and subfolders at a workspace folder path. Omit `path` for the root.",
|
|
5690
|
-
parameters: {
|
|
5691
|
-
type: "object",
|
|
5692
|
-
properties: {
|
|
5693
|
-
path: {
|
|
5694
|
-
type: "string",
|
|
5695
|
-
description: "Workspace folder path, e.g. /notes. Defaults to /."
|
|
5696
|
-
}
|
|
5697
|
-
}
|
|
5698
|
-
}
|
|
5699
|
-
}
|
|
5700
|
-
},
|
|
5701
|
-
{
|
|
5702
|
-
type: "function",
|
|
5703
|
-
function: {
|
|
5704
|
-
name: "cabane_read",
|
|
5705
|
-
description: "Read the contents of one file at a workspace path.",
|
|
5706
|
-
parameters: {
|
|
5707
|
-
type: "object",
|
|
5708
|
-
properties: {
|
|
5709
|
-
path: { type: "string", description: "Workspace file path, e.g. /notes/todo.md." }
|
|
5710
|
-
},
|
|
5711
|
-
required: ["path"]
|
|
5712
|
-
}
|
|
5713
|
-
}
|
|
5714
|
-
},
|
|
5715
|
-
{
|
|
5716
|
-
type: "function",
|
|
5717
|
-
function: {
|
|
5718
|
-
name: "cabane_search",
|
|
5719
|
-
description: "Case-insensitive substring search across file names and file contents. Optionally scope to a subtree with `path`.",
|
|
5720
|
-
parameters: {
|
|
5721
|
-
type: "object",
|
|
5722
|
-
properties: {
|
|
5723
|
-
q: { type: "string", description: "The search string." },
|
|
5724
|
-
path: {
|
|
5725
|
-
type: "string",
|
|
5726
|
-
description: "Optional workspace subtree to scope the search to."
|
|
5727
|
-
}
|
|
5728
|
-
},
|
|
5729
|
-
required: ["q"]
|
|
5730
|
-
}
|
|
5731
|
-
}
|
|
5732
|
-
},
|
|
5733
|
-
{
|
|
5734
|
-
type: "function",
|
|
5735
|
-
function: {
|
|
5736
|
-
name: "cabane_write",
|
|
5737
|
-
description: "Create a file at a workspace path (missing parent folders are created). Pass `overwrite: true` to replace an existing file instead of failing on a name conflict.",
|
|
5738
|
-
parameters: {
|
|
5739
|
-
type: "object",
|
|
5740
|
-
properties: {
|
|
5741
|
-
path: { type: "string", description: "Workspace file path, e.g. /notes/new.md." },
|
|
5742
|
-
content: { type: "string", description: "The file contents." },
|
|
5743
|
-
overwrite: { type: "boolean", description: "Replace an existing file (default false)." }
|
|
5744
|
-
},
|
|
5745
|
-
required: ["path", "content"]
|
|
5746
|
-
}
|
|
5747
|
-
}
|
|
5748
|
-
},
|
|
5749
|
-
{
|
|
5750
|
-
type: "function",
|
|
5751
|
-
function: {
|
|
5752
|
-
name: "cabane_edit",
|
|
5753
|
-
description: "Modify an existing file with a single find/replace. By default `find` must occur exactly once; set `replaceAll: true` to replace every occurrence.",
|
|
5754
|
-
parameters: {
|
|
5755
|
-
type: "object",
|
|
5756
|
-
properties: {
|
|
5757
|
-
path: { type: "string", description: "Workspace file path to edit." },
|
|
5758
|
-
find: { type: "string", description: "The substring to find." },
|
|
5759
|
-
replace: { type: "string", description: "The replacement." },
|
|
5760
|
-
replaceAll: { type: "boolean", description: "Replace every occurrence (default false)." }
|
|
5761
|
-
},
|
|
5762
|
-
required: ["path", "find", "replace"]
|
|
5763
|
-
}
|
|
5764
|
-
}
|
|
5765
|
-
}
|
|
5766
|
-
];
|
|
5767
|
-
function summarizeCabaneToolArgs(name, args) {
|
|
5768
|
-
if (name === "cabane_search") return typeof args.q === "string" ? args.q : "";
|
|
5769
|
-
return typeof args.path === "string" ? args.path : "";
|
|
5770
|
-
}
|
|
5771
|
-
async function executeCabaneNativeTool(name, args, ctx, signal) {
|
|
5772
|
-
const doFetch = ctx.fetchImpl ?? fetch;
|
|
5773
|
-
const wsBase = `${ctx.apiRoot}/workspaces/${ctx.workspaceId}`;
|
|
5774
|
-
const headers = { Authorization: `Bearer ${ctx.bearer}`, "Content-Type": "application/json" };
|
|
5775
|
-
const call = async (method, path3, init2) => {
|
|
5776
|
-
const url = new URL(`${wsBase}${path3}`);
|
|
5777
|
-
for (const [k, v] of Object.entries(init2?.query ?? {})) {
|
|
5778
|
-
if (v !== void 0) url.searchParams.set(k, v);
|
|
5779
|
-
}
|
|
5780
|
-
let res;
|
|
5781
|
-
try {
|
|
5782
|
-
res = await doFetch(url.toString(), {
|
|
5783
|
-
method,
|
|
5784
|
-
headers,
|
|
5785
|
-
...init2?.body !== void 0 ? { body: JSON.stringify(init2.body) } : {},
|
|
5786
|
-
signal
|
|
5787
|
-
});
|
|
5788
|
-
} catch (err) {
|
|
5789
|
-
return {
|
|
5790
|
-
ok: false,
|
|
5791
|
-
result: `error: request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
5792
|
-
};
|
|
5793
|
-
}
|
|
5794
|
-
const contentType = res.headers.get("content-type") ?? "";
|
|
5795
|
-
const text = contentType.includes("application/json") ? JSON.stringify(await res.json().catch(() => ({}))) : await res.text().catch(() => "");
|
|
5796
|
-
if (!res.ok) return { ok: false, result: truncate2(`error ${res.status}: ${text}`) };
|
|
5797
|
-
return { ok: true, result: truncate2(text) };
|
|
5798
|
-
};
|
|
5799
|
-
switch (name) {
|
|
5800
|
-
case "cabane_list":
|
|
5801
|
-
return call("GET", "/files/tree", { query: { path: str2(args.path) ?? "/" } });
|
|
5802
|
-
case "cabane_read":
|
|
5803
|
-
return call("GET", "/files/content", { query: { path: str2(args.path) } });
|
|
5804
|
-
case "cabane_search":
|
|
5805
|
-
return call("GET", "/search", { query: { q: str2(args.q), path: str2(args.path) } });
|
|
5806
|
-
case "cabane_write": {
|
|
5807
|
-
const overwrite = args.overwrite === true;
|
|
5808
|
-
return overwrite ? call("PUT", "/files/content", {
|
|
5809
|
-
body: { path: str2(args.path), content: str2(args.content) }
|
|
5810
|
-
}) : call("POST", "/files", {
|
|
5811
|
-
body: { path: str2(args.path), content: str2(args.content), mkdirs: true }
|
|
5812
|
-
});
|
|
5813
|
-
}
|
|
5814
|
-
case "cabane_edit":
|
|
5815
|
-
return call("PATCH", "/files", {
|
|
5816
|
-
body: {
|
|
5817
|
-
path: str2(args.path),
|
|
5818
|
-
find: str2(args.find),
|
|
5819
|
-
replace: str2(args.replace) ?? "",
|
|
5820
|
-
...args.replaceAll === true ? { replaceAll: true } : {}
|
|
5821
|
-
}
|
|
5822
|
-
});
|
|
5823
|
-
default:
|
|
5824
|
-
return { ok: false, result: `error: unknown tool "${name}"` };
|
|
5825
|
-
}
|
|
5826
|
-
}
|
|
5827
|
-
function str2(v) {
|
|
5828
|
-
return typeof v === "string" ? v : void 0;
|
|
5829
|
-
}
|
|
5830
|
-
function truncate2(s) {
|
|
5831
|
-
return s.length > TOOL_RESULT_MAX_CHARS ? `${s.slice(0, TOOL_RESULT_MAX_CHARS)}
|
|
5832
|
-
\u2026 [truncated]` : s;
|
|
5833
|
-
}
|
|
5834
|
-
|
|
5835
|
-
// packages/agent-runtime/src/cabane-native/loop.ts
|
|
5836
|
-
var DEFAULT_MAX_ITERATIONS = 12;
|
|
5837
|
-
async function* runCabaneNativeTurn(req, signal, deps) {
|
|
5838
|
-
if (!req.config.model) {
|
|
5839
|
-
yield { type: "result", ok: false, reason: "no_model" };
|
|
5840
|
-
return;
|
|
5841
|
-
}
|
|
5842
|
-
const model = parseCabaneNativeModel(req.config.model);
|
|
5843
|
-
const toolCtx = {
|
|
5844
|
-
apiRoot: deps.apiRoot,
|
|
5845
|
-
workspaceId: deps.workspaceId,
|
|
5846
|
-
bearer: deps.bearer,
|
|
5847
|
-
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
|
|
5848
|
-
};
|
|
5849
|
-
const messages = await assembleMessages(req.systemPrompt, req.content, req.prompt, {
|
|
5850
|
-
apiRoot: deps.apiRoot,
|
|
5851
|
-
workspaceId: deps.workspaceId,
|
|
5852
|
-
bearer: deps.bearer,
|
|
5853
|
-
conversationId: deps.conversationId,
|
|
5854
|
-
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {},
|
|
5855
|
-
...deps.historyLimit !== void 0 ? { historyLimit: deps.historyLimit } : {}
|
|
5856
|
-
});
|
|
5857
|
-
if (signal.aborted) return;
|
|
5858
|
-
const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
|
5859
|
-
let usage;
|
|
5860
|
-
let resolvedModel = model;
|
|
5861
|
-
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
5862
|
-
let text = "";
|
|
5863
|
-
const toolAcc = /* @__PURE__ */ new Map();
|
|
5864
|
-
let finishReason;
|
|
5865
|
-
let errored2;
|
|
5866
|
-
for await (const ev of deps.provider.stream(
|
|
5867
|
-
{ model, messages, tools: CABANE_NATIVE_TOOLS },
|
|
5868
|
-
signal
|
|
5869
|
-
)) {
|
|
5870
|
-
if (signal.aborted) return;
|
|
5871
|
-
switch (ev.type) {
|
|
5872
|
-
case "text":
|
|
5873
|
-
text += ev.delta;
|
|
5874
|
-
break;
|
|
5875
|
-
case "tool_call": {
|
|
5876
|
-
const cur = toolAcc.get(ev.index) ?? { id: `call_${ev.index}`, name: "", args: "" };
|
|
5877
|
-
if (ev.id) cur.id = ev.id;
|
|
5878
|
-
if (ev.name) cur.name = ev.name;
|
|
5879
|
-
if (ev.argumentsDelta) cur.args += ev.argumentsDelta;
|
|
5880
|
-
toolAcc.set(ev.index, cur);
|
|
5881
|
-
break;
|
|
5882
|
-
}
|
|
5883
|
-
case "usage":
|
|
5884
|
-
usage = { inputTokens: ev.inputTokens, outputTokens: ev.outputTokens };
|
|
5885
|
-
break;
|
|
5886
|
-
case "model":
|
|
5887
|
-
resolvedModel = ev.model;
|
|
5888
|
-
break;
|
|
5889
|
-
case "error":
|
|
5890
|
-
errored2 = ev.message;
|
|
5891
|
-
break;
|
|
5892
|
-
case "done":
|
|
5893
|
-
finishReason = ev.finishReason;
|
|
5894
|
-
break;
|
|
5895
|
-
}
|
|
5896
|
-
}
|
|
5897
|
-
if (signal.aborted) return;
|
|
5898
|
-
if (errored2 !== void 0) {
|
|
5899
|
-
const sealed = sealText(text, false);
|
|
5900
|
-
if (sealed) yield sealed;
|
|
5901
|
-
const failure = classifyErrorText(errored2);
|
|
5902
|
-
yield {
|
|
5903
|
-
type: "result",
|
|
5904
|
-
ok: false,
|
|
5905
|
-
reason: failure ? encodeFailureReason(failure) : `error:${errored2.slice(0, 200)}`,
|
|
5906
|
-
...usage ? { usage } : {},
|
|
5907
|
-
...resolvedModel ? { resolvedModel } : {}
|
|
5908
|
-
};
|
|
5909
|
-
return;
|
|
5910
|
-
}
|
|
5911
|
-
const toolCalls = [...toolAcc.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
|
|
5912
|
-
if (toolCalls.length === 0) {
|
|
5913
|
-
const sealed = sealText(text, true);
|
|
5914
|
-
if (sealed) yield sealed;
|
|
5915
|
-
yield {
|
|
5916
|
-
type: "result",
|
|
5917
|
-
ok: true,
|
|
5918
|
-
...usage ? { usage } : {},
|
|
5919
|
-
...resolvedModel ? { resolvedModel } : {}
|
|
5920
|
-
};
|
|
5921
|
-
return;
|
|
5922
|
-
}
|
|
5923
|
-
const sealedInterim = sealText(text, false);
|
|
5924
|
-
if (sealedInterim) yield sealedInterim;
|
|
5925
|
-
const assistantToolCalls = toolCalls.map((t) => ({
|
|
5926
|
-
id: t.id,
|
|
5927
|
-
type: "function",
|
|
5928
|
-
function: { name: t.name, arguments: t.args || "{}" }
|
|
5929
|
-
}));
|
|
5930
|
-
messages.push({ role: "assistant", content: text, tool_calls: assistantToolCalls });
|
|
5931
|
-
for (const t of toolCalls) {
|
|
5932
|
-
if (signal.aborted) return;
|
|
5933
|
-
const args = parseArgs(t.args);
|
|
5934
|
-
const displayName = prettyToolName(t.name);
|
|
5935
|
-
const summary = summarizeCabaneToolArgs(t.name, args);
|
|
5936
|
-
yield { type: "tool", id: t.id, name: displayName, phase: "start", summary, input: args };
|
|
5937
|
-
const result = await executeCabaneNativeTool(t.name, args, toolCtx, signal);
|
|
5938
|
-
if (signal.aborted) return;
|
|
5939
|
-
yield {
|
|
5940
|
-
type: "tool",
|
|
5941
|
-
id: t.id,
|
|
5942
|
-
name: displayName,
|
|
5943
|
-
phase: result.ok ? "done" : "error",
|
|
5944
|
-
summary,
|
|
5945
|
-
input: args,
|
|
5946
|
-
result: result.result
|
|
5947
|
-
};
|
|
5948
|
-
messages.push({ role: "tool", tool_call_id: t.id, content: result.result });
|
|
5949
|
-
}
|
|
5950
|
-
}
|
|
5951
|
-
deps.onWarn?.("cabane-native: turn hit the tool-iteration cap; force-settling", {
|
|
5952
|
-
maxIterations
|
|
5953
|
-
});
|
|
5954
|
-
yield {
|
|
5955
|
-
type: "text",
|
|
5956
|
-
body: `(Stopped after ${maxIterations} tool steps without a final answer.)`,
|
|
5957
|
-
terminal: true
|
|
5958
|
-
};
|
|
5959
|
-
yield {
|
|
5960
|
-
type: "result",
|
|
5961
|
-
ok: true,
|
|
5962
|
-
...usage ? { usage } : {},
|
|
5963
|
-
...resolvedModel ? { resolvedModel } : {}
|
|
5964
|
-
};
|
|
5965
|
-
}
|
|
5966
|
-
function sealText(text, terminal) {
|
|
5967
|
-
const body = text.trim();
|
|
5968
|
-
if (body.length === 0) return null;
|
|
5969
|
-
return { type: "text", body, terminal };
|
|
5970
|
-
}
|
|
5971
|
-
function parseArgs(raw) {
|
|
5972
|
-
if (!raw.trim()) return {};
|
|
5973
|
-
try {
|
|
5974
|
-
const parsed = JSON.parse(raw);
|
|
5975
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
5976
|
-
} catch {
|
|
5977
|
-
return {};
|
|
5978
|
-
}
|
|
5979
|
-
}
|
|
5980
|
-
|
|
5981
|
-
// packages/agent-runtime/src/cabane-native/policy.ts
|
|
5982
|
-
import { z as z13 } from "zod";
|
|
5983
|
-
var cabaneNativeDialectSchema = z13.object({}).loose();
|
|
5984
|
-
|
|
5985
|
-
// packages/agent-runtime/src/cabane-native/provider.ts
|
|
5986
|
-
var DEFAULT_OPENROUTER_BASE = "https://openrouter.ai/api/v1";
|
|
5987
|
-
function createOpenRouterProvider(opts) {
|
|
5988
|
-
const base = (opts.baseUrl ?? DEFAULT_OPENROUTER_BASE).replace(/\/$/, "");
|
|
5989
|
-
const doFetch = opts.fetchImpl ?? fetch;
|
|
5990
|
-
return {
|
|
5991
|
-
async *stream(req, signal) {
|
|
5992
|
-
let res;
|
|
5993
|
-
try {
|
|
5994
|
-
res = await doFetch(`${base}/chat/completions`, {
|
|
5995
|
-
method: "POST",
|
|
5996
|
-
headers: {
|
|
5997
|
-
Authorization: `Bearer ${opts.apiKey}`,
|
|
5998
|
-
"Content-Type": "application/json",
|
|
5999
|
-
// OpenRouter attribution headers (optional, but polite + used for
|
|
6000
|
-
// routing/analytics on their side).
|
|
6001
|
-
"HTTP-Referer": "https://cabane.ai",
|
|
6002
|
-
"X-Title": "Cabane"
|
|
6003
|
-
},
|
|
6004
|
-
body: JSON.stringify({
|
|
6005
|
-
model: req.model,
|
|
6006
|
-
messages: req.messages,
|
|
6007
|
-
...req.tools.length > 0 ? { tools: req.tools } : {},
|
|
6008
|
-
stream: true,
|
|
6009
|
-
// Ask OpenRouter to append a trailing usage chunk to the stream.
|
|
6010
|
-
stream_options: { include_usage: true }
|
|
6011
|
-
}),
|
|
6012
|
-
signal
|
|
6013
|
-
});
|
|
6014
|
-
} catch (err) {
|
|
6015
|
-
if (signal.aborted) return;
|
|
6016
|
-
yield { type: "error", message: `request failed: ${errText(err)}` };
|
|
6017
|
-
return;
|
|
6018
|
-
}
|
|
6019
|
-
if (!res.ok || !res.body) {
|
|
6020
|
-
const bodyText = await res.text().catch(() => "");
|
|
6021
|
-
yield { type: "error", message: providerErrorMessage(res.status, bodyText) };
|
|
6022
|
-
return;
|
|
6023
|
-
}
|
|
6024
|
-
const decoder = new TextDecoder();
|
|
6025
|
-
const reader = res.body.getReader();
|
|
6026
|
-
let buffer = "";
|
|
6027
|
-
let finishReason;
|
|
6028
|
-
let modelEmitted = false;
|
|
6029
|
-
try {
|
|
6030
|
-
for (; ; ) {
|
|
6031
|
-
if (signal.aborted) return;
|
|
6032
|
-
const { value, done } = await reader.read();
|
|
6033
|
-
if (done) break;
|
|
6034
|
-
buffer += decoder.decode(value, { stream: true });
|
|
6035
|
-
let nl;
|
|
6036
|
-
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
6037
|
-
const line = buffer.slice(0, nl).trim();
|
|
6038
|
-
buffer = buffer.slice(nl + 1);
|
|
6039
|
-
if (!line || line.startsWith(":")) continue;
|
|
6040
|
-
if (!line.startsWith("data:")) continue;
|
|
6041
|
-
const data = line.slice("data:".length).trim();
|
|
6042
|
-
if (data === "[DONE]") {
|
|
6043
|
-
yield { type: "done", ...finishReason ? { finishReason } : {} };
|
|
6044
|
-
return;
|
|
6045
|
-
}
|
|
6046
|
-
let chunk;
|
|
6047
|
-
try {
|
|
6048
|
-
chunk = JSON.parse(data);
|
|
6049
|
-
} catch {
|
|
6050
|
-
continue;
|
|
6051
|
-
}
|
|
6052
|
-
if (chunk.error) {
|
|
6053
|
-
yield { type: "error", message: chunk.error.message ?? "provider error" };
|
|
6054
|
-
return;
|
|
6055
|
-
}
|
|
6056
|
-
if (!modelEmitted && chunk.model) {
|
|
6057
|
-
modelEmitted = true;
|
|
6058
|
-
yield { type: "model", model: chunk.model };
|
|
6059
|
-
}
|
|
6060
|
-
const choice = chunk.choices?.[0];
|
|
6061
|
-
if (choice) {
|
|
6062
|
-
const delta = choice.delta;
|
|
6063
|
-
if (delta?.content) yield { type: "text", delta: delta.content };
|
|
6064
|
-
if (delta?.tool_calls) {
|
|
6065
|
-
for (const tc of delta.tool_calls) {
|
|
6066
|
-
yield {
|
|
6067
|
-
type: "tool_call",
|
|
6068
|
-
index: tc.index,
|
|
6069
|
-
...tc.id ? { id: tc.id } : {},
|
|
6070
|
-
...tc.function?.name ? { name: tc.function.name } : {},
|
|
6071
|
-
...tc.function?.arguments !== void 0 ? { argumentsDelta: tc.function.arguments } : {}
|
|
6072
|
-
};
|
|
6073
|
-
}
|
|
6074
|
-
}
|
|
6075
|
-
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
6076
|
-
}
|
|
6077
|
-
if (chunk.usage) {
|
|
6078
|
-
yield {
|
|
6079
|
-
type: "usage",
|
|
6080
|
-
inputTokens: chunk.usage.prompt_tokens ?? 0,
|
|
6081
|
-
outputTokens: chunk.usage.completion_tokens ?? 0
|
|
6082
|
-
};
|
|
6083
|
-
}
|
|
6084
|
-
}
|
|
6085
|
-
}
|
|
6086
|
-
} catch (err) {
|
|
6087
|
-
if (signal.aborted) return;
|
|
6088
|
-
yield { type: "error", message: `stream read failed: ${errText(err)}` };
|
|
6089
|
-
return;
|
|
6090
|
-
}
|
|
6091
|
-
yield { type: "done", ...finishReason ? { finishReason } : {} };
|
|
6092
|
-
}
|
|
6093
|
-
};
|
|
6094
|
-
}
|
|
6095
|
-
function providerErrorMessage(status, body) {
|
|
6096
|
-
let detail = body.slice(0, 300);
|
|
6097
|
-
try {
|
|
6098
|
-
const parsed = JSON.parse(body);
|
|
6099
|
-
if (parsed.error?.message) detail = parsed.error.message;
|
|
6100
|
-
} catch {
|
|
6101
|
-
}
|
|
6102
|
-
return `HTTP ${status}: ${detail}`;
|
|
6103
|
-
}
|
|
6104
|
-
function errText(err) {
|
|
6105
|
-
return err instanceof Error ? err.message : String(err);
|
|
6106
|
-
}
|
|
6107
|
-
|
|
6108
|
-
// packages/agent-runtime/src/cabane-native/index.ts
|
|
6109
|
-
var CABANE_NATIVE_RUNTIME_NAME = "cabane-native";
|
|
6110
|
-
function createCabaneNativeAdapter(deps = {}) {
|
|
6111
|
-
const provider = deps.provider ?? (deps.apiKey ? createOpenRouterProvider({
|
|
6112
|
-
apiKey: deps.apiKey,
|
|
6113
|
-
...deps.baseUrl ? { baseUrl: deps.baseUrl } : {},
|
|
6114
|
-
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
|
|
6115
|
-
}) : null);
|
|
6116
|
-
return {
|
|
6117
|
-
name: CABANE_NATIVE_RUNTIME_NAME,
|
|
6118
|
-
// CT614: the native runtime carries its OWN `cabane_*` tool surface (not the
|
|
6119
|
-
// `cabane` MCP server), so the CT609 code-mode lever — which filters the MCP
|
|
6120
|
-
// server down to `code` — never touches what's mounted here. The addendum is
|
|
6121
|
-
// therefore flag-INDEPENDENT: it always teaches the `cabane_*` names that are
|
|
6122
|
-
// actually mounted (the `codeMode` arg is accepted-and-ignored). Native's
|
|
6123
|
-
// adoption of `code` as its primary surface is a separate, later track.
|
|
6124
|
-
promptAddendum: () => CABANE_NATIVE_ADDENDUM,
|
|
6125
|
-
dialectSchema: cabaneNativeDialectSchema,
|
|
6126
|
-
async *runTurn(req, signal) {
|
|
6127
|
-
if (!provider) {
|
|
6128
|
-
yield {
|
|
6129
|
-
type: "result",
|
|
6130
|
-
ok: false,
|
|
6131
|
-
reason: "cabane_native_unavailable:no OPENROUTER_API_KEY configured on this device"
|
|
6132
|
-
};
|
|
6133
|
-
return;
|
|
6134
|
-
}
|
|
6135
|
-
const workspaceId = req.cabane.workspaceId;
|
|
6136
|
-
if (!workspaceId) {
|
|
6137
|
-
yield {
|
|
6138
|
-
type: "result",
|
|
6139
|
-
ok: false,
|
|
6140
|
-
reason: "cabane_native_unavailable:turn carried no workspaceId"
|
|
6141
|
-
};
|
|
6142
|
-
return;
|
|
6143
|
-
}
|
|
6144
|
-
const apiRoot = req.cabane.mcpUrl.replace(/\/mcp\/?$/, "");
|
|
6145
|
-
yield* runCabaneNativeTurn(req, signal, {
|
|
6146
|
-
provider,
|
|
6147
|
-
apiRoot,
|
|
6148
|
-
workspaceId,
|
|
6149
|
-
bearer: req.cabane.bearer,
|
|
6150
|
-
conversationId: req.cabane.activeConversationId,
|
|
6151
|
-
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {},
|
|
6152
|
-
...deps.onWarn ? { onWarn: deps.onWarn } : {},
|
|
6153
|
-
...deps.maxIterations !== void 0 ? { maxIterations: deps.maxIterations } : {}
|
|
6154
|
-
});
|
|
6155
|
-
}
|
|
6156
|
-
};
|
|
6157
|
-
}
|
|
6158
|
-
var cabaneNativeAdapter = createCabaneNativeAdapter();
|
|
6159
|
-
|
|
6160
5811
|
// packages/agent-runtime/src/claude-code/sdk.ts
|
|
6161
5812
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
6162
5813
|
|
|
@@ -6195,8 +5846,8 @@ var ConnectorHealthStore = class {
|
|
|
6195
5846
|
return this.byRuntime.get(runtime);
|
|
6196
5847
|
}
|
|
6197
5848
|
// The per-connector reports to attach to a heartbeat — one entry per runtime the
|
|
6198
|
-
//
|
|
6199
|
-
// so a
|
|
5849
|
+
// companion has an observation for. Empty until the first classified failure/heal,
|
|
5850
|
+
// so a companion that has seen nothing sends no `connectors[]` and the server's
|
|
6200
5851
|
// manifest synthesis (status-less rows) is unaffected.
|
|
6201
5852
|
reports() {
|
|
6202
5853
|
return [...this.byRuntime.entries()].map(([runtime, h]) => ({
|
|
@@ -6210,22 +5861,23 @@ var ConnectorHealthStore = class {
|
|
|
6210
5861
|
|
|
6211
5862
|
// src/dispatcher.ts
|
|
6212
5863
|
import { randomUUID } from "crypto";
|
|
6213
|
-
import { existsSync as existsSync9 } from "fs";
|
|
5864
|
+
import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync9 } from "fs";
|
|
5865
|
+
import { join as join12 } from "path";
|
|
6214
5866
|
|
|
6215
5867
|
// src/summon.ts
|
|
6216
|
-
import { z as
|
|
6217
|
-
var
|
|
5868
|
+
import { z as z12 } from "zod";
|
|
5869
|
+
var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
|
|
6218
5870
|
var SUMMON_AGENT_TOOL = "summon_agent";
|
|
6219
|
-
var SUMMON_AGENT_TOOL_NAME = `mcp__${
|
|
6220
|
-
var
|
|
5871
|
+
var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
|
|
5872
|
+
var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
|
|
6221
5873
|
var SKIP_TURN_TOOL = "skip_turn";
|
|
6222
|
-
var SKIP_TURN_TOOL_NAME = `mcp__${
|
|
5874
|
+
var SKIP_TURN_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SKIP_TURN_TOOL}`;
|
|
6223
5875
|
var ASK_TOOL = "ask";
|
|
6224
|
-
var ASK_TOOL_NAME = `mcp__${
|
|
5876
|
+
var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
|
|
6225
5877
|
var SUB_AGENT_TOOL = "sub_agent";
|
|
6226
|
-
var SUB_AGENT_TOOL_NAME = `mcp__${
|
|
5878
|
+
var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
|
|
6227
5879
|
var WAKE_ME_TOOL = "wake_me";
|
|
6228
|
-
var WAKE_ME_TOOL_NAME = `mcp__${
|
|
5880
|
+
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
6229
5881
|
function createSummonState() {
|
|
6230
5882
|
return { agentId: null };
|
|
6231
5883
|
}
|
|
@@ -6240,14 +5892,14 @@ function createWakeState() {
|
|
|
6240
5892
|
}
|
|
6241
5893
|
function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
|
|
6242
5894
|
return createSdkMcpServer({
|
|
6243
|
-
name:
|
|
5895
|
+
name: COMPANION_LOCAL_MCP_SERVER,
|
|
6244
5896
|
version: "0.0.0",
|
|
6245
5897
|
tools: [
|
|
6246
5898
|
tool(
|
|
6247
5899
|
SUMMON_AGENT_TOOL,
|
|
6248
5900
|
"Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Use it to hand part of the work to a teammate, or pull in an expert, without leaving the conversation. Pass the peer's `agentId` (discover handles + ids via `list_agents`). The peer is dispatched on your turn's final reply, so write the context/ask into that reply first \u2014 it receives your message + this conversation to work from. Writing `@handle` in your prose does NOT summon anyone (agent prose never dispatches); this tool is the only in-thread lever. Single target \u2014 the last call wins. Summoning yourself is a no-op. Reach for it when the human wants the peer's answer right HERE, in front of them \u2014 the reply lands in this thread, so there's no return to wire (a return is for work YOU consume, never a courtesy notification). A handoff to a DIFFERENT conversation is `create_conversation` / `post_message` with their `dispatch` field instead.",
|
|
6249
5901
|
{
|
|
6250
|
-
agentId:
|
|
5902
|
+
agentId: z12.string().uuid().describe("The peer agent to summon \u2014 a workspace agent id from `list_agents`.")
|
|
6251
5903
|
},
|
|
6252
5904
|
async (args) => {
|
|
6253
5905
|
summonState.agentId = args.agentId;
|
|
@@ -6262,7 +5914,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6262
5914
|
SKIP_TURN_TOOL,
|
|
6263
5915
|
`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 a thanks/aside, a question already answered, chatter outside your lane, or a pile-on where someone else has it. Your turn ends silently: no message bubble is posted. The \`reason\` is a short free-text note for telemetry (e.g. "already answered by cabane", "thanks, nothing to add"). Prefer this over posting a low-value "ok!"/"got it" reply. Don't also write a reply when you skip \u2014 skipping IS the whole turn.`,
|
|
6264
5916
|
{
|
|
6265
|
-
reason:
|
|
5917
|
+
reason: z12.string().min(1).max(500).describe("Short reason you are declining \u2014 used for telemetry/debugging.")
|
|
6266
5918
|
},
|
|
6267
5919
|
async (args) => {
|
|
6268
5920
|
skipState.skipped = true;
|
|
@@ -6279,21 +5931,21 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6279
5931
|
ASK_TOOL,
|
|
6280
5932
|
"Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact, a go/no-go). Pass `targetUserId` (a workspace member's user id \u2014 get it from `mcp__cabane__list_members`). Two forms: a SINGLE question \u2014 a `headline` (the actual question as one clear, capitalized sentence ending in `?`, \"Do we go to prod?\") plus a short `question` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once (\"three calls before I build: A? B? C?\"), a `questions` array of 1\u20135 items, each `{ headline, body?, options? }`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence `headline`, a short `body` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted lists or headings), and 2\u20134 `options` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. An option can be a short button label OR a sentence that carries its own context; short/binary sets render as inline buttons, long ones stack full-width. Provide EITHER `question` (single) or `questions` (array), never both. The ask is recorded as a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use summon/dispatch instead.",
|
|
6281
5933
|
{
|
|
6282
|
-
targetUserId:
|
|
6283
|
-
question:
|
|
5934
|
+
targetUserId: z12.string().uuid().describe("The workspace member (human) to ask \u2014 a user id from `list_members`."),
|
|
5935
|
+
question: z12.string().min(1).max(400).optional().describe(
|
|
6284
5936
|
"SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (capped, inline markdown only). Provide EITHER this or `questions`, not both. Put the crisp one-sentence question in `headline`."
|
|
6285
5937
|
),
|
|
6286
|
-
headline:
|
|
5938
|
+
headline: z12.string().min(1).max(120).optional().describe(
|
|
6287
5939
|
'SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in `?` ("Do we go to prod?"). What the human reads first in the inbox and the chip \u2014 one scannable question, no elaboration (that goes in `question`). Strongly encouraged.'
|
|
6288
5940
|
),
|
|
6289
|
-
options:
|
|
6290
|
-
questions:
|
|
6291
|
-
|
|
6292
|
-
headline:
|
|
5941
|
+
options: z12.array(z12.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
|
|
5942
|
+
questions: z12.array(
|
|
5943
|
+
z12.object({
|
|
5944
|
+
headline: z12.string().min(1).max(120).describe(
|
|
6293
5945
|
'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
|
|
6294
5946
|
),
|
|
6295
|
-
body:
|
|
6296
|
-
options:
|
|
5947
|
+
body: z12.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
|
|
5948
|
+
options: z12.array(z12.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
|
|
6297
5949
|
})
|
|
6298
5950
|
).min(1).max(5).optional().describe(
|
|
6299
5951
|
"MULTI-question form: 1\u20135 questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or `question`/`headline`/`options`, not both."
|
|
@@ -6350,13 +6002,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6350
6002
|
SUB_AGENT_TOOL,
|
|
6351
6003
|
"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. Modeled on the Task tool, with ONE deliberate difference: it does NOT return the result inline. A callee's turn can run for minutes and no turn may hold an unbounded wait, so the shape is spawn-now, results-on-wake \u2014 this returns immediately with the child's `conversationId`, and the outcome lands LATER as a message in THIS conversation; you're woken once every sub-agent you have out in this conversation has returned. So DON'T wait for it: after spawning, finish whatever else this turn can do and end your turn (never poll the child with reads in a loop \u2014 the wake is automatic). Parallel fan-out = call this N times in one turn (they run concurrently; ONE wake when all are in); series = one call per turn. `prompt` is the child's opening instruction \u2014 make it self-contained (the sub-agent starts fresh, with only this prompt + the thread it lands in). `agentId` (optional) dispatches a PEER instead of yourself \u2014 same mechanics, a different mind (use for capability/context you lack); default (self) is the pure sub-worker with a clean context window. `title` (optional) names the child thread (results link it, so a legible title helps). A single sub-agent has no wall-clock advantage (you idle either way) \u2014 it pays when the callee has capability/context you lack, or to isolate a big read from your own session; the real win is fan-out. Don't spawn one for a lookup you can do in-turn with your own tools. The result returns to YOU to act on \u2014 reach for it when you're the consumer of the output, not as a way to notify a human: if a person just wants to read the result, dispatch a plain (no-return) conversation and link it instead of spawning a sub-agent.",
|
|
6352
6004
|
{
|
|
6353
|
-
prompt:
|
|
6005
|
+
prompt: z12.string().min(1).max(65536).describe(
|
|
6354
6006
|
"The sub-agent's opening instruction \u2014 self-contained (it starts with a fresh context window; only this prompt + the thread it lands in)."
|
|
6355
6007
|
),
|
|
6356
|
-
agentId:
|
|
6008
|
+
agentId: z12.string().uuid().optional().describe(
|
|
6357
6009
|
"Optional peer to run the sub-agent as (a workspace agent id from `list_agents`); omit to spawn yourself with a fresh context window."
|
|
6358
6010
|
),
|
|
6359
|
-
title:
|
|
6011
|
+
title: z12.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
|
|
6360
6012
|
},
|
|
6361
6013
|
async (args) => {
|
|
6362
6014
|
const result = await subAgentCreate(args);
|
|
@@ -6386,13 +6038,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6386
6038
|
WAKE_ME_TOOL,
|
|
6387
6039
|
'Wake yourself later \u2014 end this turn now and be re-dispatched at a time you pick, with a note you write to yourself. Use it for "wait until X": when the thing you need hasn\'t happened yet (a conversation isn\'t done, a PR isn\'t merged, a human hasn\'t answered), arm a wake, end your turn, and you\'re woken later to CHECK \u2014 read the workspace, and either act or re-arm. This is the loop behind "check back in five minutes", "keep checking until {condition}", and a scheduled re-try ("re-send once that agent\'s limit resets"). Ground the delay before you arm it. Almost every wake is short \u2014 seconds to a couple of hours \u2014 waiting on a condition you can name: a session limit resetting, a PR merging, a deploy landing, a throttled dispatch to re-send. Reach past a few hours only when (a) a human asked for that timing, or (b) the wait is pinned to a real external event you can name \u2014 a report that only runs Mondays, a scheduled appointment, a known reset time. A speculative far-future check-in you invented yourself \u2014 "in two weeks I\'ll see whether this feature is used" \u2014 is the one thing not to arm: if no one asked and you can\'t name both what clears the wait and why it takes that long, don\'t arm it \u2014 finish now, or raise an `ask`. Pass EXACTLY ONE of `afterSeconds` (a relative delay \u2014 `300` for five minutes) or `at` (an absolute ISO-8601 timestamp WITH a zone, e.g. `2026-07-16T09:00:00-07:00` \u2014 YOU compute it from a phrase like "tomorrow morning"; the system never parses natural-language time). `note` is a message to your future self \u2014 it becomes the body of the wake message that re-dispatches you, so write the condition to re-check ("check whether CT441 merged yet"). The wake is armed when your turn SETTLES, not now, so the delay counts from the turn ending; one wake per turn (last call wins). This is the sanctioned way to schedule your own continuation \u2014 the ONLY one; never reach for a host cron/scheduler. Guardrails: at least 60s out, at most 14 days; widen the interval as a loop ages (5m \u2192 15m \u2192 1h\u2026) rather than hammering; after many consecutive re-arms with no other activity you\'ll be steered to raise an `ask` to the human instead. If a wake can\'t be armed you\'re re-dispatched with a note explaining why \u2014 never a silent drop.',
|
|
6388
6040
|
{
|
|
6389
|
-
afterSeconds:
|
|
6041
|
+
afterSeconds: z12.number().int().positive().optional().describe(
|
|
6390
6042
|
"Relative delay in seconds from when this turn ends (e.g. 300 = five minutes). Provide EITHER this or `at`, not both. Floor 60s, horizon 14 days \u2014 enforced server-side."
|
|
6391
6043
|
),
|
|
6392
|
-
at:
|
|
6044
|
+
at: z12.string().datetime({ offset: true }).optional().describe(
|
|
6393
6045
|
"Absolute ISO-8601 timestamp WITH a zone (`Z` or `\xB1HH:MM`), e.g. `2026-07-16T09:00:00-07:00`. YOU compute it from a natural-language phrase using the current datetime in your turn context. Provide EITHER this or `afterSeconds`, not both."
|
|
6394
6046
|
),
|
|
6395
|
-
note:
|
|
6047
|
+
note: z12.string().min(1).max(2e3).describe(
|
|
6396
6048
|
'A note to your future self \u2014 becomes the body of the wake message that re-dispatches you. Write the condition to re-check ("check whether the PR merged").'
|
|
6397
6049
|
)
|
|
6398
6050
|
},
|
|
@@ -6442,7 +6094,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6442
6094
|
function cabaneMcpUrl(baseUrl) {
|
|
6443
6095
|
return `${trimSlash3(baseUrl)}/api/mcp`;
|
|
6444
6096
|
}
|
|
6445
|
-
function
|
|
6097
|
+
function turnControlMcpUrl(baseUrl) {
|
|
6098
|
+
return `${trimSlash3(baseUrl)}/api/turn-control`;
|
|
6099
|
+
}
|
|
6100
|
+
function buildCompanionTurnRequest(params) {
|
|
6446
6101
|
const { turnContext: t } = params;
|
|
6447
6102
|
return {
|
|
6448
6103
|
systemPrompt: t.systemPrompt,
|
|
@@ -6453,11 +6108,18 @@ function buildBridgeTurnRequest(params) {
|
|
|
6453
6108
|
session: t.session,
|
|
6454
6109
|
cabane: {
|
|
6455
6110
|
mcpUrl: cabaneMcpUrl(params.baseUrl),
|
|
6456
|
-
// CT306: prefer the per-turn OBO credential; fall back to the
|
|
6111
|
+
// CT306: prefer the per-turn OBO credential; fall back to the companion PAT
|
|
6457
6112
|
// when the API didn't mint one (older API / unresolvable delegation).
|
|
6458
6113
|
bearer: params.turnToken ?? params.agentPat,
|
|
6459
6114
|
activeConversationId: params.activeConversationId,
|
|
6460
|
-
workspaceId: params.workspaceId
|
|
6115
|
+
workspaceId: params.workspaceId,
|
|
6116
|
+
...t.workspaceToolSurface ? { workspaceToolSurface: t.workspaceToolSurface } : {},
|
|
6117
|
+
// CT714: mount the turn-control surface ONLY when a real turn token backs
|
|
6118
|
+
// this turn — the surface admits `turn_token` auth exclusively, so a
|
|
6119
|
+
// PAT-fallback bearer (older API / unresolved delegation) would be rejected
|
|
6120
|
+
// there. Absent it, external adapters simply don't mount it that turn (the
|
|
6121
|
+
// same graceful degrade as the rest of the OBO path).
|
|
6122
|
+
...params.turnToken ? { turnControlUrl: turnControlMcpUrl(params.baseUrl) } : {}
|
|
6461
6123
|
},
|
|
6462
6124
|
local: {
|
|
6463
6125
|
...params.cwd ? { cwd: params.cwd } : {},
|
|
@@ -6468,8 +6130,11 @@ function buildBridgeTurnRequest(params) {
|
|
|
6468
6130
|
// CT289: the auto-memory escape hatch, when the operator set it.
|
|
6469
6131
|
...params.claudeCode ? { claudeCode: params.claudeCode } : {}
|
|
6470
6132
|
},
|
|
6471
|
-
// Host-injected: the
|
|
6472
|
-
|
|
6133
|
+
// Host-injected: the companion-local summon server (for the subprocess adapters,
|
|
6134
|
+
// under its own namespace).
|
|
6135
|
+
extra: {
|
|
6136
|
+
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.summonServer }
|
|
6137
|
+
}
|
|
6473
6138
|
};
|
|
6474
6139
|
}
|
|
6475
6140
|
function trimSlash3(s) {
|
|
@@ -6482,11 +6147,14 @@ import { join as join9 } from "path";
|
|
|
6482
6147
|
function dirFor(workspaceId) {
|
|
6483
6148
|
return join9(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
|
|
6484
6149
|
}
|
|
6485
|
-
function
|
|
6486
|
-
return join9(dirFor(workspaceId),
|
|
6150
|
+
function conversationDir(workspaceId, conversationId) {
|
|
6151
|
+
return join9(dirFor(workspaceId), encodeURIComponent(conversationId));
|
|
6487
6152
|
}
|
|
6488
|
-
function
|
|
6489
|
-
|
|
6153
|
+
function pathFor3(workspaceId, conversationId, agentId) {
|
|
6154
|
+
return join9(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
|
|
6155
|
+
}
|
|
6156
|
+
function readPrepared(workspaceId, conversationId, agentId) {
|
|
6157
|
+
const path3 = pathFor3(workspaceId, conversationId, agentId);
|
|
6490
6158
|
if (!existsSync7(path3)) return null;
|
|
6491
6159
|
try {
|
|
6492
6160
|
const parsed = JSON.parse(readFileSync6(path3, "utf8"));
|
|
@@ -6501,20 +6169,24 @@ function readPrepared(workspaceId, conversationId) {
|
|
|
6501
6169
|
return null;
|
|
6502
6170
|
}
|
|
6503
6171
|
}
|
|
6504
|
-
function writePrepared(workspaceId, conversationId, result) {
|
|
6505
|
-
mkdirSync7(
|
|
6506
|
-
writeFileSync6(
|
|
6172
|
+
function writePrepared(workspaceId, conversationId, agentId, result) {
|
|
6173
|
+
mkdirSync7(conversationDir(workspaceId, conversationId), { recursive: true });
|
|
6174
|
+
writeFileSync6(
|
|
6175
|
+
pathFor3(workspaceId, conversationId, agentId),
|
|
6176
|
+
JSON.stringify(result) + "\n",
|
|
6177
|
+
"utf8"
|
|
6178
|
+
);
|
|
6507
6179
|
}
|
|
6508
6180
|
|
|
6509
6181
|
// src/secrets.ts
|
|
6510
6182
|
import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
|
|
6511
6183
|
import { join as join10 } from "path";
|
|
6512
|
-
import { z as
|
|
6184
|
+
import { z as z13 } from "zod";
|
|
6513
6185
|
var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
6514
6186
|
function secretsPath() {
|
|
6515
6187
|
return join10(cabaneDir(), "secrets.json");
|
|
6516
6188
|
}
|
|
6517
|
-
var secretStoreSchema =
|
|
6189
|
+
var secretStoreSchema = z13.record(z13.string(), z13.string());
|
|
6518
6190
|
function loadSecretStore() {
|
|
6519
6191
|
const path3 = secretsPath();
|
|
6520
6192
|
if (!existsSync8(path3)) return makeStore({});
|
|
@@ -6844,17 +6516,132 @@ var TurnCommitter = class {
|
|
|
6844
6516
|
}
|
|
6845
6517
|
};
|
|
6846
6518
|
|
|
6519
|
+
// src/workspace-readiness.ts
|
|
6520
|
+
var CLASSIC_REQUIRED = ["read", "list", "search", "write", "edit"];
|
|
6521
|
+
async function proveWorkspaceTools(req, runtime, opts = {}) {
|
|
6522
|
+
const base = {
|
|
6523
|
+
ok: false,
|
|
6524
|
+
proofType: "authenticated_mcp_tools_list",
|
|
6525
|
+
runtime,
|
|
6526
|
+
harnessFingerprint: opts.harnessFingerprint ?? runtime,
|
|
6527
|
+
endpoint: safeEndpoint(req.cabane.mcpUrl),
|
|
6528
|
+
initialized: false,
|
|
6529
|
+
authenticated: false,
|
|
6530
|
+
discoveredTools: [],
|
|
6531
|
+
requiredTools: [],
|
|
6532
|
+
acceptedNames: ["sdk", "mcp__cabane__sdk"],
|
|
6533
|
+
failedCapability: null,
|
|
6534
|
+
detail: null
|
|
6535
|
+
};
|
|
6536
|
+
if (!req.cabane.mcpUrl) return fail(base, "server_not_configured", "Cabane MCP URL absent");
|
|
6537
|
+
if (!req.cabane.bearer) return fail(base, "authentication_failed", "Cabane bearer absent");
|
|
6538
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
6539
|
+
const headers = {
|
|
6540
|
+
authorization: `Bearer ${req.cabane.bearer}`,
|
|
6541
|
+
accept: "application/json, text/event-stream",
|
|
6542
|
+
"content-type": "application/json",
|
|
6543
|
+
"x-cabane-active-conversation": req.cabane.activeConversationId
|
|
6544
|
+
};
|
|
6545
|
+
try {
|
|
6546
|
+
const initialized = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
|
|
6547
|
+
jsonrpc: "2.0",
|
|
6548
|
+
id: 1,
|
|
6549
|
+
method: "initialize",
|
|
6550
|
+
params: {
|
|
6551
|
+
protocolVersion: "2025-03-26",
|
|
6552
|
+
capabilities: {},
|
|
6553
|
+
clientInfo: { name: "cabane-companion-readiness", version: "1" }
|
|
6554
|
+
}
|
|
6555
|
+
});
|
|
6556
|
+
if (initialized.status === 401 || initialized.status === 403)
|
|
6557
|
+
return fail(base, "authentication_failed", `initialize returned HTTP ${initialized.status}`);
|
|
6558
|
+
if (!initialized.ok) return fail(base, "initialization_failed", initialized.detail);
|
|
6559
|
+
base.initialized = true;
|
|
6560
|
+
base.authenticated = true;
|
|
6561
|
+
if (initialized.sessionId) headers["mcp-session-id"] = initialized.sessionId;
|
|
6562
|
+
const listed = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
|
|
6563
|
+
jsonrpc: "2.0",
|
|
6564
|
+
id: 2,
|
|
6565
|
+
method: "tools/list",
|
|
6566
|
+
params: {}
|
|
6567
|
+
});
|
|
6568
|
+
if (listed.status === 401 || listed.status === 403)
|
|
6569
|
+
return fail(base, "authentication_failed", `tools/list returned HTTP ${listed.status}`);
|
|
6570
|
+
if (!listed.ok) return fail(base, "tool_discovery_failed", listed.detail);
|
|
6571
|
+
const result = asRecord3(asRecord3(listed.value)?.result);
|
|
6572
|
+
const tools = Array.isArray(result?.tools) ? result.tools : null;
|
|
6573
|
+
if (!tools) return fail(base, "tool_discovery_failed", "tools/list returned no tool inventory");
|
|
6574
|
+
base.discoveredTools = tools.map(
|
|
6575
|
+
(tool2) => tool2 && typeof tool2 === "object" && typeof tool2.name === "string" ? tool2.name : null
|
|
6576
|
+
).filter((name) => name !== null).sort();
|
|
6577
|
+
if (!req.cabane.workspaceToolSurface)
|
|
6578
|
+
return fail(base, "required_tool_missing", "resolved workspace tool surface absent");
|
|
6579
|
+
base.requiredTools = req.cabane.workspaceToolSurface === "code" ? ["sdk"] : CLASSIC_REQUIRED;
|
|
6580
|
+
const missing = base.requiredTools.filter((name) => !base.discoveredTools.includes(name));
|
|
6581
|
+
if (missing.length > 0)
|
|
6582
|
+
return fail(
|
|
6583
|
+
base,
|
|
6584
|
+
"required_tool_missing",
|
|
6585
|
+
`missing initialized tools: ${missing.join(", ")}`
|
|
6586
|
+
);
|
|
6587
|
+
base.ok = true;
|
|
6588
|
+
return base;
|
|
6589
|
+
} catch (error) {
|
|
6590
|
+
return fail(
|
|
6591
|
+
base,
|
|
6592
|
+
"initialization_failed",
|
|
6593
|
+
error instanceof Error ? error.message : String(error)
|
|
6594
|
+
);
|
|
6595
|
+
}
|
|
6596
|
+
}
|
|
6597
|
+
function fail(proof, capability, detail) {
|
|
6598
|
+
proof.failedCapability = capability;
|
|
6599
|
+
proof.detail = detail.slice(0, 300);
|
|
6600
|
+
return proof;
|
|
6601
|
+
}
|
|
6602
|
+
function safeEndpoint(value) {
|
|
6603
|
+
try {
|
|
6604
|
+
const url = new URL(value);
|
|
6605
|
+
return `${url.origin}${url.pathname}`;
|
|
6606
|
+
} catch {
|
|
6607
|
+
return null;
|
|
6608
|
+
}
|
|
6609
|
+
}
|
|
6610
|
+
async function rpc(fetchImpl, url, headers, body) {
|
|
6611
|
+
const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
6612
|
+
const text = await response.text();
|
|
6613
|
+
const value = parseRpcBody(text);
|
|
6614
|
+
return {
|
|
6615
|
+
ok: response.ok && !!value && !value.error,
|
|
6616
|
+
status: response.status,
|
|
6617
|
+
sessionId: response.headers.get("mcp-session-id"),
|
|
6618
|
+
value,
|
|
6619
|
+
detail: typeof asRecord3(value?.error)?.message === "string" ? String(asRecord3(value?.error)?.message) : `HTTP ${response.status}`
|
|
6620
|
+
};
|
|
6621
|
+
}
|
|
6622
|
+
function parseRpcBody(text) {
|
|
6623
|
+
const trimmed = text.trim();
|
|
6624
|
+
if (trimmed.startsWith("{")) return JSON.parse(trimmed);
|
|
6625
|
+
for (const line of trimmed.split("\n")) {
|
|
6626
|
+
if (line.startsWith("data:")) return JSON.parse(line.slice(5).trim());
|
|
6627
|
+
}
|
|
6628
|
+
return null;
|
|
6629
|
+
}
|
|
6630
|
+
function asRecord3(value) {
|
|
6631
|
+
return value !== null && typeof value === "object" ? value : null;
|
|
6632
|
+
}
|
|
6633
|
+
|
|
6847
6634
|
// src/dispatcher.ts
|
|
6848
6635
|
var PREPARING_TOOL_NAME = "preparing";
|
|
6849
6636
|
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
|
|
6637
|
+
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
6638
|
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
|
|
6639
|
+
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:";
|
|
6640
|
+
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
6641
|
var SKIPPED_MARKER_BODY = "(skipped)";
|
|
6855
6642
|
var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
|
|
6856
6643
|
var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
|
|
6857
|
-
var DEFAULT_AGENT_TOTAL_TIMEOUT_MS =
|
|
6644
|
+
var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
|
|
6858
6645
|
function runKey(conversationId, agentId) {
|
|
6859
6646
|
return `${conversationId}|${agentId}`;
|
|
6860
6647
|
}
|
|
@@ -6895,13 +6682,13 @@ var Dispatcher = class {
|
|
|
6895
6682
|
}
|
|
6896
6683
|
}
|
|
6897
6684
|
// CT138: shared pre-run teardown for every early-return that happens BEFORE
|
|
6898
|
-
// the active-run flag is flipped (the `
|
|
6685
|
+
// the active-run flag is flipped (the `setActiveRun` working-flip below).
|
|
6899
6686
|
// The server lights the "X is replying…" indicator eagerly at dispatch
|
|
6900
|
-
// (chat-dispatch.ts `scheduleRun`), and from that point only the
|
|
6687
|
+
// (chat-dispatch.ts `scheduleRun`), and from that point only the companion can
|
|
6901
6688
|
// clear it — the SJ383 `finally` after the SDK loop is the one clear, and
|
|
6902
6689
|
// every pre-run exit returns before reaching it. So each pre-run failure has
|
|
6903
6690
|
// to clear `active_run_started_at` itself, mirroring that `finally`, or the
|
|
6904
|
-
// indicator strands until the
|
|
6691
|
+
// indicator strands until the 12h age sweep.
|
|
6905
6692
|
//
|
|
6906
6693
|
// `errorReason` controls the server's duplicate-notice rule (the active-run
|
|
6907
6694
|
// PATCH handler in conversations.ts): a clear carrying `errorReason` makes the
|
|
@@ -6915,7 +6702,7 @@ var Dispatcher = class {
|
|
|
6915
6702
|
const body = { activeRunStartedAt: null };
|
|
6916
6703
|
if (errorReason) body.errorReason = errorReason.slice(0, 200);
|
|
6917
6704
|
try {
|
|
6918
|
-
await this.opts.api.
|
|
6705
|
+
await this.opts.api.setActiveRun(
|
|
6919
6706
|
this.opts.workspaceId,
|
|
6920
6707
|
payload.conversationId,
|
|
6921
6708
|
payload.agentId,
|
|
@@ -6941,9 +6728,14 @@ var Dispatcher = class {
|
|
|
6941
6728
|
agentId: payload.agentId,
|
|
6942
6729
|
messageId: payload.messageId
|
|
6943
6730
|
});
|
|
6731
|
+
const turnId = randomUUID();
|
|
6944
6732
|
let turnContext;
|
|
6945
6733
|
try {
|
|
6946
|
-
turnContext = await this.opts.api.getTurnContext(
|
|
6734
|
+
turnContext = await this.opts.api.getTurnContext(
|
|
6735
|
+
payload.conversationId,
|
|
6736
|
+
payload.messageId,
|
|
6737
|
+
turnId
|
|
6738
|
+
);
|
|
6947
6739
|
} catch (err) {
|
|
6948
6740
|
const status = err instanceof ApiError ? err.status : 0;
|
|
6949
6741
|
if (status === 404) {
|
|
@@ -6984,7 +6776,7 @@ var Dispatcher = class {
|
|
|
6984
6776
|
);
|
|
6985
6777
|
if (missing.length > 0) {
|
|
6986
6778
|
const list = missing.map((n) => `\`${n}\``).join(", ");
|
|
6987
|
-
turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this
|
|
6779
|
+
turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this companion");
|
|
6988
6780
|
try {
|
|
6989
6781
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
6990
6782
|
body: `${MISSING_SECRET_PREFIX} ${list}`,
|
|
@@ -7005,7 +6797,6 @@ var Dispatcher = class {
|
|
|
7005
6797
|
const localCwd = this.opts.local.cwd;
|
|
7006
6798
|
const prepareHook = this.opts.local.prepareHook;
|
|
7007
6799
|
const cabaneCwd = turnContext.cwd;
|
|
7008
|
-
const turnId = randomUUID();
|
|
7009
6800
|
let seqCounter = 0;
|
|
7010
6801
|
const nextSeq = () => ++seqCounter;
|
|
7011
6802
|
let effectiveCwd = localCwd ?? cabaneCwd;
|
|
@@ -7018,7 +6809,7 @@ var Dispatcher = class {
|
|
|
7018
6809
|
}
|
|
7019
6810
|
let hookEnv;
|
|
7020
6811
|
if (prepareHook) {
|
|
7021
|
-
const cached2 = readPrepared(workspaceId, payload.conversationId);
|
|
6812
|
+
const cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
|
|
7022
6813
|
if (cached2) {
|
|
7023
6814
|
effectiveCwd = cached2.cwd;
|
|
7024
6815
|
hookEnv = cached2.env;
|
|
@@ -7055,6 +6846,7 @@ var Dispatcher = class {
|
|
|
7055
6846
|
conversationId: payload.conversationId,
|
|
7056
6847
|
agentId: payload.agentId,
|
|
7057
6848
|
agentUsername: this.opts.agentUsername,
|
|
6849
|
+
runtime: turnContext.runtime,
|
|
7058
6850
|
// CT317/CT319: the trigger message's referenced-entry paths — what the
|
|
7059
6851
|
// tasker prepare hook keys its per-task env off. Defaults to `[]` for
|
|
7060
6852
|
// an older API. The conversation anchor is gone (CT319).
|
|
@@ -7063,7 +6855,7 @@ var Dispatcher = class {
|
|
|
7063
6855
|
});
|
|
7064
6856
|
clearTimeout(preparingTimer);
|
|
7065
6857
|
if (preparingStarted) reportPreparing("done");
|
|
7066
|
-
writePrepared(workspaceId, payload.conversationId, result);
|
|
6858
|
+
writePrepared(workspaceId, payload.conversationId, payload.agentId, result);
|
|
7067
6859
|
effectiveCwd = result.cwd;
|
|
7068
6860
|
hookEnv = result.env;
|
|
7069
6861
|
} catch (err) {
|
|
@@ -7092,12 +6884,25 @@ ${reason}`,
|
|
|
7092
6884
|
}
|
|
7093
6885
|
}
|
|
7094
6886
|
}
|
|
6887
|
+
let turnEnv = hookEnv;
|
|
6888
|
+
if (effectiveCwd && turnContext.runtime === "codex") {
|
|
6889
|
+
const tmpDir = join12(effectiveCwd, "node_modules", ".cache", "cabane-tmp", turnId);
|
|
6890
|
+
try {
|
|
6891
|
+
mkdirSync9(tmpDir, { recursive: true });
|
|
6892
|
+
turnEnv = { ...hookEnv, TMPDIR: tmpDir };
|
|
6893
|
+
} catch (err) {
|
|
6894
|
+
turnLog.warn(
|
|
6895
|
+
{ err: err instanceof Error ? err.message : String(err), tmpDir },
|
|
6896
|
+
"dispatcher: failed to create per-turn TMPDIR \u2014 proceeding with the inherited temp dir"
|
|
6897
|
+
);
|
|
6898
|
+
}
|
|
6899
|
+
}
|
|
7095
6900
|
const key = runKey(payload.conversationId, payload.agentId);
|
|
7096
6901
|
const abortController = new AbortController();
|
|
7097
6902
|
this.aborts.set(key, abortController);
|
|
7098
6903
|
let timeoutReason = null;
|
|
7099
6904
|
try {
|
|
7100
|
-
await this.opts.api.
|
|
6905
|
+
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
7101
6906
|
activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7102
6907
|
// CT33: hand the server this turn's id so the new-run chokepoint's
|
|
7103
6908
|
// `closeAbandonedTurns` sweep excludes it. The prepare hook may have
|
|
@@ -7145,16 +6950,18 @@ ${reason}`,
|
|
|
7145
6950
|
subAgentCreate,
|
|
7146
6951
|
wakeState
|
|
7147
6952
|
);
|
|
7148
|
-
const request =
|
|
6953
|
+
const request = buildCompanionTurnRequest({
|
|
7149
6954
|
turnContext,
|
|
7150
6955
|
baseUrl: this.opts.baseUrl,
|
|
7151
6956
|
agentPat: this.opts.credential,
|
|
7152
6957
|
// CT306: the per-turn OBO credential when the API minted one; falls back to
|
|
7153
|
-
// the
|
|
6958
|
+
// the companion PAT (`agentPat`) inside `buildCompanionTurnRequest` otherwise.
|
|
7154
6959
|
...turnContext.turnToken ? { turnToken: turnContext.turnToken } : {},
|
|
7155
6960
|
// SJ524: the hook-resolved cwd overrides the static local cwd.
|
|
7156
6961
|
...effectiveCwd ? { cwd: effectiveCwd } : {},
|
|
7157
|
-
|
|
6962
|
+
// CT804: `turnEnv` = the prepare-hook env plus the per-turn checkout-local
|
|
6963
|
+
// TMPDIR (falls back to `hookEnv` when no cwd was resolved).
|
|
6964
|
+
...turnEnv ? { env: turnEnv } : {},
|
|
7158
6965
|
mcpServers: resolvedMcpServers,
|
|
7159
6966
|
summonServer,
|
|
7160
6967
|
// CT238: this turn's conversation, forwarded as the active-conversation
|
|
@@ -7174,9 +6981,6 @@ ${reason}`,
|
|
|
7174
6981
|
if (this.opts.codexEnabled) {
|
|
7175
6982
|
adapters.push(createCodexAdapter({ enabled: true, onWarn }));
|
|
7176
6983
|
}
|
|
7177
|
-
if (this.opts.cabaneNativeApiKey) {
|
|
7178
|
-
adapters.push(createCabaneNativeAdapter({ apiKey: this.opts.cabaneNativeApiKey, onWarn }));
|
|
7179
|
-
}
|
|
7180
6984
|
const registry = createAdapterRegistry(adapters);
|
|
7181
6985
|
let adapter;
|
|
7182
6986
|
try {
|
|
@@ -7207,6 +7011,58 @@ ${reason}`,
|
|
|
7207
7011
|
`runtime_unavailable:${err.runtime}`
|
|
7208
7012
|
);
|
|
7209
7013
|
}
|
|
7014
|
+
if (prepareHook && hookEnv?.CABANE_TASK_ID) {
|
|
7015
|
+
const proof = await proveWorkspaceTools(request, adapter.name, {
|
|
7016
|
+
...this.opts.workspaceProofFetch ? { fetchImpl: this.opts.workspaceProofFetch } : {},
|
|
7017
|
+
harnessFingerprint: turnContext.runtime
|
|
7018
|
+
});
|
|
7019
|
+
turnLog[proof.ok ? "info" : "error"](
|
|
7020
|
+
{ workspaceProof: proof, checkout: effectiveCwd ?? null },
|
|
7021
|
+
`dispatcher: workspace tool proof ${proof.ok ? "passed" : "failed"}`
|
|
7022
|
+
);
|
|
7023
|
+
if (effectiveCwd) {
|
|
7024
|
+
try {
|
|
7025
|
+
const diagnosticDir = join12(effectiveCwd, ".git", "cabane");
|
|
7026
|
+
mkdirSync9(diagnosticDir, { recursive: true });
|
|
7027
|
+
appendFileSync2(
|
|
7028
|
+
join12(diagnosticDir, "readiness.jsonl"),
|
|
7029
|
+
`${JSON.stringify({
|
|
7030
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7031
|
+
taskId: hookEnv.CABANE_TASK_ID,
|
|
7032
|
+
binding: hookEnv.CABANE_TASK_BINDING ?? null,
|
|
7033
|
+
checkout: effectiveCwd,
|
|
7034
|
+
classification: proof.ok ? "ready" : "workspace_tools_missing",
|
|
7035
|
+
failedCapability: proof.failedCapability,
|
|
7036
|
+
workspaceTools: proof
|
|
7037
|
+
})}
|
|
7038
|
+
`,
|
|
7039
|
+
{ mode: 384 }
|
|
7040
|
+
);
|
|
7041
|
+
} catch (error) {
|
|
7042
|
+
turnLog.warn(
|
|
7043
|
+
{ err: error instanceof Error ? error.message : String(error) },
|
|
7044
|
+
"dispatcher: workspace-proof diagnostic write failed"
|
|
7045
|
+
);
|
|
7046
|
+
}
|
|
7047
|
+
}
|
|
7048
|
+
if (!proof.ok) {
|
|
7049
|
+
const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd ?? "unknown"}; runtime=${adapter.name}; recovery=restart the connector after restoring the Cabane workspace tool mount`;
|
|
7050
|
+
try {
|
|
7051
|
+
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7052
|
+
body: `**Couldn't prepare your environment.** ${reason}`,
|
|
7053
|
+
kind: "final",
|
|
7054
|
+
turnId,
|
|
7055
|
+
parentMessageId: payload.messageId
|
|
7056
|
+
});
|
|
7057
|
+
} catch (postErr) {
|
|
7058
|
+
turnLog.warn(
|
|
7059
|
+
{ err: postErr instanceof Error ? postErr.message : String(postErr) },
|
|
7060
|
+
"dispatcher: workspace-proof failure notice post failed"
|
|
7061
|
+
);
|
|
7062
|
+
}
|
|
7063
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
|
|
7064
|
+
}
|
|
7065
|
+
}
|
|
7210
7066
|
const transcript = this.opts.transcriptDir ? new TranscriptWriter(
|
|
7211
7067
|
this.opts.transcriptDir,
|
|
7212
7068
|
{
|
|
@@ -7250,6 +7106,49 @@ ${reason}`,
|
|
|
7250
7106
|
// server arms the wake schedule atomically with the reply it rode on.
|
|
7251
7107
|
wakeState
|
|
7252
7108
|
});
|
|
7109
|
+
const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
|
|
7110
|
+
let turnControlIntentFetched = false;
|
|
7111
|
+
const applyRecordedTurnControlIntent = async () => {
|
|
7112
|
+
if (turnControlIntentFetched || !usesHttpTurnControl || !turnContext.turnToken) return;
|
|
7113
|
+
turnControlIntentFetched = true;
|
|
7114
|
+
try {
|
|
7115
|
+
const intent = await this.opts.api.getTurnIntent(
|
|
7116
|
+
workspaceId,
|
|
7117
|
+
payload.conversationId,
|
|
7118
|
+
payload.agentId,
|
|
7119
|
+
turnId
|
|
7120
|
+
);
|
|
7121
|
+
if (intent.ask) {
|
|
7122
|
+
askState.targetUserId = intent.ask.targetUserId;
|
|
7123
|
+
if (intent.ask.questions && intent.ask.questions.length > 0) {
|
|
7124
|
+
askState.questions = intent.ask.questions;
|
|
7125
|
+
askState.question = null;
|
|
7126
|
+
askState.headline = null;
|
|
7127
|
+
askState.options = null;
|
|
7128
|
+
} else {
|
|
7129
|
+
askState.question = intent.ask.question ?? null;
|
|
7130
|
+
askState.headline = intent.ask.headline ?? null;
|
|
7131
|
+
askState.options = intent.ask.options ?? null;
|
|
7132
|
+
askState.questions = null;
|
|
7133
|
+
}
|
|
7134
|
+
}
|
|
7135
|
+
if (intent.wake) {
|
|
7136
|
+
wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
|
|
7137
|
+
wakeState.at = intent.wake.at ?? null;
|
|
7138
|
+
wakeState.note = intent.wake.note;
|
|
7139
|
+
}
|
|
7140
|
+
if (intent.summonAgentId) summonState.agentId = intent.summonAgentId;
|
|
7141
|
+
if (intent.skipped) {
|
|
7142
|
+
skipState.skipped = true;
|
|
7143
|
+
skipState.reason = intent.skipReason;
|
|
7144
|
+
}
|
|
7145
|
+
} catch (err) {
|
|
7146
|
+
turnLog.warn(
|
|
7147
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
7148
|
+
"dispatcher: turn-control intent fetch failed; turn-control effects for this turn are dropped"
|
|
7149
|
+
);
|
|
7150
|
+
}
|
|
7151
|
+
};
|
|
7253
7152
|
const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
|
|
7254
7153
|
const totalTimeoutMs = this.opts.totalTimeoutMs ?? DEFAULT_AGENT_TOTAL_TIMEOUT_MS;
|
|
7255
7154
|
const fireTimeout = (reason) => {
|
|
@@ -7285,7 +7184,7 @@ ${reason}`,
|
|
|
7285
7184
|
if (!sessionWritten) {
|
|
7286
7185
|
sessionWritten = true;
|
|
7287
7186
|
try {
|
|
7288
|
-
await this.opts.api.
|
|
7187
|
+
await this.opts.api.setActiveRun(
|
|
7289
7188
|
workspaceId,
|
|
7290
7189
|
payload.conversationId,
|
|
7291
7190
|
payload.agentId,
|
|
@@ -7306,6 +7205,9 @@ ${reason}`,
|
|
|
7306
7205
|
turnResolvedConfig = event.resolvedConfig;
|
|
7307
7206
|
} else if (event.type === "text" && skipState.skipped) {
|
|
7308
7207
|
} else {
|
|
7208
|
+
if (event.type === "text" && event.terminal) {
|
|
7209
|
+
await applyRecordedTurnControlIntent();
|
|
7210
|
+
}
|
|
7309
7211
|
await committer.ingestEvent(event);
|
|
7310
7212
|
}
|
|
7311
7213
|
}
|
|
@@ -7316,18 +7218,28 @@ ${reason}`,
|
|
|
7316
7218
|
if (!okResult && !resultReason) {
|
|
7317
7219
|
resultReason = "no_result";
|
|
7318
7220
|
}
|
|
7221
|
+
if (!abortController.signal.aborted) {
|
|
7222
|
+
await applyRecordedTurnControlIntent();
|
|
7223
|
+
}
|
|
7319
7224
|
if (!abortController.signal.aborted && skipState.skipped) {
|
|
7320
7225
|
turnLog.info(
|
|
7321
7226
|
{ reason: skipState.reason, turnId, ok: okResult },
|
|
7322
7227
|
"agent skipped turn (skip_turn)"
|
|
7323
7228
|
);
|
|
7229
|
+
const { afterSeconds: wakeAfter, at: wakeAt, note: wakeNote } = wakeState;
|
|
7230
|
+
const skipWake = wakeNote && (wakeAfter !== null || wakeAt !== null) ? {
|
|
7231
|
+
...wakeAfter !== null ? { afterSeconds: wakeAfter } : {},
|
|
7232
|
+
...wakeAt !== null ? { at: wakeAt } : {},
|
|
7233
|
+
note: wakeNote
|
|
7234
|
+
} : void 0;
|
|
7324
7235
|
try {
|
|
7325
7236
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7326
7237
|
body: SKIPPED_MARKER_BODY,
|
|
7327
7238
|
kind: "skipped",
|
|
7328
7239
|
turnId,
|
|
7329
7240
|
seq: nextSeq(),
|
|
7330
|
-
parentMessageId: payload.messageId
|
|
7241
|
+
parentMessageId: payload.messageId,
|
|
7242
|
+
...skipWake ? { wake: skipWake } : {}
|
|
7331
7243
|
});
|
|
7332
7244
|
} catch (err) {
|
|
7333
7245
|
turnLog.warn(
|
|
@@ -7413,7 +7325,7 @@ ${reason}`,
|
|
|
7413
7325
|
errorReason: body.errorReason ?? null
|
|
7414
7326
|
});
|
|
7415
7327
|
try {
|
|
7416
|
-
await this.opts.api.
|
|
7328
|
+
await this.opts.api.setActiveRun(
|
|
7417
7329
|
workspaceId,
|
|
7418
7330
|
payload.conversationId,
|
|
7419
7331
|
payload.agentId,
|
|
@@ -7465,7 +7377,7 @@ ${reason}`,
|
|
|
7465
7377
|
};
|
|
7466
7378
|
}
|
|
7467
7379
|
// SJ383: cancel a specific (conversation, agent) run if one is in flight in
|
|
7468
|
-
// THIS
|
|
7380
|
+
// THIS companion process. Returns true if an in-flight run was aborted.
|
|
7469
7381
|
cancel(conversationId, agentId) {
|
|
7470
7382
|
const key = runKey(conversationId, agentId);
|
|
7471
7383
|
const ac = this.aborts.get(key);
|
|
@@ -7479,27 +7391,17 @@ ${reason}`,
|
|
|
7479
7391
|
};
|
|
7480
7392
|
|
|
7481
7393
|
// src/manifest.ts
|
|
7482
|
-
var
|
|
7394
|
+
var DEVICE_MANIFEST = {
|
|
7483
7395
|
runtimes: [{ name: "claude-code", version: null }],
|
|
7484
7396
|
capabilities: { hostFs: true, browser: true, userMcp: true }
|
|
7485
7397
|
};
|
|
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
|
-
}
|
|
7398
|
+
function buildCompanionManifest(opts) {
|
|
7496
7399
|
const v = opts.versions ?? {};
|
|
7497
7400
|
const runtimes = [];
|
|
7498
7401
|
if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
|
|
7499
7402
|
if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
|
|
7500
7403
|
if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
|
|
7501
|
-
|
|
7502
|
-
return { runtimes, capabilities: { ...BRIDGE_MANIFEST.capabilities } };
|
|
7404
|
+
return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
|
|
7503
7405
|
}
|
|
7504
7406
|
|
|
7505
7407
|
// src/harness-status.ts
|
|
@@ -7510,7 +7412,7 @@ var LABELS = {
|
|
|
7510
7412
|
};
|
|
7511
7413
|
function deriveHarnessSnapshot(signals) {
|
|
7512
7414
|
const advertised = new Set(
|
|
7513
|
-
|
|
7415
|
+
buildCompanionManifest({
|
|
7514
7416
|
claudeCode: signals.claudeOnPath,
|
|
7515
7417
|
opencode: signals.opencodeConfigured,
|
|
7516
7418
|
codex: signals.codexEnabled
|
|
@@ -7700,14 +7602,14 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
|
|
|
7700
7602
|
// src/outbox.ts
|
|
7701
7603
|
import {
|
|
7702
7604
|
existsSync as existsSync10,
|
|
7703
|
-
mkdirSync as
|
|
7605
|
+
mkdirSync as mkdirSync10,
|
|
7704
7606
|
readdirSync as readdirSync2,
|
|
7705
7607
|
readFileSync as readFileSync8,
|
|
7706
7608
|
renameSync as renameSync3,
|
|
7707
7609
|
rmSync as rmSync5,
|
|
7708
7610
|
writeFileSync as writeFileSync7
|
|
7709
7611
|
} from "fs";
|
|
7710
|
-
import { join as
|
|
7612
|
+
import { join as join13 } from "path";
|
|
7711
7613
|
var MAX_ENTRIES = 2e3;
|
|
7712
7614
|
var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
7713
7615
|
var Outbox = class {
|
|
@@ -7720,17 +7622,17 @@ var Outbox = class {
|
|
|
7720
7622
|
// Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
|
|
7721
7623
|
// cases route writes at the right tmpdir.
|
|
7722
7624
|
dir() {
|
|
7723
|
-
return
|
|
7625
|
+
return join13(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
|
|
7724
7626
|
}
|
|
7725
7627
|
fileFor(turnId, seq) {
|
|
7726
|
-
return
|
|
7628
|
+
return join13(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
|
|
7727
7629
|
}
|
|
7728
7630
|
// Persist a commit for later draining. Atomic (temp file + rename) so a
|
|
7729
7631
|
// concurrent `list()` never reads a half-written entry, then enforces the
|
|
7730
7632
|
// per-workspace bounds.
|
|
7731
7633
|
persist(entry) {
|
|
7732
7634
|
const dir2 = this.dir();
|
|
7733
|
-
|
|
7635
|
+
mkdirSync10(dir2, { recursive: true });
|
|
7734
7636
|
const target = this.fileFor(entry.turnId, entry.seq);
|
|
7735
7637
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
7736
7638
|
try {
|
|
@@ -7743,7 +7645,7 @@ var Outbox = class {
|
|
|
7743
7645
|
}
|
|
7744
7646
|
this.log?.warn(
|
|
7745
7647
|
{ workspaceId: this.workspaceId, err: err instanceof Error ? err.message : String(err) },
|
|
7746
|
-
"
|
|
7648
|
+
"companion outbox: failed to persist entry"
|
|
7747
7649
|
);
|
|
7748
7650
|
return;
|
|
7749
7651
|
}
|
|
@@ -7765,7 +7667,7 @@ var Outbox = class {
|
|
|
7765
7667
|
const entries = [];
|
|
7766
7668
|
for (const name of names) {
|
|
7767
7669
|
if (!name.endsWith(".json")) continue;
|
|
7768
|
-
const full =
|
|
7670
|
+
const full = join13(dir2, name);
|
|
7769
7671
|
try {
|
|
7770
7672
|
const parsed = JSON.parse(readFileSync8(full, "utf8"));
|
|
7771
7673
|
if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
|
|
@@ -7801,7 +7703,7 @@ var Outbox = class {
|
|
|
7801
7703
|
dropCorrupt(full) {
|
|
7802
7704
|
this.log?.warn(
|
|
7803
7705
|
{ workspaceId: this.workspaceId, file: full },
|
|
7804
|
-
"
|
|
7706
|
+
"companion outbox: dropping unreadable entry"
|
|
7805
7707
|
);
|
|
7806
7708
|
try {
|
|
7807
7709
|
rmSync5(full, { force: true });
|
|
@@ -7818,7 +7720,7 @@ var Outbox = class {
|
|
|
7818
7720
|
if (now - e.enqueuedAt > MAX_AGE_MS) {
|
|
7819
7721
|
this.log?.warn(
|
|
7820
7722
|
{ workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
|
|
7821
|
-
"
|
|
7723
|
+
"companion outbox: evicting entry past max age (undeliverable)"
|
|
7822
7724
|
);
|
|
7823
7725
|
this.remove(e.turnId, e.seq);
|
|
7824
7726
|
} else {
|
|
@@ -7830,7 +7732,7 @@ var Outbox = class {
|
|
|
7830
7732
|
for (const e of survivors.slice(0, overflow)) {
|
|
7831
7733
|
this.log?.warn(
|
|
7832
7734
|
{ workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
|
|
7833
|
-
"
|
|
7735
|
+
"companion outbox: evicting oldest entry past max size"
|
|
7834
7736
|
);
|
|
7835
7737
|
this.remove(e.turnId, e.seq);
|
|
7836
7738
|
}
|
|
@@ -7839,40 +7741,43 @@ var Outbox = class {
|
|
|
7839
7741
|
};
|
|
7840
7742
|
|
|
7841
7743
|
// src/run-config.ts
|
|
7842
|
-
import { z as
|
|
7843
|
-
var mcpStdioServerSchema =
|
|
7844
|
-
type:
|
|
7845
|
-
command:
|
|
7846
|
-
args:
|
|
7847
|
-
env:
|
|
7744
|
+
import { z as z14 } from "zod";
|
|
7745
|
+
var mcpStdioServerSchema = z14.object({
|
|
7746
|
+
type: z14.literal("stdio").optional(),
|
|
7747
|
+
command: z14.string().min(1),
|
|
7748
|
+
args: z14.array(z14.string()).optional(),
|
|
7749
|
+
env: z14.record(z14.string(), z14.string()).optional()
|
|
7848
7750
|
});
|
|
7849
|
-
var mcpHttpServerSchema =
|
|
7850
|
-
type:
|
|
7851
|
-
url:
|
|
7852
|
-
headers:
|
|
7751
|
+
var mcpHttpServerSchema = z14.object({
|
|
7752
|
+
type: z14.literal("http"),
|
|
7753
|
+
url: z14.string().url(),
|
|
7754
|
+
headers: z14.record(z14.string(), z14.string()).optional()
|
|
7853
7755
|
});
|
|
7854
|
-
var mcpSseServerSchema =
|
|
7855
|
-
type:
|
|
7856
|
-
url:
|
|
7857
|
-
headers:
|
|
7756
|
+
var mcpSseServerSchema = z14.object({
|
|
7757
|
+
type: z14.literal("sse"),
|
|
7758
|
+
url: z14.string().url(),
|
|
7759
|
+
headers: z14.record(z14.string(), z14.string()).optional()
|
|
7858
7760
|
});
|
|
7859
|
-
var mcpServerDefSchema =
|
|
7761
|
+
var mcpServerDefSchema = z14.union([
|
|
7860
7762
|
mcpHttpServerSchema,
|
|
7861
7763
|
mcpSseServerSchema,
|
|
7862
7764
|
mcpStdioServerSchema
|
|
7863
7765
|
]);
|
|
7864
|
-
var thinkingConfigSchema =
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
7766
|
+
var thinkingConfigSchema = z14.discriminatedUnion("type", [
|
|
7767
|
+
z14.object({ type: z14.literal("adaptive") }),
|
|
7768
|
+
z14.object({ type: z14.literal("enabled"), budgetTokens: z14.number().int().positive().optional() }),
|
|
7769
|
+
z14.object({ type: z14.literal("disabled") })
|
|
7868
7770
|
]);
|
|
7869
|
-
var effortSchema =
|
|
7870
|
-
var runConfigSchema =
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7771
|
+
var effortSchema = z14.enum(["low", "medium", "high", "xhigh", "max"]);
|
|
7772
|
+
var runConfigSchema = z14.object({
|
|
7773
|
+
// CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
|
|
7774
|
+
// trio + its custom tool lists — `true` grants the host filesystem/shell, absent
|
|
7775
|
+
// is the locked surface. Kept in lockstep with `@cabane/shared`'s
|
|
7776
|
+
// `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
|
|
7777
|
+
// stripped, so an older companion riding a newer server never rejects the config).
|
|
7778
|
+
hostAccess: z14.boolean().optional(),
|
|
7779
|
+
mcpServers: z14.record(z14.string(), mcpServerDefSchema).optional(),
|
|
7780
|
+
model: z14.string().min(1).optional(),
|
|
7876
7781
|
thinking: thinkingConfigSchema.optional(),
|
|
7877
7782
|
effort: effortSchema.optional()
|
|
7878
7783
|
});
|
|
@@ -7993,7 +7898,7 @@ function sleep2(ms) {
|
|
|
7993
7898
|
// src/version.ts
|
|
7994
7899
|
import { createRequire as createRequire2 } from "module";
|
|
7995
7900
|
var pkg = createRequire2(import.meta.url)("../package.json");
|
|
7996
|
-
var
|
|
7901
|
+
var COMPANION_VERSION = pkg.version;
|
|
7997
7902
|
|
|
7998
7903
|
// src/supervisor.ts
|
|
7999
7904
|
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
@@ -8007,7 +7912,7 @@ var ASSIGNMENTS_POLL_MS = 6e4;
|
|
|
8007
7912
|
var DRAIN_BASE_MS = 1e3;
|
|
8008
7913
|
var DRAIN_MAX_MS = 3e4;
|
|
8009
7914
|
var DRAIN_IDLE_MS = 15e3;
|
|
8010
|
-
var
|
|
7915
|
+
var CompanionSupervisor = class {
|
|
8011
7916
|
workspaces = /* @__PURE__ */ new Map();
|
|
8012
7917
|
config;
|
|
8013
7918
|
log;
|
|
@@ -8031,10 +7936,12 @@ var BridgeSupervisor = class {
|
|
|
8031
7936
|
dispatcherFactory;
|
|
8032
7937
|
deviceApi = null;
|
|
8033
7938
|
heartbeatTimer = null;
|
|
7939
|
+
inFlightHeartbeat = null;
|
|
8034
7940
|
pollTimer = null;
|
|
8035
7941
|
refreshing = false;
|
|
8036
7942
|
stopped = false;
|
|
8037
|
-
|
|
7943
|
+
draining = false;
|
|
7944
|
+
// CT484: latch so the companion/server version-skew warning is logged once, not
|
|
8038
7945
|
// on every 30s heartbeat.
|
|
8039
7946
|
versionSkewWarned = false;
|
|
8040
7947
|
// This device's id, captured from the heartbeat / assignments response. The SSE
|
|
@@ -8060,18 +7967,18 @@ var BridgeSupervisor = class {
|
|
|
8060
7967
|
this.dispatcherFactory = opts.dispatcherFactory;
|
|
8061
7968
|
}
|
|
8062
7969
|
// Stand up the data plane: pair check, initial assignments pull, then the
|
|
8063
|
-
// heartbeat + poll loops. A
|
|
7970
|
+
// heartbeat + poll loops. A companion with no device token (logged out) does
|
|
8064
7971
|
// nothing but say so.
|
|
8065
7972
|
async start() {
|
|
8066
7973
|
this.log.info(
|
|
8067
|
-
{ protocolVersion: TURN_PROTOCOL_VERSION, version:
|
|
8068
|
-
"
|
|
7974
|
+
{ protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
|
|
7975
|
+
"companion: starting"
|
|
8069
7976
|
);
|
|
8070
7977
|
void this.refreshHarnessStatuses();
|
|
8071
7978
|
if (!this.config.deviceToken) {
|
|
8072
|
-
this.log.warn("
|
|
7979
|
+
this.log.warn("companion: not paired (no device token) \u2014 run `cabane-companion pair`");
|
|
8073
7980
|
process.stdout.write(
|
|
8074
|
-
"
|
|
7981
|
+
"companion: this device is not paired \u2014 run `cabane-companion pair`, then confirm the short code in Settings \u2192 Connectors.\n"
|
|
8075
7982
|
);
|
|
8076
7983
|
return;
|
|
8077
7984
|
}
|
|
@@ -8080,8 +7987,8 @@ var BridgeSupervisor = class {
|
|
|
8080
7987
|
deviceToken: this.config.deviceToken
|
|
8081
7988
|
});
|
|
8082
7989
|
await this.refreshAssignments();
|
|
8083
|
-
|
|
8084
|
-
this.heartbeatTimer = setInterval(() =>
|
|
7990
|
+
this.kickHeartbeat();
|
|
7991
|
+
this.heartbeatTimer = setInterval(() => this.kickHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
|
8085
7992
|
this.heartbeatTimer.unref?.();
|
|
8086
7993
|
this.pollTimer = setInterval(() => void this.refreshAssignments(), ASSIGNMENTS_POLL_MS);
|
|
8087
7994
|
this.pollTimer.unref?.();
|
|
@@ -8094,20 +8001,28 @@ var BridgeSupervisor = class {
|
|
|
8094
8001
|
return [...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : []);
|
|
8095
8002
|
}
|
|
8096
8003
|
// ---- device-level loops ----
|
|
8004
|
+
kickHeartbeat() {
|
|
8005
|
+
if (this.draining || this.inFlightHeartbeat) return;
|
|
8006
|
+
const pending = this.sendHeartbeat();
|
|
8007
|
+
this.inFlightHeartbeat = pending;
|
|
8008
|
+
void pending.finally(() => {
|
|
8009
|
+
if (this.inFlightHeartbeat === pending) this.inFlightHeartbeat = null;
|
|
8010
|
+
});
|
|
8011
|
+
}
|
|
8097
8012
|
async sendHeartbeat() {
|
|
8098
8013
|
if (!this.deviceApi) return;
|
|
8099
8014
|
await this.refreshHarnessStatuses();
|
|
8100
8015
|
try {
|
|
8101
|
-
const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "
|
|
8016
|
+
const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "companion: secrets"));
|
|
8102
8017
|
const connectorReports = this.connectorHealth.reports();
|
|
8103
8018
|
const opencodeModels = this.config.opencode?.serverUrl ? await enumerateOpencodeModels(this.config.opencode.serverUrl) : null;
|
|
8104
8019
|
const res = await this.deviceApi.heartbeat({
|
|
8105
|
-
version:
|
|
8020
|
+
version: COMPANION_VERSION,
|
|
8106
8021
|
exposedSecretNames: store.names(),
|
|
8107
8022
|
// Report each runtime only when this device can actually run it: CT309
|
|
8108
8023
|
// claude-code when `claude` is on PATH, CT270 opencode when the operator
|
|
8109
8024
|
// configured an `opencode serve`.
|
|
8110
|
-
manifest:
|
|
8025
|
+
manifest: buildCompanionManifest({
|
|
8111
8026
|
// CT586: prefer the live re-probe's presence; fall back to the boot probe
|
|
8112
8027
|
// until the first re-probe lands. Same exit-0 `claude --version` signal
|
|
8113
8028
|
// either way, so the manifest's claude-code advertising is unchanged in
|
|
@@ -8118,17 +8033,13 @@ var BridgeSupervisor = class {
|
|
|
8118
8033
|
// like opencode — the CLI's presence is the operator's responsibility;
|
|
8119
8034
|
// a misconfigured device fails the turn loudly, never silently).
|
|
8120
8035
|
codex: isCodexEnabled(this.config),
|
|
8121
|
-
// CT598: advertise the native runtime when an OpenRouter key is set — the
|
|
8122
|
-
// env flag that mounts "In Cabane" native turns on this device (house or
|
|
8123
|
-
// user). Key absent → not advertised, so a native turn never routes here.
|
|
8124
|
-
cabaneNative: isCabaneNativeEnabled(),
|
|
8125
8036
|
// CT571/CT586: each runtime's `version` from the latest harness probe
|
|
8126
8037
|
// (fail-soft to null). Informational only — the server matches on name.
|
|
8127
8038
|
versions: this.harnessVersions
|
|
8128
8039
|
}),
|
|
8129
8040
|
// CT566: echo the last classified credential state per runtime, when the
|
|
8130
|
-
//
|
|
8131
|
-
// failure/heal, so a fresh
|
|
8041
|
+
// companion has seen any. Omitted (undefined) until the first observed
|
|
8042
|
+
// failure/heal, so a fresh companion's beat is unchanged and the server's
|
|
8132
8043
|
// manifest synthesis (status-less rows) still runs.
|
|
8133
8044
|
...connectorReports.length > 0 ? { connectors: connectorReports } : {},
|
|
8134
8045
|
// CT584: include enumerated models only when the probe SUCCEEDED (non-null).
|
|
@@ -8142,22 +8053,22 @@ var BridgeSupervisor = class {
|
|
|
8142
8053
|
} catch (err) {
|
|
8143
8054
|
this.log.warn(
|
|
8144
8055
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
8145
|
-
"
|
|
8056
|
+
"companion: device heartbeat failed (will retry on next tick)"
|
|
8146
8057
|
);
|
|
8147
8058
|
}
|
|
8148
8059
|
}
|
|
8149
|
-
// CT484: belt-and-suspenders beside shipping the
|
|
8060
|
+
// CT484: belt-and-suspenders beside shipping the companion in lockstep inside the
|
|
8150
8061
|
// artifact — if the server reports a build version that differs from this
|
|
8151
|
-
//
|
|
8152
|
-
// an npm-pinned
|
|
8153
|
-
//
|
|
8062
|
+
// companion's, warn loudly (once). This is exactly the skew that bit the M1 walk:
|
|
8063
|
+
// an npm-pinned companion lagging a from-develop server. The elimination (bundled
|
|
8064
|
+
// companion) makes it match by construction; this catches a companion run out of band.
|
|
8154
8065
|
checkVersionSkew(serverVersion) {
|
|
8155
8066
|
if (this.versionSkewWarned) return;
|
|
8156
|
-
if (!serverVersion || serverVersion ===
|
|
8067
|
+
if (!serverVersion || serverVersion === COMPANION_VERSION) return;
|
|
8157
8068
|
this.versionSkewWarned = true;
|
|
8158
8069
|
this.log.warn(
|
|
8159
|
-
{
|
|
8160
|
-
"
|
|
8070
|
+
{ companionVersion: COMPANION_VERSION, serverVersion },
|
|
8071
|
+
"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
8072
|
);
|
|
8162
8073
|
}
|
|
8163
8074
|
// Pull assignments and reconcile the live runner set against them. Re-entrancy
|
|
@@ -8180,7 +8091,7 @@ var BridgeSupervisor = class {
|
|
|
8180
8091
|
} catch (err) {
|
|
8181
8092
|
this.log.error(
|
|
8182
8093
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
8183
|
-
"
|
|
8094
|
+
"companion: assignments pull failed \u2014 check the device is still active in the cabane app"
|
|
8184
8095
|
);
|
|
8185
8096
|
this.hub.setDeviceError(err instanceof Error ? err.message : String(err));
|
|
8186
8097
|
return;
|
|
@@ -8245,21 +8156,21 @@ var BridgeSupervisor = class {
|
|
|
8245
8156
|
const credential = it.credential ?? getCredential(it.agentId);
|
|
8246
8157
|
const runConfig = parseRunConfig(
|
|
8247
8158
|
it.runConfig,
|
|
8248
|
-
(m) => this.log.warn({ agentId: it.agentId, msg: m }, "
|
|
8159
|
+
(m) => this.log.warn({ agentId: it.agentId, msg: m }, "companion: run-config")
|
|
8249
8160
|
);
|
|
8250
8161
|
const required = requiredSecretNames(runConfig.mcpServers);
|
|
8251
8162
|
const missing = required.filter((n) => !exposed.has(n));
|
|
8252
8163
|
if (!credential) {
|
|
8253
8164
|
this.log.error(
|
|
8254
8165
|
{ workspaceId, agentId: it.agentId, username: it.agentUsername },
|
|
8255
|
-
"
|
|
8166
|
+
"companion: agent assigned but no credential on this device \u2014 re-assign it in the cabane app"
|
|
8256
8167
|
);
|
|
8257
8168
|
this.removeAgent(wr, it.agentId);
|
|
8258
8169
|
this.hub.setAgent(workspaceId, {
|
|
8259
8170
|
agentId: it.agentId,
|
|
8260
8171
|
username: it.agentUsername,
|
|
8261
8172
|
displayName: it.agentDisplayName,
|
|
8262
|
-
mode: runConfig.
|
|
8173
|
+
mode: runConfig.hostAccess ? "full" : "none",
|
|
8263
8174
|
hasCredential: false,
|
|
8264
8175
|
missingSecrets: missing
|
|
8265
8176
|
});
|
|
@@ -8279,14 +8190,14 @@ var BridgeSupervisor = class {
|
|
|
8279
8190
|
agentId: it.agentId,
|
|
8280
8191
|
username: it.agentUsername,
|
|
8281
8192
|
displayName: it.agentDisplayName,
|
|
8282
|
-
mode: runConfig.
|
|
8193
|
+
mode: runConfig.hostAccess ? "full" : "none",
|
|
8283
8194
|
hasCredential: true,
|
|
8284
8195
|
missingSecrets: missing
|
|
8285
8196
|
});
|
|
8286
8197
|
if (missing.length > 0) {
|
|
8287
8198
|
this.log.warn(
|
|
8288
8199
|
{ workspaceId, agentId: it.agentId, missing },
|
|
8289
|
-
"
|
|
8200
|
+
"companion: agent needs secrets this device does not expose (turns using them will fail)"
|
|
8290
8201
|
);
|
|
8291
8202
|
}
|
|
8292
8203
|
}
|
|
@@ -8325,7 +8236,7 @@ var BridgeSupervisor = class {
|
|
|
8325
8236
|
drain2.kick();
|
|
8326
8237
|
this.log.info(
|
|
8327
8238
|
{ workspaceId: it.workspaceId, agentId: it.agentId, username: it.agentUsername },
|
|
8328
|
-
"
|
|
8239
|
+
"companion: running agent"
|
|
8329
8240
|
);
|
|
8330
8241
|
}
|
|
8331
8242
|
removeAgent(wr, agentId) {
|
|
@@ -8334,7 +8245,10 @@ var BridgeSupervisor = class {
|
|
|
8334
8245
|
runner.cancelDrain();
|
|
8335
8246
|
wr.agents.delete(agentId);
|
|
8336
8247
|
this.hub.removeAgent(wr.workspaceId, agentId);
|
|
8337
|
-
this.log.info(
|
|
8248
|
+
this.log.info(
|
|
8249
|
+
{ workspaceId: wr.workspaceId, agentId },
|
|
8250
|
+
"companion: stopped agent (unassigned)"
|
|
8251
|
+
);
|
|
8338
8252
|
}
|
|
8339
8253
|
buildDispatcher(ctx) {
|
|
8340
8254
|
if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
|
|
@@ -8360,12 +8274,9 @@ var BridgeSupervisor = class {
|
|
|
8360
8274
|
// CT481: register the codex adapter when this device offers codex; unset
|
|
8361
8275
|
// leaves an `openai/…` turn to fail loudly (no silent claude-code fallback).
|
|
8362
8276
|
...isCodexEnabled(this.config) ? { codexEnabled: true } : {},
|
|
8363
|
-
//
|
|
8364
|
-
// unset leaves a `cabane-native/…` turn to fail loudly (no silent fallback).
|
|
8365
|
-
...cabaneNativeApiKey() ? { cabaneNativeApiKey: cabaneNativeApiKey() } : {},
|
|
8366
|
-
// CT556: per-turn timeout watchdog windows, from the bridge's own env
|
|
8277
|
+
// CT556: per-turn timeout watchdog windows, from the companion's own env
|
|
8367
8278
|
// (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
|
|
8368
|
-
// dispatcher's baked-in defaults (10 min idle /
|
|
8279
|
+
// dispatcher's baked-in defaults (10 min idle / 6h total).
|
|
8369
8280
|
...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
|
|
8370
8281
|
...positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") !== void 0 ? { totalTimeoutMs: positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") } : {},
|
|
8371
8282
|
observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
|
|
@@ -8402,14 +8313,14 @@ var BridgeSupervisor = class {
|
|
|
8402
8313
|
this.hub.setAuthFailed(wr.workspaceId);
|
|
8403
8314
|
this.log.error(
|
|
8404
8315
|
{ workspaceId: wr.workspaceId, status, sseAgentId: wr.sseAgentId },
|
|
8405
|
-
"
|
|
8316
|
+
"companion: workspace stream auth failed \u2014 re-pulling assignments"
|
|
8406
8317
|
);
|
|
8407
8318
|
wr.sseAgentId = null;
|
|
8408
8319
|
void this.refreshAssignments();
|
|
8409
8320
|
}
|
|
8410
8321
|
});
|
|
8411
8322
|
wr.sub.start();
|
|
8412
|
-
this.log.info({ workspaceId: wr.workspaceId }, "
|
|
8323
|
+
this.log.info({ workspaceId: wr.workspaceId }, "companion: subscribed");
|
|
8413
8324
|
}
|
|
8414
8325
|
async removeWorkspace(workspaceId) {
|
|
8415
8326
|
const wr = this.workspaces.get(workspaceId);
|
|
@@ -8440,9 +8351,9 @@ var BridgeSupervisor = class {
|
|
|
8440
8351
|
return;
|
|
8441
8352
|
}
|
|
8442
8353
|
if (ev.id) wr.cursor.observe(ev.id);
|
|
8443
|
-
if (wire.type === "
|
|
8354
|
+
if (wire.type === "device:cancel_requested") {
|
|
8444
8355
|
const payload2 = {
|
|
8445
|
-
type: "
|
|
8356
|
+
type: "device:cancel_requested",
|
|
8446
8357
|
...wire.payload
|
|
8447
8358
|
};
|
|
8448
8359
|
const agent2 = wr.agents.get(payload2.agentId);
|
|
@@ -8462,12 +8373,13 @@ var BridgeSupervisor = class {
|
|
|
8462
8373
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
8463
8374
|
return;
|
|
8464
8375
|
}
|
|
8465
|
-
if (wire.type !== "
|
|
8376
|
+
if (wire.type !== "device:dispatch_requested") {
|
|
8466
8377
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
8467
8378
|
return;
|
|
8468
8379
|
}
|
|
8380
|
+
if (this.draining) return;
|
|
8469
8381
|
const payload = {
|
|
8470
|
-
type: "
|
|
8382
|
+
type: "device:dispatch_requested",
|
|
8471
8383
|
...wire.payload
|
|
8472
8384
|
};
|
|
8473
8385
|
let agent = wr.agents.get(payload.agentId);
|
|
@@ -8488,7 +8400,7 @@ var BridgeSupervisor = class {
|
|
|
8488
8400
|
agentId: payload.agentId,
|
|
8489
8401
|
err: err instanceof Error ? err.message : String(err)
|
|
8490
8402
|
},
|
|
8491
|
-
"
|
|
8403
|
+
"companion: conversation turn handler threw"
|
|
8492
8404
|
);
|
|
8493
8405
|
});
|
|
8494
8406
|
wr.chains.set(chainKey, tail);
|
|
@@ -8516,7 +8428,7 @@ var BridgeSupervisor = class {
|
|
|
8516
8428
|
if (hasCompleted(workspaceId, ev.id)) {
|
|
8517
8429
|
this.log.info(
|
|
8518
8430
|
{ workspaceId, eventId: ev.id },
|
|
8519
|
-
"
|
|
8431
|
+
"companion: skipping already-completed event (resume after restart)"
|
|
8520
8432
|
);
|
|
8521
8433
|
wr.cursor.settle(ev.id);
|
|
8522
8434
|
return;
|
|
@@ -8524,7 +8436,7 @@ var BridgeSupervisor = class {
|
|
|
8524
8436
|
if (noResume()) {
|
|
8525
8437
|
this.log.warn(
|
|
8526
8438
|
{ workspaceId, eventId: ev.id },
|
|
8527
|
-
"
|
|
8439
|
+
"companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
|
|
8528
8440
|
);
|
|
8529
8441
|
markCompleted(workspaceId, ev.id);
|
|
8530
8442
|
wr.cursor.settle(ev.id);
|
|
@@ -8534,7 +8446,7 @@ var BridgeSupervisor = class {
|
|
|
8534
8446
|
if (attempt > MAX_RESUME_ATTEMPTS) {
|
|
8535
8447
|
this.log.error(
|
|
8536
8448
|
{ workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
8537
|
-
"
|
|
8449
|
+
"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
8450
|
);
|
|
8539
8451
|
markCompleted(workspaceId, ev.id);
|
|
8540
8452
|
wr.cursor.settle(ev.id);
|
|
@@ -8542,7 +8454,7 @@ var BridgeSupervisor = class {
|
|
|
8542
8454
|
}
|
|
8543
8455
|
this.log.info(
|
|
8544
8456
|
{ workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
8545
|
-
"
|
|
8457
|
+
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
8546
8458
|
);
|
|
8547
8459
|
}
|
|
8548
8460
|
if (ev.id) markDispatched(workspaceId, ev.id);
|
|
@@ -8582,7 +8494,7 @@ var BridgeSupervisor = class {
|
|
|
8582
8494
|
drainDelay = Math.min(drainDelay * 2, DRAIN_MAX_MS);
|
|
8583
8495
|
this.log.warn(
|
|
8584
8496
|
{ agentId, err: err instanceof Error ? err.message : String(err) },
|
|
8585
|
-
"
|
|
8497
|
+
"companion: outbox drain pass threw (will retry with backoff)"
|
|
8586
8498
|
);
|
|
8587
8499
|
} finally {
|
|
8588
8500
|
if (!drainStopped) {
|
|
@@ -8651,7 +8563,7 @@ var BridgeSupervisor = class {
|
|
|
8651
8563
|
} catch (err) {
|
|
8652
8564
|
this.log.warn(
|
|
8653
8565
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
8654
|
-
"
|
|
8566
|
+
"companion: harness probe failed (will retry on next beat)"
|
|
8655
8567
|
);
|
|
8656
8568
|
}
|
|
8657
8569
|
}
|
|
@@ -8676,7 +8588,7 @@ var BridgeSupervisor = class {
|
|
|
8676
8588
|
next = { ...this.config, codex: { enabled: true } };
|
|
8677
8589
|
} else {
|
|
8678
8590
|
const serverUrl = input.serverUrl.trim();
|
|
8679
|
-
const parsed =
|
|
8591
|
+
const parsed = companionConfigSchema.shape.opencode.safeParse({ serverUrl });
|
|
8680
8592
|
if (!parsed.success) {
|
|
8681
8593
|
return {
|
|
8682
8594
|
ok: false,
|
|
@@ -8697,7 +8609,7 @@ var BridgeSupervisor = class {
|
|
|
8697
8609
|
saveConfig(next);
|
|
8698
8610
|
this.rebuildDispatchers();
|
|
8699
8611
|
await this.refreshHarnessStatuses();
|
|
8700
|
-
|
|
8612
|
+
this.kickHeartbeat();
|
|
8701
8613
|
return { ok: true };
|
|
8702
8614
|
}
|
|
8703
8615
|
// Re-create every running agent's Dispatcher from the CURRENT config, keeping
|
|
@@ -8733,6 +8645,41 @@ var BridgeSupervisor = class {
|
|
|
8733
8645
|
[...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
|
|
8734
8646
|
);
|
|
8735
8647
|
}
|
|
8648
|
+
async drainForRestart(graceMs) {
|
|
8649
|
+
this.draining = true;
|
|
8650
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
8651
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
8652
|
+
if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
|
|
8653
|
+
if (!this.deviceApi) throw new Error("cannot establish deploy drain before pairing");
|
|
8654
|
+
await this.deviceApi.beginDrain();
|
|
8655
|
+
for (const wr of this.workspaces.values()) wr.sub?.stop();
|
|
8656
|
+
const turns = [...this.workspaces.values()].flatMap((wr) => [...wr.chains.values()]);
|
|
8657
|
+
let timedOut = false;
|
|
8658
|
+
if (turns.length > 0) {
|
|
8659
|
+
let timer;
|
|
8660
|
+
await Promise.race([
|
|
8661
|
+
Promise.allSettled(turns),
|
|
8662
|
+
new Promise((resolve) => {
|
|
8663
|
+
timer = setTimeout(
|
|
8664
|
+
() => {
|
|
8665
|
+
timedOut = true;
|
|
8666
|
+
resolve();
|
|
8667
|
+
},
|
|
8668
|
+
Math.max(0, graceMs)
|
|
8669
|
+
);
|
|
8670
|
+
timer.unref?.();
|
|
8671
|
+
})
|
|
8672
|
+
]);
|
|
8673
|
+
if (timer) clearTimeout(timer);
|
|
8674
|
+
}
|
|
8675
|
+
await Promise.allSettled(
|
|
8676
|
+
[...this.workspaces.values()].flatMap(
|
|
8677
|
+
(wr) => [...wr.agents.values()].map((agent) => agent.api.drainOutbox())
|
|
8678
|
+
)
|
|
8679
|
+
);
|
|
8680
|
+
await this.shutdown();
|
|
8681
|
+
return { drained: !timedOut };
|
|
8682
|
+
}
|
|
8736
8683
|
async requestStop() {
|
|
8737
8684
|
await this.shutdown();
|
|
8738
8685
|
this.exitFn(0);
|
|
@@ -8804,25 +8751,25 @@ function handleUncaught(log, err, origin) {
|
|
|
8804
8751
|
if (isRecoverableSocketError(err)) {
|
|
8805
8752
|
log.warn(
|
|
8806
8753
|
{ origin, code, err: message },
|
|
8807
|
-
"
|
|
8754
|
+
"companion: recovered from a socket error (kept running \u2014 a broken pipe never kills the companion)"
|
|
8808
8755
|
);
|
|
8809
8756
|
return;
|
|
8810
8757
|
}
|
|
8811
8758
|
log.error(
|
|
8812
8759
|
{ origin, code, err: message, stack: err instanceof Error ? err.stack : void 0 },
|
|
8813
|
-
"
|
|
8760
|
+
"companion: uncaught error (kept running \u2014 see the stack above)"
|
|
8814
8761
|
);
|
|
8815
8762
|
}
|
|
8816
8763
|
|
|
8817
8764
|
// src/crash-marker.ts
|
|
8818
|
-
import { existsSync as existsSync11, mkdirSync as
|
|
8819
|
-
import { join as
|
|
8765
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
|
|
8766
|
+
import { join as join14 } from "path";
|
|
8820
8767
|
function crashMarkerPath() {
|
|
8821
|
-
return
|
|
8768
|
+
return join14(cabaneDir(), "last-error.json");
|
|
8822
8769
|
}
|
|
8823
8770
|
function recordCrash(rec) {
|
|
8824
8771
|
try {
|
|
8825
|
-
|
|
8772
|
+
mkdirSync11(cabaneDir(), { recursive: true });
|
|
8826
8773
|
writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
|
|
8827
8774
|
} catch {
|
|
8828
8775
|
}
|
|
@@ -8836,7 +8783,7 @@ function clearCrash() {
|
|
|
8836
8783
|
}
|
|
8837
8784
|
|
|
8838
8785
|
// src/runtime.ts
|
|
8839
|
-
async function
|
|
8786
|
+
async function createCompanionRuntime(opts = {}) {
|
|
8840
8787
|
const log = getLogger();
|
|
8841
8788
|
installProcessSafetyNet(log);
|
|
8842
8789
|
const probeClaude = opts.probeClaude ?? claudeOnPath;
|
|
@@ -8870,23 +8817,29 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
8870
8817
|
url: "",
|
|
8871
8818
|
port: 0,
|
|
8872
8819
|
startedAt,
|
|
8873
|
-
daemon: process.env.
|
|
8820
|
+
daemon: process.env.CABANE_COMPANION_DAEMON === "1",
|
|
8874
8821
|
instanceId
|
|
8875
8822
|
});
|
|
8876
8823
|
if (!claim.acquired) {
|
|
8877
8824
|
return { ok: false, reason: "already-running", existing: claim.existing ?? null };
|
|
8878
8825
|
}
|
|
8879
8826
|
process.on("exit", () => clearRuntimeState());
|
|
8880
|
-
const hub = new
|
|
8827
|
+
const hub = new CompanionStateHub({
|
|
8881
8828
|
// CT29: one device, one base URL — the cabane instance this device is paired
|
|
8882
8829
|
// with. The dashboard's connection line shows it.
|
|
8883
8830
|
baseUrl: cfg.baseUrl,
|
|
8884
|
-
|
|
8831
|
+
companionVersion: COMPANION_VERSION,
|
|
8885
8832
|
// SJ516 F4: surfaced on `/api/status` so `stop`/`status` can confirm the
|
|
8886
|
-
// process behind the marker pid is this
|
|
8833
|
+
// process behind the marker pid is this companion (not a recycled pid).
|
|
8887
8834
|
instanceId
|
|
8888
8835
|
});
|
|
8889
|
-
const supervisor = new
|
|
8836
|
+
const supervisor = new CompanionSupervisor({
|
|
8837
|
+
config: cfg,
|
|
8838
|
+
log,
|
|
8839
|
+
hub,
|
|
8840
|
+
claudeCode,
|
|
8841
|
+
harnessVersions
|
|
8842
|
+
});
|
|
8890
8843
|
await supervisor.start();
|
|
8891
8844
|
const preferredPort = opts.port ?? cfg.dashboardPort;
|
|
8892
8845
|
const dashboard = await startDashboard({
|
|
@@ -8901,9 +8854,9 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
8901
8854
|
port: dashboard.port,
|
|
8902
8855
|
startedAt,
|
|
8903
8856
|
// SJ495: the daemon launcher sets this env on the detached child, so the
|
|
8904
|
-
// marker records whether this
|
|
8857
|
+
// marker records whether this companion is backgrounded (foreground start
|
|
8905
8858
|
// leaves it unset → false).
|
|
8906
|
-
daemon: process.env.
|
|
8859
|
+
daemon: process.env.CABANE_COMPANION_DAEMON === "1",
|
|
8907
8860
|
instanceId
|
|
8908
8861
|
});
|
|
8909
8862
|
clearCrash();
|
|
@@ -8920,9 +8873,21 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
8920
8873
|
};
|
|
8921
8874
|
return {
|
|
8922
8875
|
ok: true,
|
|
8923
|
-
runtime: {
|
|
8876
|
+
runtime: {
|
|
8877
|
+
url: dashboard.url,
|
|
8878
|
+
port: dashboard.port,
|
|
8879
|
+
config: cfg,
|
|
8880
|
+
stop,
|
|
8881
|
+
drainForRestart: async (graceMs) => {
|
|
8882
|
+
clearRuntimeState();
|
|
8883
|
+
const result = await supervisor.drainForRestart(graceMs);
|
|
8884
|
+
await dashboard.close();
|
|
8885
|
+
stopped = true;
|
|
8886
|
+
return result;
|
|
8887
|
+
}
|
|
8888
|
+
}
|
|
8924
8889
|
};
|
|
8925
8890
|
}
|
|
8926
8891
|
export {
|
|
8927
|
-
|
|
8892
|
+
createCompanionRuntime
|
|
8928
8893
|
};
|