@cabane/companion 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.js +1667 -570
- package/dist/pairing-config.js +38 -33
- package/dist/runtime.js +1547 -477
- package/dist/static/app.js +22 -19
- package/dist/static/index.html +6 -6
- package/dist/static/styles.css +1 -1
- package/package.json +2 -3
package/dist/cli.js
CHANGED
|
@@ -23,13 +23,13 @@ import { dirname, join } from "path";
|
|
|
23
23
|
import { z as z3 } from "zod";
|
|
24
24
|
|
|
25
25
|
// src/errors.ts
|
|
26
|
-
var
|
|
26
|
+
var CompanionError = class extends Error {
|
|
27
27
|
constructor(message) {
|
|
28
28
|
super(message);
|
|
29
|
-
this.name = "
|
|
29
|
+
this.name = "CompanionError";
|
|
30
30
|
}
|
|
31
31
|
};
|
|
32
|
-
var ApiError = class extends
|
|
32
|
+
var ApiError = class extends CompanionError {
|
|
33
33
|
constructor(status2, message, body) {
|
|
34
34
|
super(message);
|
|
35
35
|
this.status = status2;
|
|
@@ -39,13 +39,13 @@ var ApiError = class extends BridgeError {
|
|
|
39
39
|
status;
|
|
40
40
|
body;
|
|
41
41
|
};
|
|
42
|
-
var ConfigError = class extends
|
|
42
|
+
var ConfigError = class extends CompanionError {
|
|
43
43
|
constructor(message) {
|
|
44
44
|
super(message);
|
|
45
45
|
this.name = "ConfigError";
|
|
46
46
|
}
|
|
47
47
|
};
|
|
48
|
-
var PrepareHookError = class extends
|
|
48
|
+
var PrepareHookError = class extends CompanionError {
|
|
49
49
|
constructor(message) {
|
|
50
50
|
super(message);
|
|
51
51
|
this.name = "PrepareHookError";
|
|
@@ -76,12 +76,12 @@ var pairingSchema = z.object({
|
|
|
76
76
|
baseUrl: z.string().url().refine(isAllowedBaseUrl, {
|
|
77
77
|
message: "baseUrl must be https (loopback http is allowed for local dev only)"
|
|
78
78
|
}),
|
|
79
|
-
// The `cabdev_` device token plaintext — the
|
|
79
|
+
// The `cabdev_` device token plaintext — the companion's one durable credential.
|
|
80
80
|
deviceToken: z.string().min(1).startsWith(DEVICE_TOKEN_PREFIX, {
|
|
81
81
|
message: 'deviceToken must be a cabane device token (starts with "cabdev_")'
|
|
82
82
|
}),
|
|
83
83
|
// Optional identity hints the app may include for nicer local display. The
|
|
84
|
-
//
|
|
84
|
+
// companion also learns these from the first assignments pull, so they're not
|
|
85
85
|
// required.
|
|
86
86
|
deviceId: z.string().min(1).optional(),
|
|
87
87
|
deviceLabel: z.string().min(1).optional()
|
|
@@ -89,7 +89,7 @@ var pairingSchema = z.object({
|
|
|
89
89
|
function decodePairing(raw) {
|
|
90
90
|
const cleaned = raw.replace(/\s+/g, "");
|
|
91
91
|
if (cleaned.length === 0) {
|
|
92
|
-
throw new
|
|
92
|
+
throw new CompanionError("empty pairing string. Copy it from the cabane app and try again.");
|
|
93
93
|
}
|
|
94
94
|
const bytes = Buffer.from(cleaned, "base64url");
|
|
95
95
|
const json = bytes.toString("utf8");
|
|
@@ -97,23 +97,23 @@ function decodePairing(raw) {
|
|
|
97
97
|
try {
|
|
98
98
|
parsed = JSON.parse(json);
|
|
99
99
|
} catch {
|
|
100
|
-
throw new
|
|
100
|
+
throw new CompanionError(
|
|
101
101
|
`that doesn't look like a complete pairing string \u2014 received ${cleaned.length} characters that decoded to ${bytes.length} bytes, but they weren't valid JSON. Copy the WHOLE block from the cabane app (a partial or truncated copy is the usual cause).`
|
|
102
102
|
);
|
|
103
103
|
}
|
|
104
104
|
if (parsed && typeof parsed === "object" && "v" in parsed && parsed.v !== PAIRING_VERSION) {
|
|
105
|
-
throw new
|
|
106
|
-
`this pairing string is version ${String(parsed.v)}, but this
|
|
105
|
+
throw new CompanionError(
|
|
106
|
+
`this pairing string is version ${String(parsed.v)}, but this companion only understands version ${PAIRING_VERSION}. Update cabane-companion (\`git pull\` + rebuild) and try again.`
|
|
107
107
|
);
|
|
108
108
|
}
|
|
109
109
|
const result = pairingSchema.safeParse(parsed);
|
|
110
110
|
if (!result.success) {
|
|
111
111
|
if (result.error.issues.some((i) => i.path[0] === "baseUrl")) {
|
|
112
|
-
throw new
|
|
113
|
-
"this pairing string points at a non-https cabane URL. The
|
|
112
|
+
throw new CompanionError(
|
|
113
|
+
"this pairing string points at a non-https cabane URL. The companion runs the server's prompt with permissions bypassed and sends its credentials over the same channel, so it refuses plaintext http (except localhost for local dev). Use an https base URL."
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
|
-
throw new
|
|
116
|
+
throw new CompanionError(
|
|
117
117
|
`the pairing string is missing or malformed fields: ${result.error.issues.map((i) => i.path.join(".") || "(root)").join(", ")}. Re-copy it from the cabane app.`
|
|
118
118
|
);
|
|
119
119
|
}
|
|
@@ -130,13 +130,18 @@ var prepareHookSchema = z2.object({
|
|
|
130
130
|
env: z2.record(z2.string(), z2.string()).optional(),
|
|
131
131
|
// Wall-clock cap for the hook. Provisioning is slow (minutes), so the
|
|
132
132
|
// default is generous; a hook that hangs past this is killed and the turn
|
|
133
|
-
// fails with a clear timeout message rather than pinning the
|
|
133
|
+
// fails with a clear timeout message rather than pinning the companion.
|
|
134
134
|
timeoutMs: z2.number().int().positive().optional()
|
|
135
135
|
}).strict();
|
|
136
136
|
var DEFAULT_TIMEOUT_MS = 10 * 6e4;
|
|
137
137
|
var prepareResultSchema = z2.object({
|
|
138
138
|
cwd: z2.string().min(1),
|
|
139
|
-
env: z2.record(z2.string(), z2.string()).optional()
|
|
139
|
+
env: z2.record(z2.string(), z2.string()).optional(),
|
|
140
|
+
nativeWorkAssignment: z2.object({
|
|
141
|
+
itemId: z2.string(),
|
|
142
|
+
executionId: z2.string(),
|
|
143
|
+
activationEpoch: z2.number().int().nonnegative()
|
|
144
|
+
}).strict().optional()
|
|
140
145
|
});
|
|
141
146
|
function parsePrepareOutput(stdout) {
|
|
142
147
|
const last = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).at(-1);
|
|
@@ -155,10 +160,14 @@ function parsePrepareOutput(stdout) {
|
|
|
155
160
|
const r = prepareResultSchema.safeParse(parsed);
|
|
156
161
|
if (!r.success) {
|
|
157
162
|
throw new PrepareHookError(
|
|
158
|
-
'prepare hook JSON must carry a non-empty string "cwd" (
|
|
163
|
+
'prepare hook JSON must carry a non-empty string "cwd" (plus optional "env" or "nativeWorkAssignment")'
|
|
159
164
|
);
|
|
160
165
|
}
|
|
161
|
-
return {
|
|
166
|
+
return {
|
|
167
|
+
cwd: r.data.cwd,
|
|
168
|
+
...r.data.env ? { env: r.data.env } : {},
|
|
169
|
+
...r.data.nativeWorkAssignment ? { nativeWorkAssignment: r.data.nativeWorkAssignment } : {}
|
|
170
|
+
};
|
|
162
171
|
}
|
|
163
172
|
return { cwd: last };
|
|
164
173
|
}
|
|
@@ -183,6 +192,7 @@ var runPrepareHook = (hook, input) => {
|
|
|
183
192
|
CABANE_CONVERSATION_ID: input.conversationId,
|
|
184
193
|
CABANE_AGENT_ID: input.agentId,
|
|
185
194
|
CABANE_AGENT_USERNAME: input.agentUsername,
|
|
195
|
+
CABANE_RUNTIME: input.runtime ?? "",
|
|
186
196
|
// CT319: the conversation anchor is gone. These are kept as DEPRECATED
|
|
187
197
|
// back-compat constants so an old user prepare script that still reads
|
|
188
198
|
// them doesn't crash; a hook should key off CABANE_TRIGGER_ENTRY_PATHS.
|
|
@@ -194,10 +204,10 @@ var runPrepareHook = (hook, input) => {
|
|
|
194
204
|
CABANE_CONVERSATION_TITLE: input.title ?? ""
|
|
195
205
|
}
|
|
196
206
|
});
|
|
197
|
-
} catch (
|
|
207
|
+
} catch (err2) {
|
|
198
208
|
reject(
|
|
199
209
|
new PrepareHookError(
|
|
200
|
-
`prepare hook failed to start: ${
|
|
210
|
+
`prepare hook failed to start: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
201
211
|
)
|
|
202
212
|
);
|
|
203
213
|
return;
|
|
@@ -217,8 +227,8 @@ var runPrepareHook = (hook, input) => {
|
|
|
217
227
|
child.stderr?.on("data", (d) => {
|
|
218
228
|
stderr += d.toString();
|
|
219
229
|
});
|
|
220
|
-
child.on("error", (
|
|
221
|
-
finish(() => reject(new PrepareHookError(`prepare hook failed to start: ${
|
|
230
|
+
child.on("error", (err2) => {
|
|
231
|
+
finish(() => reject(new PrepareHookError(`prepare hook failed to start: ${err2.message}`)));
|
|
222
232
|
});
|
|
223
233
|
child.on("close", (code) => {
|
|
224
234
|
finish(() => {
|
|
@@ -231,8 +241,8 @@ var runPrepareHook = (hook, input) => {
|
|
|
231
241
|
}
|
|
232
242
|
try {
|
|
233
243
|
resolve(parsePrepareOutput(stdout));
|
|
234
|
-
} catch (
|
|
235
|
-
reject(
|
|
244
|
+
} catch (err2) {
|
|
245
|
+
reject(err2 instanceof PrepareHookError ? err2 : new PrepareHookError(String(err2)));
|
|
236
246
|
}
|
|
237
247
|
});
|
|
238
248
|
});
|
|
@@ -257,12 +267,12 @@ function realAccountHome() {
|
|
|
257
267
|
return realHomeCache;
|
|
258
268
|
}
|
|
259
269
|
function cabaneDir() {
|
|
260
|
-
const dir2 = join(process.env.
|
|
270
|
+
const dir2 = join(process.env.CABANE_COMPANION_HOME || homedir(), ".cabane");
|
|
261
271
|
if (process.env.VITEST) {
|
|
262
272
|
const real = realAccountHome();
|
|
263
273
|
if (real && dir2 === join(real, ".cabane")) {
|
|
264
274
|
throw new Error(
|
|
265
|
-
`cabaneDir() resolved to the real ${dir2} during a test run.
|
|
275
|
+
`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).`
|
|
266
276
|
);
|
|
267
277
|
}
|
|
268
278
|
}
|
|
@@ -275,22 +285,22 @@ var localAgentConfigSchema = z3.object({
|
|
|
275
285
|
cwd: z3.string().optional(),
|
|
276
286
|
prepareHook: prepareHookSchema.optional(),
|
|
277
287
|
// CT289: the Claude Code auto-memory escape hatch. Auto-memory is forced OFF
|
|
278
|
-
// by default on every
|
|
279
|
-
//
|
|
280
|
-
// On a
|
|
288
|
+
// by default on every companion (memory belongs in the Cabane workspace, and a
|
|
289
|
+
// shared companion would otherwise pool one cwd-keyed memory dir across users).
|
|
290
|
+
// On a companion you run yourself, set `claudeCode: { autoMemory: true }` to hand
|
|
281
291
|
// auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
|
|
282
292
|
// injecting the off switch and your normal Claude Code memory workflow applies
|
|
283
293
|
// (in coding mode, where the checkout's project settings are read).
|
|
284
294
|
claudeCode: z3.object({ autoMemory: z3.boolean().optional() }).strict().optional()
|
|
285
295
|
}).strict();
|
|
286
|
-
var
|
|
296
|
+
var companionConfigSchema = z3.object({
|
|
287
297
|
// The cabane instance this device is paired with. SJ515: https-enforced
|
|
288
298
|
// (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
|
|
289
299
|
// base URL onto the MITM-able channel the device token + prompt ride.
|
|
290
300
|
baseUrl: z3.string().url().refine(isAllowedBaseUrl, {
|
|
291
301
|
message: "baseUrl must be https (loopback http is allowed for local dev only)"
|
|
292
302
|
}),
|
|
293
|
-
// The `cabdev_` device token plaintext — the
|
|
303
|
+
// The `cabdev_` device token plaintext — the companion's one durable credential,
|
|
294
304
|
// presented as `Authorization: Bearer <token>` on the device-facing pull +
|
|
295
305
|
// heartbeat endpoints. Optional so `cabane-companion logout` can strip it (a
|
|
296
306
|
// "paired but logged out" state the supervisor refuses to run) while keeping
|
|
@@ -301,19 +311,19 @@ var bridgeConfigSchema = z3.object({
|
|
|
301
311
|
deviceId: z3.string().optional(),
|
|
302
312
|
deviceLabel: z3.string().optional(),
|
|
303
313
|
// Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
|
|
304
|
-
// agentId / username / `slug/username`. Hand-added by the operator; the
|
|
314
|
+
// agentId / username / `slug/username`. Hand-added by the operator; the companion
|
|
305
315
|
// never writes this (it only persists credentials + the device, elsewhere).
|
|
306
316
|
agents: z3.record(z3.string(), localAgentConfigSchema).optional(),
|
|
307
317
|
// Dashboard settings (all optional). dashboardPort: preferred bind port (next
|
|
308
318
|
// free one if taken); autoOpen: whether `start` opens the browser (the
|
|
309
|
-
// `--no-open` flag / `
|
|
319
|
+
// `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
|
|
310
320
|
// level, live-editable from the dashboard settings panel.
|
|
311
321
|
dashboardPort: z3.number().int().min(1).max(65535).optional(),
|
|
312
322
|
autoOpen: z3.boolean().optional(),
|
|
313
323
|
logLevel: z3.enum(["warn", "info", "debug"]).optional(),
|
|
314
324
|
// CT270: the opencode runtime, when the operator runs one on this machine. The
|
|
315
325
|
// operator installs opencode, starts `opencode serve` (auth via opencode's own
|
|
316
|
-
// `/connect` — Cabane never sees provider keys), and points the
|
|
326
|
+
// `/connect` — Cabane never sees provider keys), and points the companion at it
|
|
317
327
|
// here. Setting this makes the device advertise the `opencode` runtime on its
|
|
318
328
|
// heartbeat manifest (so the server offers DeepSeek/opencode models here and
|
|
319
329
|
// routes those turns to this device) AND registers the opencode adapter in the
|
|
@@ -354,21 +364,21 @@ function loadConfig() {
|
|
|
354
364
|
let raw;
|
|
355
365
|
try {
|
|
356
366
|
raw = readFileSync(path3, "utf8");
|
|
357
|
-
} catch (
|
|
367
|
+
} catch (err2) {
|
|
358
368
|
throw new ConfigError(
|
|
359
|
-
`couldn't read ${path3}: ${
|
|
369
|
+
`couldn't read ${path3}: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
360
370
|
);
|
|
361
371
|
}
|
|
362
372
|
if (raw.trim().length === 0) return null;
|
|
363
373
|
let parsed;
|
|
364
374
|
try {
|
|
365
375
|
parsed = JSON.parse(raw);
|
|
366
|
-
} catch (
|
|
376
|
+
} catch (err2) {
|
|
367
377
|
throw new ConfigError(
|
|
368
|
-
`${path3} is not valid JSON: ${
|
|
378
|
+
`${path3} is not valid JSON: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
369
379
|
);
|
|
370
380
|
}
|
|
371
|
-
const result =
|
|
381
|
+
const result = companionConfigSchema.safeParse(parsed);
|
|
372
382
|
if (!result.success) {
|
|
373
383
|
const agentIssue = result.error.issues.find((i) => i.path[0] === "agents");
|
|
374
384
|
if (agentIssue) {
|
|
@@ -377,7 +387,7 @@ function loadConfig() {
|
|
|
377
387
|
);
|
|
378
388
|
}
|
|
379
389
|
throw new ConfigError(
|
|
380
|
-
`${path3} is from an incompatible or older version of the
|
|
390
|
+
`${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.`
|
|
381
391
|
);
|
|
382
392
|
}
|
|
383
393
|
return result.data;
|
|
@@ -399,7 +409,7 @@ function loadConfigTolerant() {
|
|
|
399
409
|
} catch {
|
|
400
410
|
return { local: empty, note: `${path3} was unreadable (invalid JSON) and has been reset.` };
|
|
401
411
|
}
|
|
402
|
-
const strict =
|
|
412
|
+
const strict = companionConfigSchema.safeParse(parsed);
|
|
403
413
|
if (strict.success) {
|
|
404
414
|
const { agents: agents2, dashboardPort, autoOpen, logLevel } = strict.data;
|
|
405
415
|
return {
|
|
@@ -423,7 +433,7 @@ function loadConfigTolerant() {
|
|
|
423
433
|
}
|
|
424
434
|
return {
|
|
425
435
|
local,
|
|
426
|
-
note: `the existing ${path3} was from an older or incompatible
|
|
436
|
+
note: `the existing ${path3} was from an older or incompatible companion; re-pairing rewrote it.`
|
|
427
437
|
};
|
|
428
438
|
}
|
|
429
439
|
function saveConfig(cfg) {
|
|
@@ -441,19 +451,19 @@ function saveConfig(cfg) {
|
|
|
441
451
|
} catch {
|
|
442
452
|
}
|
|
443
453
|
renameSync(tmp, path3);
|
|
444
|
-
} catch (
|
|
454
|
+
} catch (err2) {
|
|
445
455
|
try {
|
|
446
456
|
rmSync(tmp, { force: true });
|
|
447
457
|
} catch {
|
|
448
458
|
}
|
|
449
|
-
throw
|
|
459
|
+
throw err2;
|
|
450
460
|
}
|
|
451
461
|
}
|
|
452
462
|
function requireConfig() {
|
|
453
463
|
const cfg = loadConfig();
|
|
454
464
|
if (!cfg) {
|
|
455
465
|
throw new ConfigError(
|
|
456
|
-
"this
|
|
466
|
+
"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."
|
|
457
467
|
);
|
|
458
468
|
}
|
|
459
469
|
return cfg;
|
|
@@ -470,8 +480,8 @@ import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
|
|
|
470
480
|
import { dirname as dirname2, join as join2 } from "path";
|
|
471
481
|
import pino from "pino";
|
|
472
482
|
import pretty from "pino-pretty";
|
|
473
|
-
function
|
|
474
|
-
return join2(cabaneDir(), "
|
|
483
|
+
function companionLogPath() {
|
|
484
|
+
return join2(cabaneDir(), "companion.log");
|
|
475
485
|
}
|
|
476
486
|
var CONSOLE_IGNORE = [
|
|
477
487
|
"pid",
|
|
@@ -481,7 +491,7 @@ var CONSOLE_IGNORE = [
|
|
|
481
491
|
"agentId",
|
|
482
492
|
"messageId",
|
|
483
493
|
"sessionId",
|
|
484
|
-
"
|
|
494
|
+
"companionId"
|
|
485
495
|
].join(",");
|
|
486
496
|
function consoleShortId(log) {
|
|
487
497
|
const id = log.conversationId ?? log.workspaceId;
|
|
@@ -495,10 +505,10 @@ function consoleMessageFormat(log, messageKey) {
|
|
|
495
505
|
var cached = null;
|
|
496
506
|
function getLogger() {
|
|
497
507
|
if (cached) return cached;
|
|
498
|
-
const path3 =
|
|
508
|
+
const path3 = companionLogPath();
|
|
499
509
|
mkdirSync2(dirname2(path3), { recursive: true });
|
|
500
510
|
const streams = [];
|
|
501
|
-
if (process.env.
|
|
511
|
+
if (process.env.CABANE_COMPANION_DAEMON !== "1") {
|
|
502
512
|
const consoleStream = pretty({
|
|
503
513
|
colorize: true,
|
|
504
514
|
ignore: CONSOLE_IGNORE,
|
|
@@ -548,8 +558,8 @@ async function ensureRuntimeAvailable(cfg, deps = {}) {
|
|
|
548
558
|
);
|
|
549
559
|
return;
|
|
550
560
|
}
|
|
551
|
-
throw new
|
|
552
|
-
"Claude Code, the
|
|
561
|
+
throw new CompanionError(
|
|
562
|
+
"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)."
|
|
553
563
|
);
|
|
554
564
|
}
|
|
555
565
|
|
|
@@ -621,8 +631,8 @@ async function verifyRuntime(state, fetchImpl = fetch) {
|
|
|
621
631
|
let res;
|
|
622
632
|
try {
|
|
623
633
|
res = await fetchImpl(url, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
624
|
-
} catch (
|
|
625
|
-
return isConnRefused(
|
|
634
|
+
} catch (err2) {
|
|
635
|
+
return isConnRefused(err2) ? "stale" : "unknown";
|
|
626
636
|
}
|
|
627
637
|
if (!res.ok) return "unknown";
|
|
628
638
|
let body;
|
|
@@ -634,9 +644,9 @@ async function verifyRuntime(state, fetchImpl = fetch) {
|
|
|
634
644
|
if (typeof body.instance_id !== "string") return "unknown";
|
|
635
645
|
return body.instance_id === state.instanceId ? "ours" : "stale";
|
|
636
646
|
}
|
|
637
|
-
function isConnRefused(
|
|
638
|
-
if (!
|
|
639
|
-
const cause =
|
|
647
|
+
function isConnRefused(err2) {
|
|
648
|
+
if (!err2 || typeof err2 !== "object") return false;
|
|
649
|
+
const cause = err2.cause;
|
|
640
650
|
return !!cause && typeof cause === "object" && cause.code === "ECONNREFUSED";
|
|
641
651
|
}
|
|
642
652
|
function trimSlash(s) {
|
|
@@ -660,7 +670,7 @@ async function startDaemon(opts = {}, deps = {}) {
|
|
|
660
670
|
if (existing) {
|
|
661
671
|
if (await verify(existing) !== "stale") {
|
|
662
672
|
process.stdout.write(
|
|
663
|
-
`Cabane
|
|
673
|
+
`Cabane Companion is already running (pid ${existing.pid}).
|
|
664
674
|
\u2192 Dashboard: ${existing.url}
|
|
665
675
|
Stop it first with \`cabane-companion stop\` if you want to relaunch.
|
|
666
676
|
`
|
|
@@ -682,17 +692,17 @@ Stop it first with \`cabane-companion stop\` if you want to relaunch.
|
|
|
682
692
|
}
|
|
683
693
|
if (!ready(state)) {
|
|
684
694
|
process.stdout.write(
|
|
685
|
-
`Cabane
|
|
686
|
-
Check ${
|
|
695
|
+
`Cabane Companion was launched (pid ${child.pid ?? "?"}) but didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s.
|
|
696
|
+
Check ${companionLogPath()} for errors, or run \`cabane-companion status\`.
|
|
687
697
|
`
|
|
688
698
|
);
|
|
689
699
|
process.exitCode = 1;
|
|
690
700
|
return;
|
|
691
701
|
}
|
|
692
702
|
process.stdout.write(
|
|
693
|
-
`Cabane
|
|
703
|
+
`Cabane Companion started in the background (pid ${state.pid}).
|
|
694
704
|
\u2192 Dashboard: ${state.url}
|
|
695
|
-
Logs: ${
|
|
705
|
+
Logs: ${companionLogPath()}
|
|
696
706
|
Status: cabane-companion status
|
|
697
707
|
Stop: cabane-companion stop
|
|
698
708
|
`
|
|
@@ -701,12 +711,12 @@ Stop: cabane-companion stop
|
|
|
701
711
|
function defaultSpawnDetached(args) {
|
|
702
712
|
const cliPath = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
703
713
|
mkdirSync4(cabaneDir(), { recursive: true });
|
|
704
|
-
const logFd = openSync2(
|
|
714
|
+
const logFd = openSync2(companionLogPath(), "a");
|
|
705
715
|
try {
|
|
706
716
|
return spawn3(process.execPath, [cliPath, ...args], {
|
|
707
717
|
detached: true,
|
|
708
718
|
stdio: ["ignore", logFd, logFd],
|
|
709
|
-
env: { ...process.env,
|
|
719
|
+
env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
|
|
710
720
|
});
|
|
711
721
|
} finally {
|
|
712
722
|
closeSync2(logFd);
|
|
@@ -764,12 +774,12 @@ function save(map) {
|
|
|
764
774
|
} catch {
|
|
765
775
|
}
|
|
766
776
|
renameSync2(tmp, path3);
|
|
767
|
-
} catch (
|
|
777
|
+
} catch (err2) {
|
|
768
778
|
try {
|
|
769
779
|
rmSync3(tmp, { force: true });
|
|
770
780
|
} catch {
|
|
771
781
|
}
|
|
772
|
-
throw
|
|
782
|
+
throw err2;
|
|
773
783
|
}
|
|
774
784
|
}
|
|
775
785
|
function getCredential(agentId) {
|
|
@@ -807,14 +817,14 @@ async function logout(opts = {}) {
|
|
|
807
817
|
return;
|
|
808
818
|
}
|
|
809
819
|
if (!opts.yes) {
|
|
810
|
-
const message = opts.purge ? "Purge the entire local
|
|
811
|
-
const
|
|
812
|
-
if (!
|
|
820
|
+
const message = opts.purge ? "Purge the entire local companion config (device + agent overrides + settings)?" : "Log out this device (removes the device token + cached agent credentials; keeps your config)?";
|
|
821
|
+
const ok2 = await confirm({ message, default: false });
|
|
822
|
+
if (!ok2) {
|
|
813
823
|
process.stdout.write("cancelled\n");
|
|
814
824
|
return;
|
|
815
825
|
}
|
|
816
826
|
}
|
|
817
|
-
const serverNote = `the device remains registered server-side \u2014 remove it in the cabane app (Settings \u2192 Agents \u2192
|
|
827
|
+
const serverNote = `the device remains registered server-side \u2014 remove it in the cabane app (Settings \u2192 Agents \u2192 Companions) if you want it gone there too.`;
|
|
818
828
|
if (opts.purge) {
|
|
819
829
|
deleteConfig();
|
|
820
830
|
clearCredentials();
|
|
@@ -826,7 +836,7 @@ async function logout(opts = {}) {
|
|
|
826
836
|
saveConfig(rest);
|
|
827
837
|
clearCredentials();
|
|
828
838
|
process.stdout.write(
|
|
829
|
-
`Logged out \u2014 removed the device token and cached agent credentials, kept your
|
|
839
|
+
`Logged out \u2014 removed the device token and cached agent credentials, kept your companion config. Re-pair with \`cabane-companion pair\` (agent credentials are re-delivered when the agents are re-assigned to this device). NOTE: ${serverNote}
|
|
830
840
|
`
|
|
831
841
|
);
|
|
832
842
|
}
|
|
@@ -847,9 +857,9 @@ async function postJson(baseUrl, path3, body) {
|
|
|
847
857
|
headers: { "content-type": "application/json", accept: "application/json" },
|
|
848
858
|
body: JSON.stringify(body)
|
|
849
859
|
});
|
|
850
|
-
} catch (
|
|
851
|
-
throw new
|
|
852
|
-
`couldn't reach cabane at ${baseUrl}: ${
|
|
860
|
+
} catch (err2) {
|
|
861
|
+
throw new CompanionError(
|
|
862
|
+
`couldn't reach cabane at ${baseUrl}: ${err2 instanceof Error ? err2.message : String(err2)}. Check the server URL (pass --server <url>) and your connection.`
|
|
853
863
|
);
|
|
854
864
|
}
|
|
855
865
|
const raw = await res.text();
|
|
@@ -863,7 +873,7 @@ async function postJson(baseUrl, path3, body) {
|
|
|
863
873
|
}
|
|
864
874
|
if (res.status >= 400) {
|
|
865
875
|
if (res.status === 404 && path3.endsWith("/code")) {
|
|
866
|
-
throw new
|
|
876
|
+
throw new CompanionError(
|
|
867
877
|
`this cabane server (${baseUrl}) doesn't support device-flow pairing yet. Update the server, or use \`cabane-companion pair --legacy\` with a pairing string from the app.`
|
|
868
878
|
);
|
|
869
879
|
}
|
|
@@ -873,16 +883,16 @@ async function postJson(baseUrl, path3, body) {
|
|
|
873
883
|
return parsed;
|
|
874
884
|
}
|
|
875
885
|
function requestEnrollmentCode(baseUrl) {
|
|
876
|
-
return postJson(baseUrl, "/api/
|
|
886
|
+
return postJson(baseUrl, "/api/device-enrollment/code", {});
|
|
877
887
|
}
|
|
878
888
|
function pollOnce(baseUrl, deviceCode) {
|
|
879
|
-
return postJson(baseUrl, "/api/
|
|
889
|
+
return postJson(baseUrl, "/api/device-enrollment/poll", { deviceCode });
|
|
880
890
|
}
|
|
881
891
|
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
882
892
|
async function runDeviceFlow(baseUrl, print, opts = {}) {
|
|
883
893
|
const now = opts.now ?? (() => Date.now());
|
|
884
894
|
const wait = opts.sleepMs ?? sleep;
|
|
885
|
-
if (opts.signal?.aborted) throw new
|
|
895
|
+
if (opts.signal?.aborted) throw new CompanionError("pairing was cancelled.");
|
|
886
896
|
const code = await requestEnrollmentCode(baseUrl);
|
|
887
897
|
if (opts.onCode) await opts.onCode(code);
|
|
888
898
|
print("");
|
|
@@ -897,11 +907,11 @@ async function runDeviceFlow(baseUrl, print, opts = {}) {
|
|
|
897
907
|
let intervalMs = Math.max(1, code.interval) * 1e3;
|
|
898
908
|
while (now() < deadline) {
|
|
899
909
|
await wait(intervalMs);
|
|
900
|
-
if (opts.signal?.aborted) throw new
|
|
910
|
+
if (opts.signal?.aborted) throw new CompanionError("pairing was cancelled.");
|
|
901
911
|
const res = await pollOnce(baseUrl, code.deviceCode);
|
|
902
912
|
if (res.status === "complete") {
|
|
903
913
|
if (!res.deviceToken || !res.baseUrl) {
|
|
904
|
-
throw new
|
|
914
|
+
throw new CompanionError("the server reported the pairing complete but returned no token.");
|
|
905
915
|
}
|
|
906
916
|
return {
|
|
907
917
|
baseUrl: res.baseUrl,
|
|
@@ -911,13 +921,13 @@ async function runDeviceFlow(baseUrl, print, opts = {}) {
|
|
|
911
921
|
};
|
|
912
922
|
}
|
|
913
923
|
if (res.status === "expired") {
|
|
914
|
-
throw new
|
|
924
|
+
throw new CompanionError(
|
|
915
925
|
"this pairing code expired before it was confirmed. Run `cabane-companion pair` again for a fresh one."
|
|
916
926
|
);
|
|
917
927
|
}
|
|
918
928
|
if (res.status === "slow_down") intervalMs += 1e3;
|
|
919
929
|
}
|
|
920
|
-
throw new
|
|
930
|
+
throw new CompanionError(
|
|
921
931
|
"this pairing code expired before it was confirmed. Run `cabane-companion pair` again for a fresh one."
|
|
922
932
|
);
|
|
923
933
|
}
|
|
@@ -927,7 +937,7 @@ var DEFAULT_BASE_URL = "https://cabane.ai";
|
|
|
927
937
|
function resolvePairBaseUrl(server) {
|
|
928
938
|
const raw = (server ?? process.env.CABANE_BASE_URL ?? DEFAULT_BASE_URL).trim();
|
|
929
939
|
if (!isAllowedBaseUrl(raw)) {
|
|
930
|
-
throw new
|
|
940
|
+
throw new CompanionError(
|
|
931
941
|
`invalid server URL "${raw}": must be an https URL (loopback http is allowed for local dev). Pass it as \`cabane-companion pair --server https://your-cabane\`.`
|
|
932
942
|
);
|
|
933
943
|
}
|
|
@@ -958,9 +968,9 @@ async function resolvePairingString(opts) {
|
|
|
958
968
|
if (opts.file !== void 0) {
|
|
959
969
|
try {
|
|
960
970
|
return readFileSync4(opts.file, "utf8");
|
|
961
|
-
} catch (
|
|
962
|
-
throw new
|
|
963
|
-
`couldn't read the pairing string from ${opts.file}: ${
|
|
971
|
+
} catch (err2) {
|
|
972
|
+
throw new CompanionError(
|
|
973
|
+
`couldn't read the pairing string from ${opts.file}: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
964
974
|
);
|
|
965
975
|
}
|
|
966
976
|
}
|
|
@@ -1031,7 +1041,7 @@ function openerFor(url) {
|
|
|
1031
1041
|
function shouldAutoOpen(opts) {
|
|
1032
1042
|
if (opts.flagOpen === true) return true;
|
|
1033
1043
|
if (opts.flagOpen === false) return false;
|
|
1034
|
-
if (process.env.
|
|
1044
|
+
if (process.env.COMPANION_NO_OPEN === "1") return false;
|
|
1035
1045
|
if (opts.configAutoOpen === true) return true;
|
|
1036
1046
|
return false;
|
|
1037
1047
|
}
|
|
@@ -1070,7 +1080,7 @@ var MAX_DISPATCHES = 50;
|
|
|
1070
1080
|
function today() {
|
|
1071
1081
|
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1072
1082
|
}
|
|
1073
|
-
var
|
|
1083
|
+
var CompanionStateHub = class {
|
|
1074
1084
|
constructor(opts) {
|
|
1075
1085
|
this.opts = opts;
|
|
1076
1086
|
this.emitter.setMaxListeners(0);
|
|
@@ -1084,7 +1094,7 @@ var BridgeStateHub = class {
|
|
|
1084
1094
|
deviceLabel = null;
|
|
1085
1095
|
deviceError = null;
|
|
1086
1096
|
// CT586: the latest harness snapshot the supervisor probed, or null before the
|
|
1087
|
-
// first probe (see
|
|
1097
|
+
// first probe (see CompanionStatusJson.harnesses).
|
|
1088
1098
|
harnesses = null;
|
|
1089
1099
|
// ---- subscription (SSE) ----
|
|
1090
1100
|
on(listener) {
|
|
@@ -1276,7 +1286,7 @@ var BridgeStateHub = class {
|
|
|
1276
1286
|
})),
|
|
1277
1287
|
last_event_at_overall: lastOverall,
|
|
1278
1288
|
dashboard_url: this.dashboardUrl,
|
|
1279
|
-
|
|
1289
|
+
companion_version: this.opts.companionVersion,
|
|
1280
1290
|
instance_id: this.opts.instanceId ?? null,
|
|
1281
1291
|
harnesses: this.harnesses
|
|
1282
1292
|
};
|
|
@@ -1317,7 +1327,7 @@ function registerRoutes(app, deps) {
|
|
|
1317
1327
|
});
|
|
1318
1328
|
app.get("/api/logs", async (c) => {
|
|
1319
1329
|
const lines = clampLimit(c.req.query("lines"), 200, 1e3);
|
|
1320
|
-
return c.json({ lines: tailFile(
|
|
1330
|
+
return c.json({ lines: tailFile(companionLogPath(), lines) });
|
|
1321
1331
|
});
|
|
1322
1332
|
app.post("/api/settings", async (c) => {
|
|
1323
1333
|
const body = await readJson(c);
|
|
@@ -1453,15 +1463,15 @@ var DEFAULT_PORT = 7474;
|
|
|
1453
1463
|
var PORT_FALLBACK_SPAN = 10;
|
|
1454
1464
|
function buildDashboardApp(deps) {
|
|
1455
1465
|
const app = new Hono();
|
|
1456
|
-
app.onError((
|
|
1457
|
-
if (
|
|
1458
|
-
const status2 =
|
|
1459
|
-
return c.json({ error:
|
|
1466
|
+
app.onError((err2, c) => {
|
|
1467
|
+
if (err2 instanceof ApiError) {
|
|
1468
|
+
const status2 = err2.status >= 400 && err2.status < 600 ? err2.status : 502;
|
|
1469
|
+
return c.json({ error: err2.message }, status2);
|
|
1460
1470
|
}
|
|
1461
|
-
if (
|
|
1462
|
-
return c.json({ error:
|
|
1471
|
+
if (err2 instanceof CompanionError) {
|
|
1472
|
+
return c.json({ error: err2.message }, 400);
|
|
1463
1473
|
}
|
|
1464
|
-
return c.json({ error:
|
|
1474
|
+
return c.json({ error: err2 instanceof Error ? err2.message : "internal error" }, 500);
|
|
1465
1475
|
});
|
|
1466
1476
|
registerRoutes(app, { ...deps, staticDir: resolveStaticDir() });
|
|
1467
1477
|
return app;
|
|
@@ -1482,15 +1492,15 @@ async function startDashboard(opts) {
|
|
|
1482
1492
|
server.closeAllConnections?.();
|
|
1483
1493
|
})
|
|
1484
1494
|
};
|
|
1485
|
-
} catch (
|
|
1486
|
-
if (isAddrInUse(
|
|
1487
|
-
lastErr =
|
|
1495
|
+
} catch (err2) {
|
|
1496
|
+
if (isAddrInUse(err2)) {
|
|
1497
|
+
lastErr = err2;
|
|
1488
1498
|
continue;
|
|
1489
1499
|
}
|
|
1490
|
-
throw
|
|
1500
|
+
throw err2;
|
|
1491
1501
|
}
|
|
1492
1502
|
}
|
|
1493
|
-
throw new
|
|
1503
|
+
throw new CompanionError(
|
|
1494
1504
|
`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)})`
|
|
1495
1505
|
);
|
|
1496
1506
|
}
|
|
@@ -1503,16 +1513,16 @@ function listen(app, port) {
|
|
|
1503
1513
|
resolve(server);
|
|
1504
1514
|
}
|
|
1505
1515
|
});
|
|
1506
|
-
server.on("error", (
|
|
1516
|
+
server.on("error", (err2) => {
|
|
1507
1517
|
if (!settled) {
|
|
1508
1518
|
settled = true;
|
|
1509
|
-
reject(
|
|
1519
|
+
reject(err2);
|
|
1510
1520
|
}
|
|
1511
1521
|
});
|
|
1512
1522
|
});
|
|
1513
1523
|
}
|
|
1514
|
-
function isAddrInUse(
|
|
1515
|
-
return Boolean(
|
|
1524
|
+
function isAddrInUse(err2) {
|
|
1525
|
+
return Boolean(err2 && typeof err2 === "object" && "code" in err2 && err2.code === "EADDRINUSE");
|
|
1516
1526
|
}
|
|
1517
1527
|
function resolveStaticDir() {
|
|
1518
1528
|
return join6(dirname4(fileURLToPath2(import.meta.url)), "static");
|
|
@@ -1655,10 +1665,10 @@ var CabaneApi = class {
|
|
|
1655
1665
|
for (let attempt = 1; ; attempt++) {
|
|
1656
1666
|
try {
|
|
1657
1667
|
return await this.attempt(method, path3, body, signal);
|
|
1658
|
-
} catch (
|
|
1659
|
-
if (attempt >= maxAttempts || signal?.aborted || !isRetryable(
|
|
1668
|
+
} catch (err2) {
|
|
1669
|
+
if (attempt >= maxAttempts || signal?.aborted || !isRetryable(err2)) throw err2;
|
|
1660
1670
|
await sleep2(RETRY_BACKOFF_MS[attempt - 1], signal);
|
|
1661
|
-
if (signal?.aborted) throw
|
|
1671
|
+
if (signal?.aborted) throw err2;
|
|
1662
1672
|
}
|
|
1663
1673
|
}
|
|
1664
1674
|
}
|
|
@@ -1686,15 +1696,15 @@ var CabaneApi = class {
|
|
|
1686
1696
|
retry: true,
|
|
1687
1697
|
...signal ? { signal } : {}
|
|
1688
1698
|
});
|
|
1689
|
-
} catch (
|
|
1699
|
+
} catch (err2) {
|
|
1690
1700
|
const outbox = this.opts.outbox;
|
|
1691
|
-
if (!outbox) throw
|
|
1692
|
-
if (signal?.aborted || isAbortError(
|
|
1693
|
-
if (!isRetryable(
|
|
1701
|
+
if (!outbox) throw err2;
|
|
1702
|
+
if (signal?.aborted || isAbortError(err2)) throw err2;
|
|
1703
|
+
if (!isRetryable(err2)) throw err2;
|
|
1694
1704
|
outbox.persist({ enqueuedAt: Date.now(), turnId, seq, method: "POST", path: path3, body, kind });
|
|
1695
1705
|
this.opts.log?.warn(
|
|
1696
|
-
{ kind, turnId, seq, err:
|
|
1697
|
-
"
|
|
1706
|
+
{ kind, turnId, seq, err: err2 instanceof Error ? err2.message : String(err2) },
|
|
1707
|
+
"companion: commit queued to outbox after transient failure (will drain when the API returns)"
|
|
1698
1708
|
);
|
|
1699
1709
|
}
|
|
1700
1710
|
}
|
|
@@ -1717,11 +1727,11 @@ var CabaneApi = class {
|
|
|
1717
1727
|
await this.request(entry.method, entry.path, entry.body, { retry: true });
|
|
1718
1728
|
outbox.remove(entry.turnId, entry.seq);
|
|
1719
1729
|
progressed = true;
|
|
1720
|
-
} catch (
|
|
1721
|
-
if (
|
|
1730
|
+
} catch (err2) {
|
|
1731
|
+
if (err2 instanceof ApiError && err2.status >= 400 && err2.status < 500) {
|
|
1722
1732
|
this.opts.log?.warn(
|
|
1723
|
-
{ kind: entry.kind, turnId: entry.turnId, seq: entry.seq, status:
|
|
1724
|
-
"
|
|
1733
|
+
{ kind: entry.kind, turnId: entry.turnId, seq: entry.seq, status: err2.status },
|
|
1734
|
+
"companion outbox: discarding entry on terminal 4xx (will never land)"
|
|
1725
1735
|
);
|
|
1726
1736
|
outbox.remove(entry.turnId, entry.seq);
|
|
1727
1737
|
progressed = true;
|
|
@@ -1748,7 +1758,7 @@ var CabaneApi = class {
|
|
|
1748
1758
|
// the machinery the `sub_agent` turn-control tool is sugar over. Two things make
|
|
1749
1759
|
// it distinct from an ordinary `request` call, so it does its own `fetch`:
|
|
1750
1760
|
// - a PER-CALL bearer — the turn's OBO token when the API minted one, else the
|
|
1751
|
-
//
|
|
1761
|
+
// companion PAT — so the spawn carries the same authority as the agent's other
|
|
1752
1762
|
// cabane calls this turn (mirrors build-options' `turnToken ?? agentPat`);
|
|
1753
1763
|
// - the `x-cabane-active-conversation` header naming the caller's turn, which
|
|
1754
1764
|
// the server verifies against the live run to resolve the caller pair for the
|
|
@@ -1781,15 +1791,15 @@ var CabaneApi = class {
|
|
|
1781
1791
|
}
|
|
1782
1792
|
return { status: res.status, body: parsed };
|
|
1783
1793
|
}
|
|
1784
|
-
// SJ383:
|
|
1794
|
+
// SJ383: companion-only participant ops. All three authenticate with the
|
|
1785
1795
|
// workspace's agent-bound PAT (passed as `token` on this client) — never
|
|
1786
1796
|
// the user PAT.
|
|
1787
|
-
// Recovery-path read: returns the
|
|
1797
|
+
// Recovery-path read: returns the companion's `agentSessionId` for this
|
|
1788
1798
|
// conversation so the dispatcher can pass it as `Options.resume`, plus
|
|
1789
|
-
// the current `activeRunStartedAt` so a freshly-reconnected
|
|
1799
|
+
// the current `activeRunStartedAt` so a freshly-reconnected companion can
|
|
1790
1800
|
// see whether a prior run is still flagged in-flight.
|
|
1791
1801
|
// SJ383: recovery-path read for a participant row — the `agentSessionId` a
|
|
1792
|
-
// freshly-reconnected
|
|
1802
|
+
// freshly-reconnected companion resumes on. CT262: the per-turn piggybacks this
|
|
1793
1803
|
// fetch grew (agentRules / visionBlocks / channel / members / conversationContext)
|
|
1794
1804
|
// are gone — `getTurnContext` composes the whole turn now — so this is back to
|
|
1795
1805
|
// the plain recovery read, with no `messageId` param.
|
|
@@ -1799,17 +1809,37 @@ var CabaneApi = class {
|
|
|
1799
1809
|
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}`
|
|
1800
1810
|
);
|
|
1801
1811
|
}
|
|
1802
|
-
// CT262: the ONE turn-context fetch. Collapses the
|
|
1812
|
+
// CT262: the ONE turn-context fetch. Collapses the companion's old four-fetch
|
|
1803
1813
|
// choreography (getConversation + getMessage + getAgentSelf + getParticipantAgent)
|
|
1804
1814
|
// into a single call: the server composes the whole server portion of the
|
|
1805
1815
|
// `TurnRequest` — systemPrompt, per-turn prompt + vision content, effective
|
|
1806
1816
|
// run-config, `HostPolicy`, prior session, the user MCP definitions to resolve
|
|
1807
1817
|
// locally, plus the anchor/title + trigger-message summary the host needs.
|
|
1808
1818
|
// Agent-PAT authed; the workspace is implied by the PAT.
|
|
1809
|
-
|
|
1810
|
-
|
|
1819
|
+
// CT714: `turnId` is the host-minted id for THIS turn, passed so the server can
|
|
1820
|
+
// bind the minted turn token to it — the turn-control surface then rejects a
|
|
1821
|
+
// token whose turn has ended. The dispatcher mints it before this call and
|
|
1822
|
+
// reuses the same value on its active-run PATCH, so the token's turn id and the
|
|
1823
|
+
// pair's `active_turn_id` agree.
|
|
1824
|
+
getTurnContext(conversationId, messageId2, turnId) {
|
|
1825
|
+
const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
|
|
1811
1826
|
return this.request("GET", `/api/agent/turn-context?${q}`);
|
|
1812
1827
|
}
|
|
1828
|
+
// CT714: read a turn's recorded turn-control intent (ask/wake/summon/skip). An
|
|
1829
|
+
// EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
|
|
1830
|
+
// `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
|
|
1831
|
+
// in-memory closures, so the dispatcher fetches this once at settle — by
|
|
1832
|
+
// `turnId` — and populates those closures, letting the unchanged settle path
|
|
1833
|
+
// materialize the effects identically to claude-code. Agent-PAT authed +
|
|
1834
|
+
// self-scoped (`:agentId` must match the PAT's agent). A turn that recorded no
|
|
1835
|
+
// control verb returns all-empty fields.
|
|
1836
|
+
getTurnIntent(workspaceId, conversationId, agentId, turnId) {
|
|
1837
|
+
const q = `turnId=${encodeURIComponent(turnId)}`;
|
|
1838
|
+
return this.request(
|
|
1839
|
+
"GET",
|
|
1840
|
+
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-intent?${q}`
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1813
1843
|
// Flips `active_run_started_at` and optionally captures the SDK session
|
|
1814
1844
|
// id. The dispatcher hits this twice per turn (now() before the SDK
|
|
1815
1845
|
// loop; null when it settles) plus once with the session id on the
|
|
@@ -1822,7 +1852,7 @@ var CabaneApi = class {
|
|
|
1822
1852
|
// `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
|
|
1823
1853
|
// left best-effort: it's lower-stakes and self-heals on the next turn, so it
|
|
1824
1854
|
// stays a single-shot PATCH and is deliberately out of CT93's scope.
|
|
1825
|
-
|
|
1855
|
+
setActiveRun(workspaceId, conversationId, agentId, body) {
|
|
1826
1856
|
const path3 = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
|
|
1827
1857
|
const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
|
|
1828
1858
|
if (touchesFlag && this.opts.outbox) {
|
|
@@ -1848,11 +1878,11 @@ var CabaneApi = class {
|
|
|
1848
1878
|
try {
|
|
1849
1879
|
await this.request("PATCH", path3, body, { retry: true });
|
|
1850
1880
|
outbox?.remove(key, ACTIVE_RUN_OUTBOX_SEQ);
|
|
1851
|
-
} catch (
|
|
1852
|
-
if (!outbox) throw
|
|
1853
|
-
if (!isRetryable(
|
|
1881
|
+
} catch (err2) {
|
|
1882
|
+
if (!outbox) throw err2;
|
|
1883
|
+
if (!isRetryable(err2)) {
|
|
1854
1884
|
outbox.remove(key, ACTIVE_RUN_OUTBOX_SEQ);
|
|
1855
|
-
throw
|
|
1885
|
+
throw err2;
|
|
1856
1886
|
}
|
|
1857
1887
|
outbox.persist({
|
|
1858
1888
|
enqueuedAt: Date.now(),
|
|
@@ -1864,17 +1894,17 @@ var CabaneApi = class {
|
|
|
1864
1894
|
kind: "active-run"
|
|
1865
1895
|
});
|
|
1866
1896
|
this.opts.log?.warn(
|
|
1867
|
-
{ conversationId, agentId, err:
|
|
1868
|
-
"
|
|
1897
|
+
{ conversationId, agentId, err: err2 instanceof Error ? err2.message : String(err2) },
|
|
1898
|
+
"companion: active-run write queued to outbox after transient failure (will drain when the API returns)"
|
|
1869
1899
|
);
|
|
1870
1900
|
}
|
|
1871
1901
|
}
|
|
1872
1902
|
// CT29: per-device liveness moved off the per-workspace agent PAT and onto the
|
|
1873
1903
|
// device token — see `DeviceApi.heartbeat`. There is no agent-PAT heartbeat
|
|
1874
1904
|
// anymore.
|
|
1875
|
-
// SJ477: commit one row of the
|
|
1905
|
+
// SJ477: commit one row of the companion's turn (a `progress` interim note or
|
|
1876
1906
|
// the `final` reply), derived from its own SDK transcript. Posts to the same
|
|
1877
|
-
// public messages endpoint a user hits — the
|
|
1907
|
+
// public messages endpoint a user hits — the companion holds an agent-bound
|
|
1878
1908
|
// PAT, so the server attributes the row to this agent (role `agent`) and
|
|
1879
1909
|
// won't re-dispatch (the route gates re-dispatch on role `user`). `turnId`
|
|
1880
1910
|
// groups every row of one turn so the chat drawer renders them as a single
|
|
@@ -1889,12 +1919,12 @@ var CabaneApi = class {
|
|
|
1889
1919
|
// CT11: `kind` now includes `'stopped'` for the terminal marker the
|
|
1890
1920
|
// dispatcher writes when a turn is cancelled — same wire shape as
|
|
1891
1921
|
// `progress`/`final`, distinguished only by `kind` so the chat drawer's
|
|
1892
|
-
// turn-group renderer treats it as a closing row. `seq` is the
|
|
1922
|
+
// turn-group renderer treats it as a closing row. `seq` is the companion's
|
|
1893
1923
|
// per-turn monotonic counter, stamped on the row so the merged timeline
|
|
1894
1924
|
// orders the commit deterministically against the persisted tool/thinking
|
|
1895
|
-
// rows. Both fields are optional on the wire — an older
|
|
1925
|
+
// rows. Both fields are optional on the wire — an older companion that didn't
|
|
1896
1926
|
// mint seq still validates (the server defaults to 0); `stopped` is only
|
|
1897
|
-
// emitted by post-CT11
|
|
1927
|
+
// emitted by post-CT11 companions.
|
|
1898
1928
|
postTurnMessage(workspaceId, conversationId, body, signal) {
|
|
1899
1929
|
return this.durableCommit(
|
|
1900
1930
|
"message",
|
|
@@ -1935,11 +1965,11 @@ var CabaneApi = class {
|
|
|
1935
1965
|
);
|
|
1936
1966
|
}
|
|
1937
1967
|
// SJ493: fetch the agent's self-view — identity + operating context. The
|
|
1938
|
-
//
|
|
1968
|
+
// companion calls this on each dispatch to get its `systemPrompt` (composed
|
|
1939
1969
|
// server-side from the bundled default + the agent's charter) rather than
|
|
1940
1970
|
// baking a copy of the prompt into the download. Cabane is the control plane
|
|
1941
1971
|
// for the prompt, so changing it (or the per-agent charter) takes effect
|
|
1942
|
-
// without shipping a new
|
|
1972
|
+
// without shipping a new companion. Agent-PAT authed; the workspace is implied
|
|
1943
1973
|
// by the PAT, so no workspace arg.
|
|
1944
1974
|
// CT245: pass the triggering turn's `conversationId` so the server returns the
|
|
1945
1975
|
// run-config RESOLVED for this conversation (agent default + that
|
|
@@ -1950,10 +1980,10 @@ var CabaneApi = class {
|
|
|
1950
1980
|
const path3 = conversationId ? `/api/agent/me?conversationId=${encodeURIComponent(conversationId)}` : `/api/agent/me`;
|
|
1951
1981
|
return this.request("GET", path3);
|
|
1952
1982
|
}
|
|
1953
|
-
// The
|
|
1983
|
+
// The companion fetches the triggering message body by listing the
|
|
1954
1984
|
// conversation's messages and finding the one with `id === messageId`.
|
|
1955
1985
|
// Cabane has no single-message GET endpoint; for v0 this is fine because
|
|
1956
|
-
// the
|
|
1986
|
+
// the companion only reaches for the specific row immediately after the
|
|
1957
1987
|
// event fires (the thread is small at that point).
|
|
1958
1988
|
async getMessage(workspaceId, conversationId, messageId2) {
|
|
1959
1989
|
const res = await this.request(
|
|
@@ -1963,13 +1993,13 @@ var CabaneApi = class {
|
|
|
1963
1993
|
return res.messages.find((m) => m.id === messageId2) ?? null;
|
|
1964
1994
|
}
|
|
1965
1995
|
};
|
|
1966
|
-
function isRetryable(
|
|
1967
|
-
if (
|
|
1968
|
-
if (isAbortError(
|
|
1996
|
+
function isRetryable(err2) {
|
|
1997
|
+
if (err2 instanceof ApiError) return err2.status >= 500;
|
|
1998
|
+
if (isAbortError(err2)) return false;
|
|
1969
1999
|
return true;
|
|
1970
2000
|
}
|
|
1971
|
-
function isAbortError(
|
|
1972
|
-
return
|
|
2001
|
+
function isAbortError(err2) {
|
|
2002
|
+
return err2 instanceof Error && err2.name === "AbortError";
|
|
1973
2003
|
}
|
|
1974
2004
|
function sleep2(ms, signal) {
|
|
1975
2005
|
return new Promise((resolve) => {
|
|
@@ -1987,8 +2017,8 @@ function sleep2(ms, signal) {
|
|
|
1987
2017
|
}
|
|
1988
2018
|
function errorMessage(status2, body) {
|
|
1989
2019
|
if (body && typeof body === "object" && "error" in body) {
|
|
1990
|
-
const
|
|
1991
|
-
if (typeof
|
|
2020
|
+
const err2 = body.error;
|
|
2021
|
+
if (typeof err2 === "string") return `${status2} ${err2}`;
|
|
1992
2022
|
}
|
|
1993
2023
|
if (typeof body === "string" && body.length > 0) return `${status2} ${body.slice(0, 200)}`;
|
|
1994
2024
|
return `${status2} error`;
|
|
@@ -2032,7 +2062,10 @@ var DeviceApi = class {
|
|
|
2032
2062
|
getAssignments() {
|
|
2033
2063
|
return this.request("GET", "/api/companion/assignments");
|
|
2034
2064
|
}
|
|
2035
|
-
|
|
2065
|
+
beginDrain() {
|
|
2066
|
+
return this.request("POST", "/api/companion/drain", {});
|
|
2067
|
+
}
|
|
2068
|
+
// Per-device liveness ping. Reports the companion build version and the env-var
|
|
2036
2069
|
// names the operator's secret store exposes (never values), so CT30's UI can
|
|
2037
2070
|
// warn pre-emptively about an agent that needs a secret this device lacks.
|
|
2038
2071
|
heartbeat(body) {
|
|
@@ -2041,8 +2074,8 @@ var DeviceApi = class {
|
|
|
2041
2074
|
};
|
|
2042
2075
|
function errorMessage2(status2, body) {
|
|
2043
2076
|
if (body && typeof body === "object" && "error" in body) {
|
|
2044
|
-
const
|
|
2045
|
-
if (typeof
|
|
2077
|
+
const err2 = body.error;
|
|
2078
|
+
if (typeof err2 === "string") return `${status2} ${err2}`;
|
|
2046
2079
|
}
|
|
2047
2080
|
if (typeof body === "string" && body.length > 0) return `${status2} ${body.slice(0, 200)}`;
|
|
2048
2081
|
return `${status2} error`;
|
|
@@ -2188,7 +2221,7 @@ function bumpResumeAttempt(workspaceId, eventId) {
|
|
|
2188
2221
|
return next;
|
|
2189
2222
|
}
|
|
2190
2223
|
function noResume() {
|
|
2191
|
-
return process.env.
|
|
2224
|
+
return process.env.CABANE_COMPANION_NO_RESUME === "1";
|
|
2192
2225
|
}
|
|
2193
2226
|
|
|
2194
2227
|
// packages/agent-runtime/src/version.ts
|
|
@@ -2205,16 +2238,17 @@ var hostPolicySchema = z5.object({
|
|
|
2205
2238
|
// Public web (today's `WebSearch` / `WebFetch`, the `DEFAULT_WEB_TOOLS`). Public
|
|
2206
2239
|
// web, not host reach — granted by default today, but expressible as a grant.
|
|
2207
2240
|
web: z5.boolean(),
|
|
2208
|
-
// Browser automation (the Playwright MCP surface). Varies by host: a
|
|
2241
|
+
// Browser automation (the Playwright MCP surface). Varies by host: a companion has
|
|
2209
2242
|
// it, the house executor does not (CT230).
|
|
2210
2243
|
browser: z5.boolean(),
|
|
2211
2244
|
// User-configured MCP servers permitted. False for the house executor
|
|
2212
|
-
// (CT227: Cabane agents run no user MCP servers), true for a personal
|
|
2245
|
+
// (CT227: Cabane agents run no user MCP servers), true for a personal companion.
|
|
2213
2246
|
userMcp: z5.boolean(),
|
|
2214
2247
|
// Subagent / sub-dispatch (`Task` / `Agent`). A per-host GRANT (see the CT261
|
|
2215
2248
|
// amendment above): `false` on the locked assistant/house surface (banned via
|
|
2216
2249
|
// `DISALLOWED_TOOLS`), `true` under coding / custom where the fan-out is left to
|
|
2217
|
-
//
|
|
2250
|
+
// the coding allow-hook (which reaches sub-agent tool calls, CT680) or the user
|
|
2251
|
+
// allowlist. The subagent completes within the turn, so
|
|
2218
2252
|
// it's not the turn-model invariant `scheduling` is.
|
|
2219
2253
|
subagents: z5.boolean(),
|
|
2220
2254
|
// ── Hard platform invariants — always denied, never granted ────────────────
|
|
@@ -2235,7 +2269,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2235
2269
|
// The runtime's opaque session state, emitted when the adapter learns it (e.g.
|
|
2236
2270
|
// the SDK `system/init` frame). The platform stores `state` verbatim per
|
|
2237
2271
|
// (conversation, agent) and hands it back on the next turn; only the adapter
|
|
2238
|
-
// knows what it means. Today's
|
|
2272
|
+
// knows what it means. Today's companion captures the raw SDK session id here; a
|
|
2239
2273
|
// future adapter may encode more (e.g. `{sdkSessionId, cwd}`) — still one
|
|
2240
2274
|
// opaque string to the platform.
|
|
2241
2275
|
//
|
|
@@ -2248,7 +2282,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2248
2282
|
// so the mark now over-reaches an empty session. The host relays `degraded`
|
|
2249
2283
|
// on settle and the server rewinds the mark, so the NEXT turn rebuilds a full
|
|
2250
2284
|
// catch-up (this failing turn is unavoidably lossy — the degrade is only known
|
|
2251
|
-
// on the
|
|
2285
|
+
// on the companion, after the server committed the manifest). Runtime-neutral: a
|
|
2252
2286
|
// plain boolean, not a runtime-specific reason string (that stays in the
|
|
2253
2287
|
// adapter's `onWarn` log). Additive + optional — an old receiver ignores it.
|
|
2254
2288
|
z6.object({
|
|
@@ -2296,7 +2330,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2296
2330
|
// dropped on the floor before. `inputTokens` is the full context the model saw
|
|
2297
2331
|
// (uncached + cache-read + cache-creation input), so it doubles as the
|
|
2298
2332
|
// context-window cost; `outputTokens` the generated tokens. Optional ⇒
|
|
2299
|
-
// backward-compatible: an old
|
|
2333
|
+
// backward-compatible: an old companion / adapter that never sets it, and a
|
|
2300
2334
|
// receiver that never reads it, are unaffected (the turn's token columns stay
|
|
2301
2335
|
// null → the UI shows `—`).
|
|
2302
2336
|
//
|
|
@@ -2306,6 +2340,19 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2306
2340
|
// the server just leaves the cache columns null. Captured now because honest
|
|
2307
2341
|
// costing later prices a cache-read token far below a fresh input token.
|
|
2308
2342
|
//
|
|
2343
|
+
// CT699: `inputTokens` (and the cache slice) is a BILLING quantity — for
|
|
2344
|
+
// claude-code/codex it's the runtime's CUMULATIVE total summed across every model
|
|
2345
|
+
// request in the agentic turn, so it grows with the tool-call count and is NOT
|
|
2346
|
+
// "how full is the window." `contextTokens` is the distinct CONTEXT-OCCUPANCY
|
|
2347
|
+
// read: the input the model saw on its FINAL request of the turn (uncached +
|
|
2348
|
+
// cache, since cached tokens still occupy the window) — the number the composer
|
|
2349
|
+
// gauge wants. `contextWindow` is the model's true window in tokens when the
|
|
2350
|
+
// runtime reports it (claude-code's SDK does, per model) — a real denominator so
|
|
2351
|
+
// the gauge can show a fraction. Both optional: a runtime that can't source a
|
|
2352
|
+
// clean final-request figure (codex's cumulative-only usage) omits `contextTokens`
|
|
2353
|
+
// and the gauge falls back to the raw count; `contextWindow` falls back to the
|
|
2354
|
+
// model catalog.
|
|
2355
|
+
//
|
|
2309
2356
|
// CT601: two more optional carry-homes on the terminal result, alongside
|
|
2310
2357
|
// `usage`. `resolvedModel` is the CONCRETE model the runtime actually ran —
|
|
2311
2358
|
// claude-code learns it from the `system/init` frame mid-run (even for a
|
|
@@ -2315,7 +2362,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2315
2362
|
// for claude-code today). Both only known after the run streams — so they ride
|
|
2316
2363
|
// the terminal event home, the host relays them on settle, and the server writes
|
|
2317
2364
|
// `message_turn_metadata.resolved_model` / `resolved_config`. Optional ⇒
|
|
2318
|
-
// backward-compatible: an old adapter/
|
|
2365
|
+
// backward-compatible: an old adapter/companion omits them, a cancel has no result
|
|
2319
2366
|
// event at all, and the columns stay null → the UI shows `—`.
|
|
2320
2367
|
z6.object({
|
|
2321
2368
|
type: z6.literal("result"),
|
|
@@ -2325,7 +2372,9 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2325
2372
|
inputTokens: z6.number(),
|
|
2326
2373
|
outputTokens: z6.number(),
|
|
2327
2374
|
cacheReadTokens: z6.number().optional(),
|
|
2328
|
-
cacheCreationTokens: z6.number().optional()
|
|
2375
|
+
cacheCreationTokens: z6.number().optional(),
|
|
2376
|
+
contextTokens: z6.number().optional(),
|
|
2377
|
+
contextWindow: z6.number().optional()
|
|
2329
2378
|
}).optional(),
|
|
2330
2379
|
resolvedModel: z6.string().optional(),
|
|
2331
2380
|
resolvedConfig: z6.object({
|
|
@@ -2413,7 +2462,8 @@ function classifyErrorText(text) {
|
|
|
2413
2462
|
const t = text.toLowerCase();
|
|
2414
2463
|
if (AUTH_PATTERNS.some((re) => re.test(t))) return { kind: "auth_expired" };
|
|
2415
2464
|
if (SERVER_PATTERNS.some((re) => re.test(t))) return { kind: "server_error" };
|
|
2416
|
-
const
|
|
2465
|
+
const withoutNegatedCap = t.replace(NEGATED_CAP, "");
|
|
2466
|
+
const capNoun = CAP_PATTERNS.some((re) => re.test(withoutNegatedCap));
|
|
2417
2467
|
const rateToken = RATE_PATTERNS.some((re) => re.test(t));
|
|
2418
2468
|
if (capNoun) return { kind: "usage_capped" };
|
|
2419
2469
|
if (rateToken) return { kind: "rate_limited" };
|
|
@@ -2442,6 +2492,7 @@ var SERVER_PATTERNS = [
|
|
|
2442
2492
|
/fetch failed/
|
|
2443
2493
|
];
|
|
2444
2494
|
var CAP_PATTERNS = [/usage limit/, /weekly limit/, /session limit/, /\bquota\b/];
|
|
2495
|
+
var NEGATED_CAP = /not (your|a) usage limit/g;
|
|
2445
2496
|
var RATE_PATTERNS = [/rate[_ ]?limit/, /\b429\b/, /too many requests/];
|
|
2446
2497
|
var BARE_LIMIT = /\blimit (reached|exceeded)\b/;
|
|
2447
2498
|
|
|
@@ -2503,34 +2554,62 @@ var turnRequestSchema = z8.object({
|
|
|
2503
2554
|
mcpUrl: z8.string(),
|
|
2504
2555
|
bearer: z8.string(),
|
|
2505
2556
|
activeConversationId: z8.string(),
|
|
2557
|
+
// CT714: the scoped TURN-CONTROL MCP endpoint (`/api/turn-control`). The
|
|
2558
|
+
// EXTERNAL adapters (Codex / opencode) mount it by URL under the key
|
|
2559
|
+
// `cabane_companion` — using the same `bearer` (the turn token) and the same
|
|
2560
|
+
// active-conversation header they send to the `cabane` server — so their
|
|
2561
|
+
// agents get `ask`/`wake_me`/`summon_agent`/`sub_agent`/`skip_turn`, the
|
|
2562
|
+
// verbs they can't get from the companion's in-process SDK server. Optional:
|
|
2563
|
+
// claude-code ignores it (it mounts the in-process instance instead), and
|
|
2564
|
+
// every existing `cabane`-block fixture keeps parsing unchanged; the
|
|
2565
|
+
// companion always populates it (`build-options.ts`).
|
|
2566
|
+
turnControlUrl: z8.string().optional(),
|
|
2506
2567
|
// CT598: the workspace this turn runs in. The claude-code/opencode/codex
|
|
2507
2568
|
// adapters never need it (they reach Cabane through the `cabane` MCP server,
|
|
2508
2569
|
// which takes `workspaceId` as a per-tool arg the model supplies); the
|
|
2509
2570
|
// native runtime's interim tool surface calls the workspace-scoped REST API
|
|
2510
2571
|
// DIRECTLY, so it needs the id host-side rather than trusting the model to
|
|
2511
2572
|
// pass it. Optional so every existing `cabane`-block constructor (the three
|
|
2512
|
-
// adapters' conformance fixtures, tests) keeps parsing unchanged — the
|
|
2573
|
+
// adapters' conformance fixtures, tests) keeps parsing unchanged — the companion
|
|
2513
2574
|
// always populates it (`build-options.ts`), and the native adapter fails the
|
|
2514
2575
|
// turn loudly when it is somehow absent rather than guessing.
|
|
2515
|
-
workspaceId: z8.string().optional()
|
|
2576
|
+
workspaceId: z8.string().optional(),
|
|
2577
|
+
// CT752: the server-resolved workspace surface this credential exposes.
|
|
2578
|
+
// Readiness uses this explicit fact to require `sdk` for code mode and the
|
|
2579
|
+
// granular floor for classic mode; inventory contents alone cannot infer it
|
|
2580
|
+
// because `sdk` is intentionally also available on the classic surface.
|
|
2581
|
+
workspaceToolSurface: z8.enum(["code", "classic"]).optional()
|
|
2516
2582
|
}),
|
|
2517
2583
|
// Machine-local resolution (host-filled): the checkout cwd, extra env from a
|
|
2518
2584
|
// prepare hook, and the resolved user MCP servers.
|
|
2519
2585
|
local: z8.object({
|
|
2520
2586
|
cwd: z8.string().optional(),
|
|
2521
2587
|
env: z8.record(z8.string(), z8.string()).optional(),
|
|
2588
|
+
nativeWorkAssignment: z8.object({
|
|
2589
|
+
itemId: z8.string(),
|
|
2590
|
+
executionId: z8.string(),
|
|
2591
|
+
activationEpoch: z8.number().int().nonnegative()
|
|
2592
|
+
}).strict().optional(),
|
|
2522
2593
|
mcpServers: resolvedMcpServersSchema.optional(),
|
|
2523
2594
|
// CT289: machine-local claude-code adapter knobs the operator sets on a
|
|
2524
|
-
//
|
|
2595
|
+
// companion they run themselves — the auto-memory escape hatch. `autoMemory:
|
|
2525
2596
|
// true` opts back into Claude Code's auto-memory (governed by the operator's
|
|
2526
2597
|
// own `.claude/settings.json`); absent/false leaves the adapter's force-off
|
|
2527
|
-
// default in place (see `buildClaudeCodeOptions`). The
|
|
2598
|
+
// default in place (see `buildClaudeCodeOptions`). The In-Cabane executor never
|
|
2528
2599
|
// sets it, so house stays force-off unconditionally.
|
|
2529
2600
|
claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
|
|
2530
2601
|
}),
|
|
2531
2602
|
// Host-owned injected servers (host-filled) — e.g. the summon server.
|
|
2532
2603
|
extra: z8.object({
|
|
2533
|
-
mcpServers: hostInjectedServersSchema
|
|
2604
|
+
mcpServers: hostInjectedServersSchema,
|
|
2605
|
+
// CT666: the per-turn turn-control HANDLER for the in-process cabane-native
|
|
2606
|
+
// runtime — the seam that gives it ask/wake_me/summon_agent/sub_agent/skip_turn
|
|
2607
|
+
// without a subprocess `cabane_companion` MCP server. Typed `unknown` for the same
|
|
2608
|
+
// reason as `mcpServers`: it's a live host object (a `NativeTurnControl` whose
|
|
2609
|
+
// methods close over the dispatcher's per-turn state), passed to the native
|
|
2610
|
+
// adapter WITHOUT the contract package inspecting it. The subprocess adapters
|
|
2611
|
+
// ignore it (they get the same verbs from the injected SDK server instead).
|
|
2612
|
+
turnControl: z8.unknown().optional()
|
|
2534
2613
|
})
|
|
2535
2614
|
});
|
|
2536
2615
|
|
|
@@ -2550,8 +2629,8 @@ function createTerminalTextBuffer() {
|
|
|
2550
2629
|
async function safeEmit(emit, event, onError) {
|
|
2551
2630
|
try {
|
|
2552
2631
|
await emit(event);
|
|
2553
|
-
} catch (
|
|
2554
|
-
onError?.(
|
|
2632
|
+
} catch (err2) {
|
|
2633
|
+
onError?.(err2, event.type);
|
|
2555
2634
|
}
|
|
2556
2635
|
}
|
|
2557
2636
|
async function processAssistantMessage(msg, emit, pending, buffer, onError) {
|
|
@@ -2812,16 +2891,16 @@ var TurnPump = class {
|
|
|
2812
2891
|
// minimal note. Skipped when cancelled or already final. The held-text flush
|
|
2813
2892
|
// that precedes it is a classification concern, driven by the caller before
|
|
2814
2893
|
// this runs.
|
|
2815
|
-
async finalize(
|
|
2816
|
-
if (!
|
|
2894
|
+
async finalize(ok2) {
|
|
2895
|
+
if (!ok2 || this.opts.signal.aborted || this.emittedFinal) return;
|
|
2817
2896
|
const body = this.lastProgressBody ?? this.opts.emptyFinalBody;
|
|
2818
2897
|
const seq = this.opts.nextSeq();
|
|
2819
2898
|
try {
|
|
2820
2899
|
await this.opts.commit.commitMessage({ body, kind: "final", seq });
|
|
2821
2900
|
this.emittedFinal = true;
|
|
2822
2901
|
this.finalReplyBody = body;
|
|
2823
|
-
} catch (
|
|
2824
|
-
this.opts.onError?.(
|
|
2902
|
+
} catch (err2) {
|
|
2903
|
+
this.opts.onError?.(err2, "empty-final");
|
|
2825
2904
|
}
|
|
2826
2905
|
}
|
|
2827
2906
|
// Whether the turn has committed its `final` row — read by the host to decide
|
|
@@ -2911,10 +2990,10 @@ var claudeCodeDialectSchema = z10.object({
|
|
|
2911
2990
|
allowedTools: z10.array(z10.string()).optional(),
|
|
2912
2991
|
disallowedTools: z10.array(z10.string()).optional(),
|
|
2913
2992
|
// Which claude-code harness shape to run. `coding` switches to the
|
|
2914
|
-
// `claude_code` preset + project settings + always-allow `
|
|
2993
|
+
// `claude_code` preset + project settings + always-allow `PreToolUse` hook;
|
|
2915
2994
|
// `custom`/`assistant` (or absent) use a plain-string prompt + bypass. This
|
|
2916
2995
|
// is the claude-code-specific PRESET selector — kept distinct from
|
|
2917
|
-
// `policy.hostFs` (the host-fs BLOCK), because
|
|
2996
|
+
// `policy.hostFs` (the host-fs BLOCK), because companion `custom` mode wants host
|
|
2918
2997
|
// fs available (via its own allowlist) WITHOUT the coding harness, and in-app
|
|
2919
2998
|
// `custom` wants host fs blocked — neither of which a single `hostFs` boolean
|
|
2920
2999
|
// can express alongside the preset choice.
|
|
@@ -2962,10 +3041,18 @@ function decideResume(stored, currentCwd) {
|
|
|
2962
3041
|
// packages/agent-runtime/src/claude-code/options.ts
|
|
2963
3042
|
var CABANE_MCP_SERVER = "cabane";
|
|
2964
3043
|
var ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
3044
|
+
async function allowEverythingHook(input) {
|
|
3045
|
+
const toolInput = "tool_input" in input && input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
|
|
3046
|
+
return {
|
|
3047
|
+
continue: true,
|
|
3048
|
+
hookSpecificOutput: {
|
|
3049
|
+
hookEventName: "PreToolUse",
|
|
3050
|
+
permissionDecision: "allow",
|
|
3051
|
+
permissionDecisionReason: "coding mode: headless never-prompt (CT680)",
|
|
3052
|
+
updatedInput: toolInput
|
|
3053
|
+
}
|
|
3054
|
+
};
|
|
3055
|
+
}
|
|
2969
3056
|
function buildClaudeCodeOptions(req, augment) {
|
|
2970
3057
|
const { policy, config } = req;
|
|
2971
3058
|
const cwd = req.local.cwd;
|
|
@@ -3026,7 +3113,7 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
3026
3113
|
...devControlsAutoMemory ? {} : { settings: { autoMemoryEnabled: false } },
|
|
3027
3114
|
mcpServers,
|
|
3028
3115
|
...cwd ? { cwd } : {},
|
|
3029
|
-
// Extra env (a
|
|
3116
|
+
// Extra env (a companion prepare hook's tokens/ports; the in-app's debug flags)
|
|
3030
3117
|
// merged OVER the inherited environment.
|
|
3031
3118
|
...req.local.env ? { env: { ...process.env, ...req.local.env } } : {},
|
|
3032
3119
|
...resume ? { resume } : {}
|
|
@@ -3039,7 +3126,7 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
3039
3126
|
settingSources: ["project"],
|
|
3040
3127
|
allowedTools,
|
|
3041
3128
|
disallowedTools,
|
|
3042
|
-
|
|
3129
|
+
hooks: { PreToolUse: [{ hooks: [allowEverythingHook] }] }
|
|
3043
3130
|
};
|
|
3044
3131
|
} else {
|
|
3045
3132
|
options = {
|
|
@@ -3066,13 +3153,15 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3066
3153
|
out.push(event);
|
|
3067
3154
|
};
|
|
3068
3155
|
let sessionEmitted = false;
|
|
3069
|
-
let
|
|
3156
|
+
let ok2 = false;
|
|
3070
3157
|
let resultReason;
|
|
3071
3158
|
let sawResult = false;
|
|
3072
3159
|
let usage;
|
|
3160
|
+
let lastRequestContextTokens;
|
|
3073
3161
|
let resolvedModel;
|
|
3074
3162
|
let sawRejectedLimit = false;
|
|
3075
3163
|
let rateLimitResetIso;
|
|
3164
|
+
let rateLimitType;
|
|
3076
3165
|
let authError;
|
|
3077
3166
|
let lastAssistantError;
|
|
3078
3167
|
try {
|
|
@@ -3095,6 +3184,8 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3095
3184
|
if (typeof assistantErr === "string" && assistantErr.length > 0) {
|
|
3096
3185
|
lastAssistantError = assistantErr;
|
|
3097
3186
|
}
|
|
3187
|
+
const reqContext = readRequestContextTokens(msg);
|
|
3188
|
+
if (reqContext !== void 0) lastRequestContextTokens = reqContext;
|
|
3098
3189
|
await processAssistantMessage(msg, emit, pending, buffer);
|
|
3099
3190
|
yield* drain(out);
|
|
3100
3191
|
} else if (msg.type === "user") {
|
|
@@ -3105,16 +3196,23 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3105
3196
|
if (info?.status === "rejected") {
|
|
3106
3197
|
sawRejectedLimit = true;
|
|
3107
3198
|
rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? rateLimitResetIso;
|
|
3199
|
+
if (typeof info.rateLimitType === "string") rateLimitType = info.rateLimitType;
|
|
3108
3200
|
}
|
|
3109
3201
|
} else if (msg.type === "auth_status") {
|
|
3110
|
-
const
|
|
3111
|
-
if (typeof
|
|
3202
|
+
const err2 = msg.error;
|
|
3203
|
+
if (typeof err2 === "string" && err2.length > 0) authError = err2;
|
|
3112
3204
|
} else if (msg.type === "result") {
|
|
3113
3205
|
sawResult = true;
|
|
3114
3206
|
usage = readSdkUsage(msg);
|
|
3207
|
+
if (usage) {
|
|
3208
|
+
if (lastRequestContextTokens !== void 0)
|
|
3209
|
+
usage.contextTokens = lastRequestContextTokens;
|
|
3210
|
+
const window = readContextWindow(msg, resolvedModel);
|
|
3211
|
+
if (window !== void 0) usage.contextWindow = window;
|
|
3212
|
+
}
|
|
3115
3213
|
const isError = msg.is_error === true;
|
|
3116
3214
|
if (msg.subtype === "success" && !isError) {
|
|
3117
|
-
|
|
3215
|
+
ok2 = true;
|
|
3118
3216
|
} else {
|
|
3119
3217
|
const resultText = msg.result ?? "";
|
|
3120
3218
|
const terminalReason = msg.terminal_reason;
|
|
@@ -3122,36 +3220,40 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3122
3220
|
const errorText = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(
|
|
3123
3221
|
" "
|
|
3124
3222
|
);
|
|
3125
|
-
const
|
|
3223
|
+
const rejectedIsCap = sawRejectedLimit && (rateLimitResetIso !== void 0 || isSubscriptionWindow(rateLimitType));
|
|
3224
|
+
const failure = rejectedIsCap || terminalReason === "blocking_limit" ? {
|
|
3126
3225
|
kind: "usage_capped",
|
|
3127
3226
|
...rateLimitResetIso ? { resetsAt: rateLimitResetIso } : {}
|
|
3128
3227
|
} : classifyAssistantError(lastAssistantError) ?? classifyErrorText([authError, errorText].filter(Boolean).join(" "));
|
|
3129
3228
|
resultReason = failure ? encodeFailureReason(failure) : isError ? `error:${resultText.slice(0, 200) || "unknown"}` : `result_error:${msg.subtype}`;
|
|
3130
|
-
|
|
3229
|
+
ok2 = false;
|
|
3131
3230
|
}
|
|
3132
3231
|
break;
|
|
3133
3232
|
}
|
|
3134
3233
|
}
|
|
3135
|
-
} catch (
|
|
3136
|
-
if (ctx.signal.aborted) throw
|
|
3137
|
-
const failure = classifyErrorText(
|
|
3138
|
-
if (!failure) throw
|
|
3139
|
-
|
|
3234
|
+
} catch (err2) {
|
|
3235
|
+
if (ctx.signal.aborted) throw err2;
|
|
3236
|
+
const failure = classifyErrorText(err2 instanceof Error ? err2.message : String(err2));
|
|
3237
|
+
if (!failure) throw err2;
|
|
3238
|
+
ok2 = false;
|
|
3140
3239
|
resultReason = encodeFailureReason(failure);
|
|
3141
3240
|
sawResult = true;
|
|
3142
3241
|
}
|
|
3143
3242
|
if (ctx.signal.aborted) return;
|
|
3144
|
-
await flushHeldText(buffer, emit,
|
|
3243
|
+
await flushHeldText(buffer, emit, ok2);
|
|
3145
3244
|
yield* drain(out);
|
|
3146
|
-
if (!
|
|
3245
|
+
if (!ok2 && !resultReason && !sawResult) resultReason = "no_result";
|
|
3147
3246
|
yield {
|
|
3148
3247
|
type: "result",
|
|
3149
|
-
ok,
|
|
3248
|
+
ok: ok2,
|
|
3150
3249
|
...resultReason ? { reason: resultReason } : {},
|
|
3151
3250
|
...usage ? { usage } : {},
|
|
3152
3251
|
...resolvedModel ? { resolvedModel } : {}
|
|
3153
3252
|
};
|
|
3154
3253
|
}
|
|
3254
|
+
function isSubscriptionWindow(value) {
|
|
3255
|
+
return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
|
|
3256
|
+
}
|
|
3155
3257
|
function* drain(out) {
|
|
3156
3258
|
while (out.length > 0) yield out.shift();
|
|
3157
3259
|
}
|
|
@@ -3165,6 +3267,27 @@ function readSdkUsage(msg) {
|
|
|
3165
3267
|
const outputTokens = num(usage.output_tokens);
|
|
3166
3268
|
return { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens };
|
|
3167
3269
|
}
|
|
3270
|
+
function readRequestContextTokens(msg) {
|
|
3271
|
+
const usage = msg.message?.usage;
|
|
3272
|
+
if (!usage || typeof usage !== "object") return void 0;
|
|
3273
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
3274
|
+
return num(usage.input_tokens) + num(usage.cache_read_input_tokens) + num(usage.cache_creation_input_tokens);
|
|
3275
|
+
}
|
|
3276
|
+
function readContextWindow(msg, resolvedModel) {
|
|
3277
|
+
const modelUsage = msg.modelUsage;
|
|
3278
|
+
if (!modelUsage || typeof modelUsage !== "object") return void 0;
|
|
3279
|
+
const pos = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
3280
|
+
if (resolvedModel) {
|
|
3281
|
+
const direct = pos(modelUsage[resolvedModel]?.contextWindow);
|
|
3282
|
+
if (direct !== void 0) return direct;
|
|
3283
|
+
}
|
|
3284
|
+
let max;
|
|
3285
|
+
for (const entry of Object.values(modelUsage)) {
|
|
3286
|
+
const w = pos(entry?.contextWindow);
|
|
3287
|
+
if (w !== void 0 && (max === void 0 || w > max)) max = w;
|
|
3288
|
+
}
|
|
3289
|
+
return max;
|
|
3290
|
+
}
|
|
3168
3291
|
|
|
3169
3292
|
// packages/agent-runtime/src/claude-code/prompt-input.ts
|
|
3170
3293
|
function buildQueryPrompt(req) {
|
|
@@ -3282,9 +3405,13 @@ var resultErrorFull = (subtype, extra = {}) => ({
|
|
|
3282
3405
|
session_id: "s",
|
|
3283
3406
|
...extra
|
|
3284
3407
|
});
|
|
3285
|
-
var rateLimitEvent = (status2, resetsAt) => ({
|
|
3408
|
+
var rateLimitEvent = (status2, resetsAt, rateLimitType) => ({
|
|
3286
3409
|
type: "rate_limit_event",
|
|
3287
|
-
rate_limit_info: {
|
|
3410
|
+
rate_limit_info: {
|
|
3411
|
+
status: status2,
|
|
3412
|
+
...resetsAt !== void 0 ? { resetsAt } : {},
|
|
3413
|
+
...rateLimitType !== void 0 ? { rateLimitType } : {}
|
|
3414
|
+
},
|
|
3288
3415
|
session_id: "s"
|
|
3289
3416
|
});
|
|
3290
3417
|
var authStatus = (error) => ({
|
|
@@ -3448,17 +3575,16 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
|
|
|
3448
3575
|
]
|
|
3449
3576
|
},
|
|
3450
3577
|
{
|
|
3451
|
-
// CT558/CT592: a subscription cap. The SDK emits a
|
|
3452
|
-
// `
|
|
3453
|
-
// structured `usage_capped` reason
|
|
3454
|
-
//
|
|
3455
|
-
// as `progress`. CT592: the cap event is `usage_capped`, distinct from a 429.
|
|
3578
|
+
// CT558/CT592/CT731: a subscription cap. The SDK emits a rejected
|
|
3579
|
+
// `rate_limit_event` with a named subscription window; the terminal error result
|
|
3580
|
+
// then classifies as the structured `usage_capped` reason. Partial narration
|
|
3581
|
+
// lands as `progress`. The cap event is distinct from a provider 429 throttle.
|
|
3456
3582
|
name: "subscription cap \u2192 usage_capped",
|
|
3457
3583
|
request: makeRequest(),
|
|
3458
3584
|
nativeStream: [
|
|
3459
3585
|
init("s1"),
|
|
3460
3586
|
assistantText("Let me work on that."),
|
|
3461
|
-
rateLimitEvent("rejected"),
|
|
3587
|
+
rateLimitEvent("rejected", void 0, "five_hour"),
|
|
3462
3588
|
resultError("error_during_execution")
|
|
3463
3589
|
],
|
|
3464
3590
|
expected: [
|
|
@@ -3682,9 +3808,9 @@ function readSessionId(properties) {
|
|
|
3682
3808
|
}
|
|
3683
3809
|
function readSessionError(properties) {
|
|
3684
3810
|
const props = asRecord(properties);
|
|
3685
|
-
const
|
|
3686
|
-
if (typeof
|
|
3687
|
-
const rec2 = asRecord(
|
|
3811
|
+
const err2 = props?.error;
|
|
3812
|
+
if (typeof err2 === "string") return err2;
|
|
3813
|
+
const rec2 = asRecord(err2);
|
|
3688
3814
|
if (!rec2) return "unknown";
|
|
3689
3815
|
const { name, message } = deepestError(rec2);
|
|
3690
3816
|
if (message && name && !isGenericErrorName(name)) return `${name}: ${message}`;
|
|
@@ -3720,7 +3846,7 @@ async function* decodeOpencodeStream(events, ctx) {
|
|
|
3720
3846
|
const pending = /* @__PURE__ */ new Map();
|
|
3721
3847
|
const startedTools = /* @__PURE__ */ new Set();
|
|
3722
3848
|
const finishedTools = /* @__PURE__ */ new Set();
|
|
3723
|
-
let
|
|
3849
|
+
let ok2 = false;
|
|
3724
3850
|
let reason;
|
|
3725
3851
|
let settled = false;
|
|
3726
3852
|
const userMessageIds = /* @__PURE__ */ new Set();
|
|
@@ -3783,7 +3909,7 @@ async function* decodeOpencodeStream(events, ctx) {
|
|
|
3783
3909
|
const sealed = sealHeld(held, true);
|
|
3784
3910
|
held = null;
|
|
3785
3911
|
if (sealed) yield sealed;
|
|
3786
|
-
|
|
3912
|
+
ok2 = true;
|
|
3787
3913
|
settled = true;
|
|
3788
3914
|
break;
|
|
3789
3915
|
} else if (ev.type === "session.error") {
|
|
@@ -3792,7 +3918,7 @@ async function* decodeOpencodeStream(events, ctx) {
|
|
|
3792
3918
|
const sealed = sealHeld(held, false);
|
|
3793
3919
|
held = null;
|
|
3794
3920
|
if (sealed) yield sealed;
|
|
3795
|
-
|
|
3921
|
+
ok2 = false;
|
|
3796
3922
|
const errorText = readSessionError(ev.properties);
|
|
3797
3923
|
const failure = classifyErrorText(errorText);
|
|
3798
3924
|
reason = failure ? encodeFailureReason(failure) : `error:${errorText.slice(0, 200)}`;
|
|
@@ -3807,7 +3933,7 @@ async function* decodeOpencodeStream(events, ctx) {
|
|
|
3807
3933
|
if (sealed) yield sealed;
|
|
3808
3934
|
reason = "no_terminal";
|
|
3809
3935
|
}
|
|
3810
|
-
yield { type: "result", ok, ...reason ? { reason } : {} };
|
|
3936
|
+
yield { type: "result", ok: ok2, ...reason ? { reason } : {} };
|
|
3811
3937
|
}
|
|
3812
3938
|
function hasToolInput(input) {
|
|
3813
3939
|
return !!input && typeof input === "object" && Object.keys(input).length > 0;
|
|
@@ -3865,6 +3991,7 @@ function parseOpencodeModel(model) {
|
|
|
3865
3991
|
|
|
3866
3992
|
// packages/agent-runtime/src/opencode/run-spec.ts
|
|
3867
3993
|
var CABANE_MCP_SERVER2 = "cabane";
|
|
3994
|
+
var TURN_CONTROL_MCP_SERVER = "cabane_companion";
|
|
3868
3995
|
var ACTIVE_CONVERSATION_HEADER3 = "x-cabane-active-conversation";
|
|
3869
3996
|
function buildRunSpec(req, resumeSessionId) {
|
|
3870
3997
|
const { policy, config } = req;
|
|
@@ -3926,6 +4053,17 @@ function buildMcp(req) {
|
|
|
3926
4053
|
},
|
|
3927
4054
|
enabled: true
|
|
3928
4055
|
};
|
|
4056
|
+
if (req.cabane.turnControlUrl) {
|
|
4057
|
+
mcp[TURN_CONTROL_MCP_SERVER] = {
|
|
4058
|
+
type: "remote",
|
|
4059
|
+
url: req.cabane.turnControlUrl,
|
|
4060
|
+
headers: {
|
|
4061
|
+
Authorization: `Bearer ${req.cabane.bearer}`,
|
|
4062
|
+
[ACTIVE_CONVERSATION_HEADER3]: req.cabane.activeConversationId
|
|
4063
|
+
},
|
|
4064
|
+
enabled: true
|
|
4065
|
+
};
|
|
4066
|
+
}
|
|
3929
4067
|
for (const [name, raw] of Object.entries(req.extra.mcpServers)) {
|
|
3930
4068
|
const server = raw;
|
|
3931
4069
|
if (typeof server.url === "string") {
|
|
@@ -4058,9 +4196,9 @@ function createHttpOpencodeTransport(opts) {
|
|
|
4058
4196
|
// The lock is released when this stream finishes draining.
|
|
4059
4197
|
events: releaseAfter(parseSseStream(eventRes.body, sessionId, signal), release)
|
|
4060
4198
|
};
|
|
4061
|
-
} catch (
|
|
4199
|
+
} catch (err2) {
|
|
4062
4200
|
release();
|
|
4063
|
-
throw
|
|
4201
|
+
throw err2;
|
|
4064
4202
|
}
|
|
4065
4203
|
}
|
|
4066
4204
|
};
|
|
@@ -4191,7 +4329,7 @@ var opencodeAdapter = createOpencodeAdapter();
|
|
|
4191
4329
|
// packages/agent-runtime/src/opencode/conformance.ts
|
|
4192
4330
|
var ABORT_SENTINEL2 = { __abortHere: true };
|
|
4193
4331
|
var NEW_SESSION_ID = "sess_new";
|
|
4194
|
-
var
|
|
4332
|
+
var COMPANION_POLICY = {
|
|
4195
4333
|
hostFs: false,
|
|
4196
4334
|
web: true,
|
|
4197
4335
|
browser: true,
|
|
@@ -4207,7 +4345,7 @@ function makeRequest2(overrides = {}) {
|
|
|
4207
4345
|
prompt: "hi there",
|
|
4208
4346
|
content: [{ type: "text", text: "hi there" }],
|
|
4209
4347
|
config: { model: "deepseek/deepseek-chat" },
|
|
4210
|
-
policy:
|
|
4348
|
+
policy: COMPANION_POLICY,
|
|
4211
4349
|
session: null,
|
|
4212
4350
|
cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
|
|
4213
4351
|
local: { cwd: DIR },
|
|
@@ -4555,13 +4693,19 @@ var CODEX_ADDENDUM = [
|
|
|
4555
4693
|
"as the turn\u2019s reply."
|
|
4556
4694
|
].join(" ");
|
|
4557
4695
|
var CODEX_ADDENDUM_CODE_MODE = [
|
|
4558
|
-
"Your
|
|
4559
|
-
"
|
|
4696
|
+
"Your Cabane workspace tool is exposed to Codex as the qualified MCP tool",
|
|
4697
|
+
"`mcp__cabane__sdk`. On Codex versions that defer MCP tools, locate it in the",
|
|
4698
|
+
"`functions.exec` deferred-tool inventory and invoke that exact qualified tool",
|
|
4699
|
+
"from the exec program; do not look for or call a bare top-level `sdk` tool.",
|
|
4700
|
+
"If discovery or an invocation fails, report the recorded tool error; never",
|
|
4701
|
+
"declare the SDK absent without attempting discovery and invocation. The SDK",
|
|
4702
|
+
"call runs a TypeScript program against the ambient `cabane` object. The",
|
|
4560
4703
|
"turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
|
|
4561
|
-
"
|
|
4704
|
+
"qualified `mcp__cabane_companion__\u2026` tools (and may be deferred too). There is",
|
|
4705
|
+
"no Cabane `read`/`write`/`search`/`edit` tool here \u2014 those",
|
|
4562
4706
|
"are `cabane` SDK calls inside your program, not tools. If a tool appears in this",
|
|
4563
|
-
"prompt with an `mcp__\u2026__` prefix, that
|
|
4564
|
-
"
|
|
4707
|
+
"prompt with an `mcp__\u2026__` prefix, preserve that qualified name. Write your",
|
|
4708
|
+
"closing reply as the last thing you say in the",
|
|
4565
4709
|
"turn: you can interleave narration with tool calls, but only your final message",
|
|
4566
4710
|
"is recorded as the turn\u2019s reply."
|
|
4567
4711
|
].join(" ");
|
|
@@ -4588,7 +4732,7 @@ function readItemMessage(item) {
|
|
|
4588
4732
|
function isModelMetadataError(message) {
|
|
4589
4733
|
return message.includes("Defaulting to fallback metadata");
|
|
4590
4734
|
}
|
|
4591
|
-
function readToolItem(item) {
|
|
4735
|
+
function readToolItem(item, eventType) {
|
|
4592
4736
|
const type = str(item.type);
|
|
4593
4737
|
const id = str(item.id);
|
|
4594
4738
|
if (!type || !id) return null;
|
|
@@ -4632,7 +4776,12 @@ function readToolItem(item) {
|
|
|
4632
4776
|
}
|
|
4633
4777
|
if (type === "web_search") {
|
|
4634
4778
|
const query = str(item.query) ?? "";
|
|
4635
|
-
return {
|
|
4779
|
+
return {
|
|
4780
|
+
id,
|
|
4781
|
+
name: "web_search",
|
|
4782
|
+
input: { query },
|
|
4783
|
+
status: eventType === "item.completed" ? "completed" : "in_progress"
|
|
4784
|
+
};
|
|
4636
4785
|
}
|
|
4637
4786
|
return null;
|
|
4638
4787
|
}
|
|
@@ -4642,9 +4791,9 @@ function readItemType(item) {
|
|
|
4642
4791
|
function readErrorMessage(ev) {
|
|
4643
4792
|
const direct = str(ev.message);
|
|
4644
4793
|
if (direct) return direct;
|
|
4645
|
-
const
|
|
4646
|
-
if (
|
|
4647
|
-
const m = str(
|
|
4794
|
+
const err2 = asRecord2(ev.error);
|
|
4795
|
+
if (err2) {
|
|
4796
|
+
const m = str(err2.message);
|
|
4648
4797
|
if (m) return m;
|
|
4649
4798
|
}
|
|
4650
4799
|
return "unknown";
|
|
@@ -4700,7 +4849,7 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4700
4849
|
const startedTools = /* @__PURE__ */ new Set();
|
|
4701
4850
|
const finishedTools = /* @__PURE__ */ new Set();
|
|
4702
4851
|
let sessionEmitted = false;
|
|
4703
|
-
let
|
|
4852
|
+
let ok2 = false;
|
|
4704
4853
|
let reason;
|
|
4705
4854
|
let usage;
|
|
4706
4855
|
let settled = false;
|
|
@@ -4731,7 +4880,7 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4731
4880
|
const message = readItemMessage(item);
|
|
4732
4881
|
if (isModelMetadataError(message)) {
|
|
4733
4882
|
yield* flushInterim();
|
|
4734
|
-
|
|
4883
|
+
ok2 = false;
|
|
4735
4884
|
reason = `model_unavailable:${message.slice(0, 200)}`;
|
|
4736
4885
|
settled = true;
|
|
4737
4886
|
break;
|
|
@@ -4750,7 +4899,7 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4750
4899
|
if (text) yield { type: "thinking", text };
|
|
4751
4900
|
continue;
|
|
4752
4901
|
}
|
|
4753
|
-
const tool2 = readToolItem(item);
|
|
4902
|
+
const tool2 = readToolItem(item, ev.type);
|
|
4754
4903
|
if (!tool2) continue;
|
|
4755
4904
|
yield* flushInterim();
|
|
4756
4905
|
const name = prettyToolName(tool2.name);
|
|
@@ -4786,13 +4935,13 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4786
4935
|
held = null;
|
|
4787
4936
|
if (sealed) yield sealed;
|
|
4788
4937
|
usage = readUsage(ev);
|
|
4789
|
-
|
|
4938
|
+
ok2 = true;
|
|
4790
4939
|
settled = true;
|
|
4791
4940
|
break;
|
|
4792
4941
|
}
|
|
4793
4942
|
if (ev.type === "turn.failed" || ev.type === "error") {
|
|
4794
4943
|
yield* flushInterim();
|
|
4795
|
-
|
|
4944
|
+
ok2 = false;
|
|
4796
4945
|
const text = readErrorMessage(ev);
|
|
4797
4946
|
const failure = classifyErrorText(text);
|
|
4798
4947
|
reason = failure ? encodeFailureReason(failure) : `error:${text.slice(0, 200)}`;
|
|
@@ -4810,7 +4959,7 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4810
4959
|
const resolvedConfig = ctx.resolvedReasoningEffort ? { reasoningEffort: ctx.resolvedReasoningEffort } : void 0;
|
|
4811
4960
|
yield {
|
|
4812
4961
|
type: "result",
|
|
4813
|
-
ok,
|
|
4962
|
+
ok: ok2,
|
|
4814
4963
|
...reason ? { reason } : {},
|
|
4815
4964
|
...usage ? { usage } : {},
|
|
4816
4965
|
...ctx.resolvedModel ? { resolvedModel: ctx.resolvedModel } : {},
|
|
@@ -4830,12 +4979,15 @@ function sealHeld2(held, terminal) {
|
|
|
4830
4979
|
// packages/agent-runtime/src/codex/policy.ts
|
|
4831
4980
|
import { z as z12 } from "zod";
|
|
4832
4981
|
function codexToolPolicy(policy) {
|
|
4833
|
-
return {
|
|
4834
|
-
|
|
4982
|
+
return policy.hostFs ? {
|
|
4983
|
+
permissionProfile: "cabane-coding",
|
|
4984
|
+
approvalPolicy: "never",
|
|
4985
|
+
networkAccessEnabled: policy.web
|
|
4986
|
+
} : {
|
|
4987
|
+
sandboxMode: "read-only",
|
|
4835
4988
|
// Headless: the sandbox is the boundary; never pause for a human.
|
|
4836
4989
|
approvalPolicy: "never",
|
|
4837
|
-
//
|
|
4838
|
-
// Codex's shell commands may reach the network.
|
|
4990
|
+
// Retained in the policy value for symmetry; read-only ignores it.
|
|
4839
4991
|
networkAccessEnabled: policy.web
|
|
4840
4992
|
};
|
|
4841
4993
|
}
|
|
@@ -4858,6 +5010,7 @@ function parseCodexModel(model) {
|
|
|
4858
5010
|
|
|
4859
5011
|
// packages/agent-runtime/src/codex/run-spec.ts
|
|
4860
5012
|
var CABANE_MCP_SERVER3 = "cabane";
|
|
5013
|
+
var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
|
|
4861
5014
|
var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
|
|
4862
5015
|
function buildRunSpec2(req, resumeThreadId) {
|
|
4863
5016
|
const { policy, config } = req;
|
|
@@ -4888,13 +5041,15 @@ function buildConfig(req) {
|
|
|
4888
5041
|
if ("url" in server) {
|
|
4889
5042
|
mcp_servers[name] = {
|
|
4890
5043
|
url: server.url,
|
|
4891
|
-
...server.headers ? { http_headers: server.headers } : {}
|
|
5044
|
+
...server.headers ? { http_headers: server.headers } : {},
|
|
5045
|
+
default_tools_approval_mode: "approve"
|
|
4892
5046
|
};
|
|
4893
5047
|
} else if ("command" in server) {
|
|
4894
5048
|
mcp_servers[name] = {
|
|
4895
5049
|
command: server.command,
|
|
4896
5050
|
...server.args ? { args: server.args } : {},
|
|
4897
|
-
...server.env ? { env: server.env } : {}
|
|
5051
|
+
...server.env ? { env: server.env } : {},
|
|
5052
|
+
default_tools_approval_mode: "approve"
|
|
4898
5053
|
};
|
|
4899
5054
|
}
|
|
4900
5055
|
}
|
|
@@ -4903,12 +5058,14 @@ function buildConfig(req) {
|
|
|
4903
5058
|
if (typeof server.url === "string") {
|
|
4904
5059
|
mcp_servers[name] = {
|
|
4905
5060
|
url: server.url,
|
|
4906
|
-
...isStringRecord2(server.headers) ? { http_headers: server.headers } : {}
|
|
5061
|
+
...isStringRecord2(server.headers) ? { http_headers: server.headers } : {},
|
|
5062
|
+
default_tools_approval_mode: "approve"
|
|
4907
5063
|
};
|
|
4908
5064
|
} else if (typeof server.command === "string") {
|
|
4909
5065
|
mcp_servers[name] = {
|
|
4910
5066
|
command: server.command,
|
|
4911
|
-
...Array.isArray(server.args) ? { args: server.args } : {}
|
|
5067
|
+
...Array.isArray(server.args) ? { args: server.args } : {},
|
|
5068
|
+
default_tools_approval_mode: "approve"
|
|
4912
5069
|
};
|
|
4913
5070
|
}
|
|
4914
5071
|
}
|
|
@@ -4920,13 +5077,46 @@ function buildConfig(req) {
|
|
|
4920
5077
|
},
|
|
4921
5078
|
default_tools_approval_mode: "approve"
|
|
4922
5079
|
};
|
|
4923
|
-
|
|
5080
|
+
if (req.cabane.turnControlUrl) {
|
|
5081
|
+
mcp_servers[TURN_CONTROL_MCP_SERVER2] = {
|
|
5082
|
+
url: req.cabane.turnControlUrl,
|
|
5083
|
+
http_headers: {
|
|
5084
|
+
Authorization: `Bearer ${req.cabane.bearer}`,
|
|
5085
|
+
[ACTIVE_CONVERSATION_HEADER4]: req.cabane.activeConversationId
|
|
5086
|
+
},
|
|
5087
|
+
default_tools_approval_mode: "approve"
|
|
5088
|
+
};
|
|
5089
|
+
}
|
|
5090
|
+
const policy = codexToolPolicy(req.policy);
|
|
5091
|
+
return {
|
|
5092
|
+
mcp_servers,
|
|
5093
|
+
experimental_use_rmcp_client: true,
|
|
5094
|
+
...policy.permissionProfile ? {
|
|
5095
|
+
// CT733: named permission profiles are Codex's split-filesystem path.
|
|
5096
|
+
// `:root = read` preserves coding-mode host reads; the one explicit
|
|
5097
|
+
// workspace-root write grants the checkout, and the more-specific
|
|
5098
|
+
// `.git` write reopens the metadata Codex protects by default. Neither
|
|
5099
|
+
// rule grants an adjacent directory. Do not combine this with legacy `sandbox_mode` /
|
|
5100
|
+
// `sandbox_workspace_write`, which would restore the `.git` carve-out.
|
|
5101
|
+
approval_policy: policy.approvalPolicy,
|
|
5102
|
+
default_permissions: policy.permissionProfile,
|
|
5103
|
+
permissions: {
|
|
5104
|
+
[policy.permissionProfile]: {
|
|
5105
|
+
filesystem: {
|
|
5106
|
+
":root": "read",
|
|
5107
|
+
":workspace_roots": { ".": "write", ".git": "write" }
|
|
5108
|
+
},
|
|
5109
|
+
network: { enabled: policy.networkAccessEnabled, mode: "full" }
|
|
5110
|
+
}
|
|
5111
|
+
}
|
|
5112
|
+
} : {}
|
|
5113
|
+
};
|
|
4924
5114
|
}
|
|
4925
5115
|
function isStringRecord2(v) {
|
|
4926
5116
|
return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
|
|
4927
5117
|
}
|
|
4928
5118
|
|
|
4929
|
-
// node_modules/.pnpm/@openai+codex-sdk@0.
|
|
5119
|
+
// node_modules/.pnpm/@openai+codex-sdk@0.146.0/node_modules/@openai/codex-sdk/dist/index.js
|
|
4930
5120
|
import { promises as fs } from "fs";
|
|
4931
5121
|
import os from "os";
|
|
4932
5122
|
import path from "path";
|
|
@@ -5015,6 +5205,8 @@ var Thread = class {
|
|
|
5015
5205
|
}
|
|
5016
5206
|
if (parsed.type === "thread.started") {
|
|
5017
5207
|
this._id = parsed.thread_id;
|
|
5208
|
+
} else if (parsed.type === "turn.completed") {
|
|
5209
|
+
parsed.usage.cache_write_input_tokens ??= 0;
|
|
5018
5210
|
}
|
|
5019
5211
|
yield parsed;
|
|
5020
5212
|
}
|
|
@@ -5176,7 +5368,7 @@ var CodexExec = class {
|
|
|
5176
5368
|
signal: args.signal
|
|
5177
5369
|
});
|
|
5178
5370
|
let spawnError = null;
|
|
5179
|
-
child.once("error", (
|
|
5371
|
+
child.once("error", (err2) => spawnError = err2);
|
|
5180
5372
|
if (!child.stdin) {
|
|
5181
5373
|
child.kill();
|
|
5182
5374
|
throw new Error("Child process has no stdin");
|
|
@@ -5454,6 +5646,17 @@ var Codex = class {
|
|
|
5454
5646
|
};
|
|
5455
5647
|
|
|
5456
5648
|
// packages/agent-runtime/src/codex/transport.ts
|
|
5649
|
+
function buildSdkThreadOptions(spec) {
|
|
5650
|
+
return {
|
|
5651
|
+
...spec.model ? { model: spec.model } : {},
|
|
5652
|
+
...spec.policy.sandboxMode ? { sandboxMode: spec.policy.sandboxMode } : {},
|
|
5653
|
+
workingDirectory: spec.directory,
|
|
5654
|
+
skipGitRepoCheck: spec.skipGitRepoCheck,
|
|
5655
|
+
...spec.policy.sandboxMode ? { approvalPolicy: spec.policy.approvalPolicy } : {},
|
|
5656
|
+
...spec.policy.sandboxMode === "workspace-write" ? { networkAccessEnabled: spec.policy.networkAccessEnabled } : {},
|
|
5657
|
+
...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
|
|
5658
|
+
};
|
|
5659
|
+
}
|
|
5457
5660
|
function createSdkCodexTransport(opts = {}) {
|
|
5458
5661
|
return {
|
|
5459
5662
|
async run(spec, signal) {
|
|
@@ -5467,17 +5670,7 @@ function createSdkCodexTransport(opts = {}) {
|
|
|
5467
5670
|
config: spec.config
|
|
5468
5671
|
};
|
|
5469
5672
|
const codex = new Codex(codexOptions);
|
|
5470
|
-
const threadOptions =
|
|
5471
|
-
...spec.model ? { model: spec.model } : {},
|
|
5472
|
-
sandboxMode: spec.policy.sandboxMode,
|
|
5473
|
-
workingDirectory: spec.directory,
|
|
5474
|
-
skipGitRepoCheck: spec.skipGitRepoCheck,
|
|
5475
|
-
approvalPolicy: spec.policy.approvalPolicy,
|
|
5476
|
-
// `networkAccessEnabled` only bites under `workspace-write` (read-only
|
|
5477
|
-
// denies command network regardless); set it there off the `web` grant.
|
|
5478
|
-
...spec.policy.sandboxMode === "workspace-write" ? { networkAccessEnabled: spec.policy.networkAccessEnabled } : {},
|
|
5479
|
-
...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
|
|
5480
|
-
};
|
|
5673
|
+
const threadOptions = buildSdkThreadOptions(spec);
|
|
5481
5674
|
const thread = spec.resumeThreadId ? codex.resumeThread(spec.resumeThreadId, threadOptions) : codex.startThread(threadOptions);
|
|
5482
5675
|
const streamed = await thread.runStreamed(spec.input, { signal });
|
|
5483
5676
|
return { events: streamed.events };
|
|
@@ -5534,7 +5727,7 @@ var codexAdapter = createCodexAdapter();
|
|
|
5534
5727
|
// packages/agent-runtime/src/codex/conformance.ts
|
|
5535
5728
|
var ABORT_SENTINEL3 = { __abortHere: true };
|
|
5536
5729
|
var NEW_THREAD_ID = "th_new";
|
|
5537
|
-
var
|
|
5730
|
+
var COMPANION_POLICY2 = {
|
|
5538
5731
|
hostFs: false,
|
|
5539
5732
|
web: true,
|
|
5540
5733
|
browser: true,
|
|
@@ -5555,7 +5748,7 @@ function makeRequest3(overrides = {}) {
|
|
|
5555
5748
|
// emitted → their expected results stay unchanged; a dedicated capture fixture
|
|
5556
5749
|
// sets a real model + effort.
|
|
5557
5750
|
config: { model: null },
|
|
5558
|
-
policy:
|
|
5751
|
+
policy: COMPANION_POLICY2,
|
|
5559
5752
|
session: null,
|
|
5560
5753
|
cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
|
|
5561
5754
|
local: { cwd: DIR2 },
|
|
@@ -5719,6 +5912,75 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
5719
5912
|
{ type: "result", ok: true }
|
|
5720
5913
|
]
|
|
5721
5914
|
},
|
|
5915
|
+
{
|
|
5916
|
+
// CT715: web_search LIFECYCLE. Unlike the other three tool kinds, a `web_search`
|
|
5917
|
+
// item carries NO `status` field — completion is signaled by the frame TYPE
|
|
5918
|
+
// (`item.started` → `item.completed`). The `start` fires off the first frame
|
|
5919
|
+
// (empty query, no output card); the `done` must resolve off `item.completed`
|
|
5920
|
+
// and carry the populated query the completed frame filled in. Before CT715 the
|
|
5921
|
+
// card was pinned at "running" forever (status derived from a missing field).
|
|
5922
|
+
name: "web_search lifecycle \u2014 completes off frame type, populated query on done",
|
|
5923
|
+
request: makeRequest3(),
|
|
5924
|
+
nativeStream: [
|
|
5925
|
+
threadStarted(NEW_THREAD_ID),
|
|
5926
|
+
toolFrame("item.started", { id: "ws1", type: "web_search", query: "" }),
|
|
5927
|
+
toolFrame("item.completed", { id: "ws1", type: "web_search", query: "best pizza in nyc" }),
|
|
5928
|
+
turnCompleted()
|
|
5929
|
+
],
|
|
5930
|
+
expected: [
|
|
5931
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
5932
|
+
{
|
|
5933
|
+
type: "tool",
|
|
5934
|
+
id: "ws1",
|
|
5935
|
+
name: "web_search",
|
|
5936
|
+
phase: "start",
|
|
5937
|
+
summary: "",
|
|
5938
|
+
input: { query: "" }
|
|
5939
|
+
},
|
|
5940
|
+
{
|
|
5941
|
+
type: "tool",
|
|
5942
|
+
id: "ws1",
|
|
5943
|
+
name: "web_search",
|
|
5944
|
+
phase: "done",
|
|
5945
|
+
summary: "best pizza in nyc",
|
|
5946
|
+
input: { query: "best pizza in nyc" }
|
|
5947
|
+
},
|
|
5948
|
+
{ type: "result", ok: true }
|
|
5949
|
+
]
|
|
5950
|
+
},
|
|
5951
|
+
{
|
|
5952
|
+
// CT715: a non-text web action (`action.type: "other"`) legitimately completes
|
|
5953
|
+
// with an EMPTY query — that's Codex's own data, not our bug. It must still
|
|
5954
|
+
// resolve to `done` (empty query acceptable; stuck-running is not).
|
|
5955
|
+
name: "web_search lifecycle \u2014 empty-query completion still resolves to done",
|
|
5956
|
+
request: makeRequest3(),
|
|
5957
|
+
nativeStream: [
|
|
5958
|
+
threadStarted(NEW_THREAD_ID),
|
|
5959
|
+
toolFrame("item.started", { id: "ws2", type: "web_search", query: "" }),
|
|
5960
|
+
toolFrame("item.completed", { id: "ws2", type: "web_search", query: "" }),
|
|
5961
|
+
turnCompleted()
|
|
5962
|
+
],
|
|
5963
|
+
expected: [
|
|
5964
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
5965
|
+
{
|
|
5966
|
+
type: "tool",
|
|
5967
|
+
id: "ws2",
|
|
5968
|
+
name: "web_search",
|
|
5969
|
+
phase: "start",
|
|
5970
|
+
summary: "",
|
|
5971
|
+
input: { query: "" }
|
|
5972
|
+
},
|
|
5973
|
+
{
|
|
5974
|
+
type: "tool",
|
|
5975
|
+
id: "ws2",
|
|
5976
|
+
name: "web_search",
|
|
5977
|
+
phase: "done",
|
|
5978
|
+
summary: "",
|
|
5979
|
+
input: { query: "" }
|
|
5980
|
+
},
|
|
5981
|
+
{ type: "result", ok: true }
|
|
5982
|
+
]
|
|
5983
|
+
},
|
|
5722
5984
|
{
|
|
5723
5985
|
// HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
|
|
5724
5986
|
// adapter stops before the closing reply — no final text, no `result` event.
|
|
@@ -6013,15 +6275,35 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
6013
6275
|
// packages/agent-runtime/src/cabane-native/addendum.ts
|
|
6014
6276
|
var CABANE_NATIVE_ADDENDUM = `## Your tools (native runtime)
|
|
6015
6277
|
|
|
6016
|
-
You are running on Cabane's own agent runtime. You have a small,
|
|
6278
|
+
You are running on Cabane's own agent runtime. You have a small, curated set of workspace tools, all prefixed \`cabane_\`:
|
|
6017
6279
|
|
|
6018
6280
|
- \`cabane_list\` \u2014 list a folder's files and subfolders.
|
|
6019
6281
|
- \`cabane_read\` \u2014 read one file's contents by path.
|
|
6020
|
-
- \`cabane_search\` \u2014 substring search across file names and
|
|
6282
|
+
- \`cabane_search\` \u2014 substring search across file/folder names, file contents, and conversations.
|
|
6021
6283
|
- \`cabane_write\` \u2014 create or overwrite a file (pass \`overwrite: true\` to replace).
|
|
6022
6284
|
- \`cabane_edit\` \u2014 find/replace inside an existing file.
|
|
6285
|
+
- \`cabane_mkdir\` \u2014 create a folder (pass \`recursive: true\` to also make missing parents).
|
|
6286
|
+
- \`cabane_move\` \u2014 move or rename a file or folder.
|
|
6287
|
+
- \`cabane_delete\` \u2014 delete a file or folder (folders need \`recursive: true\`; soft-delete, recoverable).
|
|
6288
|
+
- \`cabane_context\` \u2014 gather an object plus its related context (files, conversations, people) in one call \u2014 your first move to understand what surrounds something.
|
|
6289
|
+
- \`cabane_messages\` \u2014 read a conversation's messages by id (from \`cabane_search\` / \`cabane_context\`).
|
|
6290
|
+
|
|
6291
|
+
Paths are workspace-relative with a leading slash (\`/notes/todo.md\`). This is a deliberately curated tool set \u2014 if a job needs an operation that isn't here, say so plainly in your reply rather than inventing a tool. Your reply text is streamed straight into the conversation; there is no separate send step.`;
|
|
6292
|
+
var CABANE_NATIVE_ADDENDUM_CODE_MODE = `## Your tools (native runtime \u2014 code mode)
|
|
6023
6293
|
|
|
6024
|
-
|
|
6294
|
+
You are running on Cabane's own agent runtime, in **code mode**. Your one workspace tool is \`sdk\`: you act on the workspace by writing a short TypeScript program against the ambient \`cabane\` object and submitting it as the \`code\` argument. Only what your program \`return\`s (plus \`console.log\`) comes back \u2014 so gather, filter, and summarize *inside* the program and return the small answer, not the raw material.
|
|
6295
|
+
|
|
6296
|
+
There is **no** \`cabane_read\` / \`cabane_write\` / \`cabane_search\` / \`cabane_edit\` tool \u2014 those are \`cabane\` SDK calls *inside* your program (\`cabane.files.read(path)\`, \`cabane.find.search(q)\`, \u2026), not tools you call directly. The full SDK surface \u2014 every method and its options \u2014 is documented below this note in your prompt.
|
|
6297
|
+
|
|
6298
|
+
Beside \`sdk\` you also have a few **plain-named** tools that act on the TURN itself, not the workspace (they can't be a program's \`return\` value):
|
|
6299
|
+
- \`ask\` \u2014 put a structured question to a human and END your turn (they may answer in days).
|
|
6300
|
+
- \`wake_me\` \u2014 end this turn now and be re-dispatched later to check a condition (a PR merging, a reply landing).
|
|
6301
|
+
- \`summon_agent\` \u2014 pull a peer agent into THIS conversation to reply on your turn.
|
|
6302
|
+
- \`sub_agent\` \u2014 spawn a private worker with a fresh context window; its result comes back here later (don't wait \u2014 end your turn).
|
|
6303
|
+
- \`skip_turn\` \u2014 end your turn with NO reply, when the message doesn't need one from you.
|
|
6304
|
+
- \`mint_render_token\` \u2014 mint a short-lived credential to load a workspace HTML page in a browser.
|
|
6305
|
+
|
|
6306
|
+
Your reply text is streamed straight into the conversation; there is no separate send step.`;
|
|
6025
6307
|
|
|
6026
6308
|
// packages/agent-runtime/src/cabane-native/context.ts
|
|
6027
6309
|
var DEFAULT_HISTORY_LIMIT = 20;
|
|
@@ -6086,6 +6368,18 @@ function parseCabaneNativeModel(model) {
|
|
|
6086
6368
|
return model.startsWith(CABANE_NATIVE_MODEL_PREFIX) ? model.slice(CABANE_NATIVE_MODEL_PREFIX.length) : model;
|
|
6087
6369
|
}
|
|
6088
6370
|
|
|
6371
|
+
// packages/agent-runtime/src/cabane-native/policy.ts
|
|
6372
|
+
import { z as z13 } from "zod";
|
|
6373
|
+
var NATIVE_SURFACES = ["code", "classic"];
|
|
6374
|
+
var cabaneNativeDialectSchema = z13.object({ surface: z13.enum(NATIVE_SURFACES).optional() }).loose();
|
|
6375
|
+
function readCabaneNativeDialect(runtimeOptions) {
|
|
6376
|
+
const parsed = cabaneNativeDialectSchema.safeParse(runtimeOptions?.["cabane-native"] ?? {});
|
|
6377
|
+
return parsed.success ? parsed.data : {};
|
|
6378
|
+
}
|
|
6379
|
+
function cabaneNativeSurface(runtimeOptions) {
|
|
6380
|
+
return readCabaneNativeDialect(runtimeOptions).surface ?? "code";
|
|
6381
|
+
}
|
|
6382
|
+
|
|
6089
6383
|
// packages/agent-runtime/src/cabane-native/tools.ts
|
|
6090
6384
|
var TOOL_RESULT_MAX_CHARS = 8e3;
|
|
6091
6385
|
var CABANE_NATIVE_TOOLS = [
|
|
@@ -6169,11 +6463,157 @@ var CABANE_NATIVE_TOOLS = [
|
|
|
6169
6463
|
required: ["path", "find", "replace"]
|
|
6170
6464
|
}
|
|
6171
6465
|
}
|
|
6466
|
+
},
|
|
6467
|
+
{
|
|
6468
|
+
type: "function",
|
|
6469
|
+
function: {
|
|
6470
|
+
name: "cabane_mkdir",
|
|
6471
|
+
description: "Create a folder at a workspace path. Pass `recursive: true` to also create any missing parent folders (like `mkdir -p`); otherwise the parent must already exist.",
|
|
6472
|
+
parameters: {
|
|
6473
|
+
type: "object",
|
|
6474
|
+
properties: {
|
|
6475
|
+
path: { type: "string", description: "Workspace folder path, e.g. /notes/archive." },
|
|
6476
|
+
recursive: {
|
|
6477
|
+
type: "boolean",
|
|
6478
|
+
description: "Create missing parent folders too (default false)."
|
|
6479
|
+
}
|
|
6480
|
+
},
|
|
6481
|
+
required: ["path"]
|
|
6482
|
+
}
|
|
6483
|
+
}
|
|
6484
|
+
},
|
|
6485
|
+
{
|
|
6486
|
+
type: "function",
|
|
6487
|
+
function: {
|
|
6488
|
+
name: "cabane_move",
|
|
6489
|
+
description: "Move and/or rename a file or folder. `fromPath` is the current path; `toPath` is the new full path (its parent must already exist). Same parent + new name renames in place; a different parent moves it.",
|
|
6490
|
+
parameters: {
|
|
6491
|
+
type: "object",
|
|
6492
|
+
properties: {
|
|
6493
|
+
fromPath: { type: "string", description: "The current file/folder path." },
|
|
6494
|
+
toPath: { type: "string", description: "The new full path (parent + name)." }
|
|
6495
|
+
},
|
|
6496
|
+
required: ["fromPath", "toPath"]
|
|
6497
|
+
}
|
|
6498
|
+
}
|
|
6499
|
+
},
|
|
6500
|
+
{
|
|
6501
|
+
type: "function",
|
|
6502
|
+
function: {
|
|
6503
|
+
name: "cabane_delete",
|
|
6504
|
+
description: "Delete the file or folder at a workspace path. Folders require `recursive: true` (deletes everything inside). Soft delete \u2014 the entry is recoverable from the trash, not erased.",
|
|
6505
|
+
parameters: {
|
|
6506
|
+
type: "object",
|
|
6507
|
+
properties: {
|
|
6508
|
+
path: { type: "string", description: "Workspace file/folder path to delete." },
|
|
6509
|
+
recursive: {
|
|
6510
|
+
type: "boolean",
|
|
6511
|
+
description: "Required to delete a non-empty folder (default false)."
|
|
6512
|
+
}
|
|
6513
|
+
},
|
|
6514
|
+
required: ["path"]
|
|
6515
|
+
}
|
|
6516
|
+
}
|
|
6517
|
+
},
|
|
6518
|
+
{
|
|
6519
|
+
type: "function",
|
|
6520
|
+
function: {
|
|
6521
|
+
name: "cabane_context",
|
|
6522
|
+
description: "Gather a workspace object plus its most relevant connected context \u2014 related files, conversations, people \u2014 in one call. Your first move to understand what surrounds something before acting. `ref` is the object id, or its workspace path when `kind` is `file` or `folder`.",
|
|
6523
|
+
parameters: {
|
|
6524
|
+
type: "object",
|
|
6525
|
+
properties: {
|
|
6526
|
+
kind: {
|
|
6527
|
+
type: "string",
|
|
6528
|
+
enum: ["conversation", "file", "folder", "user", "agent", "channel"],
|
|
6529
|
+
description: "The kind of object to orient around."
|
|
6530
|
+
},
|
|
6531
|
+
ref: {
|
|
6532
|
+
type: "string",
|
|
6533
|
+
description: "The object id (or the workspace path for a file/folder)."
|
|
6534
|
+
}
|
|
6535
|
+
},
|
|
6536
|
+
required: ["kind", "ref"]
|
|
6537
|
+
}
|
|
6538
|
+
}
|
|
6539
|
+
},
|
|
6540
|
+
{
|
|
6541
|
+
type: "function",
|
|
6542
|
+
function: {
|
|
6543
|
+
name: "cabane_messages",
|
|
6544
|
+
description: 'Read a page of messages from a conversation by id (get ids from `cabane_search` or `cabane_context`). `order: "desc"` (default) returns newest-first \u2014 where a thread is now; `order: "asc"` returns oldest-first \u2014 how it began. Your own current thread is already in context; use this to catch up on other conversations or to page further back.',
|
|
6545
|
+
parameters: {
|
|
6546
|
+
type: "object",
|
|
6547
|
+
properties: {
|
|
6548
|
+
conversationId: { type: "string", description: "The conversation id to read." },
|
|
6549
|
+
order: {
|
|
6550
|
+
type: "string",
|
|
6551
|
+
enum: ["asc", "desc"],
|
|
6552
|
+
description: "newest-first (desc, default) or oldest-first (asc)."
|
|
6553
|
+
},
|
|
6554
|
+
limit: {
|
|
6555
|
+
type: "number",
|
|
6556
|
+
description: "Max messages to return (default 50, max 100)."
|
|
6557
|
+
}
|
|
6558
|
+
},
|
|
6559
|
+
required: ["conversationId"]
|
|
6560
|
+
}
|
|
6561
|
+
}
|
|
6172
6562
|
}
|
|
6173
6563
|
];
|
|
6564
|
+
var CABANE_NATIVE_CODE_TOOL = {
|
|
6565
|
+
type: "function",
|
|
6566
|
+
function: {
|
|
6567
|
+
name: "sdk",
|
|
6568
|
+
description: "Run a TypeScript program against the workspace. `code` is the program \u2014 the body of an async function, so top-level `await` works and `return <value>` produces the result. Write against the ambient `cabane` object (no imports); only what you `return` plus `console.log` comes back, so gather/filter/summarize inside the program and return the small answer. The full `cabane` SDK surface \u2014 every method and its options \u2014 is documented in your system prompt above.",
|
|
6569
|
+
parameters: {
|
|
6570
|
+
type: "object",
|
|
6571
|
+
properties: {
|
|
6572
|
+
code: {
|
|
6573
|
+
type: "string",
|
|
6574
|
+
description: "The TypeScript program to run (an async function body)."
|
|
6575
|
+
}
|
|
6576
|
+
},
|
|
6577
|
+
required: ["code"]
|
|
6578
|
+
}
|
|
6579
|
+
}
|
|
6580
|
+
};
|
|
6581
|
+
var CABANE_NATIVE_RENDER_TOKEN_TOOL = {
|
|
6582
|
+
type: "function",
|
|
6583
|
+
function: {
|
|
6584
|
+
name: "mint_render_token",
|
|
6585
|
+
description: "Mint a short-lived, scoped credential that lets a browser load a workspace HTML page at the content-isolation origin. Returns `{ url, cookieName, cookieValue, expiresAt, pathPrefix }`. `pathPrefix` (default `/`) narrows the grant to a folder or single file; `ttlSeconds` (default 30 min, max 86400) overrides the TTL.",
|
|
6586
|
+
parameters: {
|
|
6587
|
+
type: "object",
|
|
6588
|
+
properties: {
|
|
6589
|
+
pathPrefix: {
|
|
6590
|
+
type: "string",
|
|
6591
|
+
description: "Workspace-rooted path-prefix the token authorizes; `/` for whole-workspace."
|
|
6592
|
+
},
|
|
6593
|
+
ttlSeconds: {
|
|
6594
|
+
type: "number",
|
|
6595
|
+
description: "Override the default 30-minute TTL; capped at 24h (86400s)."
|
|
6596
|
+
}
|
|
6597
|
+
}
|
|
6598
|
+
}
|
|
6599
|
+
}
|
|
6600
|
+
};
|
|
6174
6601
|
function summarizeCabaneToolArgs(name, args) {
|
|
6175
|
-
if (name === "
|
|
6176
|
-
|
|
6602
|
+
if (name === "mint_render_token") return str2(args.pathPrefix) ?? "/";
|
|
6603
|
+
if (name === "sdk") {
|
|
6604
|
+
const code = str2(args.code) ?? "";
|
|
6605
|
+
const firstLine = code.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
6606
|
+
return firstLine.length > 80 ? `${firstLine.slice(0, 80)}\u2026` : firstLine;
|
|
6607
|
+
}
|
|
6608
|
+
if (name === "cabane_search") return str2(args.q) ?? "";
|
|
6609
|
+
if (name === "cabane_move") {
|
|
6610
|
+
const from = str2(args.fromPath) ?? "";
|
|
6611
|
+
const to = str2(args.toPath) ?? "";
|
|
6612
|
+
return from && to ? `${from} \u2192 ${to}` : from || to;
|
|
6613
|
+
}
|
|
6614
|
+
if (name === "cabane_context") return str2(args.ref) ?? "";
|
|
6615
|
+
if (name === "cabane_messages") return str2(args.conversationId) ?? "";
|
|
6616
|
+
return str2(args.path) ?? "";
|
|
6177
6617
|
}
|
|
6178
6618
|
async function executeCabaneNativeTool(name, args, ctx, signal) {
|
|
6179
6619
|
const doFetch = ctx.fetchImpl ?? fetch;
|
|
@@ -6192,10 +6632,10 @@ async function executeCabaneNativeTool(name, args, ctx, signal) {
|
|
|
6192
6632
|
...init2?.body !== void 0 ? { body: JSON.stringify(init2.body) } : {},
|
|
6193
6633
|
signal
|
|
6194
6634
|
});
|
|
6195
|
-
} catch (
|
|
6635
|
+
} catch (err2) {
|
|
6196
6636
|
return {
|
|
6197
6637
|
ok: false,
|
|
6198
|
-
result: `error: request failed: ${
|
|
6638
|
+
result: `error: request failed: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
6199
6639
|
};
|
|
6200
6640
|
}
|
|
6201
6641
|
const contentType = res.headers.get("content-type") ?? "";
|
|
@@ -6204,6 +6644,20 @@ async function executeCabaneNativeTool(name, args, ctx, signal) {
|
|
|
6204
6644
|
return { ok: true, result: truncate2(text) };
|
|
6205
6645
|
};
|
|
6206
6646
|
switch (name) {
|
|
6647
|
+
case "sdk":
|
|
6648
|
+
return call("POST", "/sdk", {
|
|
6649
|
+
body: {
|
|
6650
|
+
program: str2(args.code) ?? "",
|
|
6651
|
+
...ctx.conversationId ? { activeConversationId: ctx.conversationId } : {}
|
|
6652
|
+
}
|
|
6653
|
+
});
|
|
6654
|
+
case "mint_render_token":
|
|
6655
|
+
return call("POST", "/render-tokens", {
|
|
6656
|
+
body: {
|
|
6657
|
+
pathPrefix: str2(args.pathPrefix) ?? "/",
|
|
6658
|
+
...typeof args.ttlSeconds === "number" ? { ttlSeconds: args.ttlSeconds } : {}
|
|
6659
|
+
}
|
|
6660
|
+
});
|
|
6207
6661
|
case "cabane_list":
|
|
6208
6662
|
return call("GET", "/files/tree", { query: { path: str2(args.path) ?? "/" } });
|
|
6209
6663
|
case "cabane_read":
|
|
@@ -6227,6 +6681,39 @@ async function executeCabaneNativeTool(name, args, ctx, signal) {
|
|
|
6227
6681
|
...args.replaceAll === true ? { replaceAll: true } : {}
|
|
6228
6682
|
}
|
|
6229
6683
|
});
|
|
6684
|
+
case "cabane_mkdir":
|
|
6685
|
+
return call("POST", "/folders", {
|
|
6686
|
+
body: {
|
|
6687
|
+
path: str2(args.path),
|
|
6688
|
+
...args.recursive === true ? { recursive: true } : {}
|
|
6689
|
+
}
|
|
6690
|
+
});
|
|
6691
|
+
case "cabane_move":
|
|
6692
|
+
return call("POST", "/entries/move", {
|
|
6693
|
+
body: { fromPath: str2(args.fromPath), toPath: str2(args.toPath) }
|
|
6694
|
+
});
|
|
6695
|
+
case "cabane_delete":
|
|
6696
|
+
return call("DELETE", "/entries", {
|
|
6697
|
+
query: {
|
|
6698
|
+
path: str2(args.path),
|
|
6699
|
+
recursive: args.recursive === true ? "true" : void 0
|
|
6700
|
+
}
|
|
6701
|
+
});
|
|
6702
|
+
case "cabane_context":
|
|
6703
|
+
return call("POST", "/graph/query", {
|
|
6704
|
+
body: { preset: "orient", args: { kind: str2(args.kind), ref: str2(args.ref) } }
|
|
6705
|
+
});
|
|
6706
|
+
case "cabane_messages":
|
|
6707
|
+
return call(
|
|
6708
|
+
"GET",
|
|
6709
|
+
`/conversations/${encodeURIComponent(str2(args.conversationId) ?? "")}/messages`,
|
|
6710
|
+
{
|
|
6711
|
+
query: {
|
|
6712
|
+
order: str2(args.order),
|
|
6713
|
+
limit: typeof args.limit === "number" ? String(args.limit) : void 0
|
|
6714
|
+
}
|
|
6715
|
+
}
|
|
6716
|
+
);
|
|
6230
6717
|
default:
|
|
6231
6718
|
return { ok: false, result: `error: unknown tool "${name}"` };
|
|
6232
6719
|
}
|
|
@@ -6239,6 +6726,238 @@ function truncate2(s) {
|
|
|
6239
6726
|
\u2026 [truncated]` : s;
|
|
6240
6727
|
}
|
|
6241
6728
|
|
|
6729
|
+
// packages/agent-runtime/src/cabane-native/turn-control.ts
|
|
6730
|
+
function describeSubAgentError(status2, body) {
|
|
6731
|
+
const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
|
|
6732
|
+
switch (code) {
|
|
6733
|
+
case "callout_cap_exceeded":
|
|
6734
|
+
return "sub_agent: you already have the maximum open sub-agents for this thread. Wait for some to return \u2014 you're woken once they're all back \u2014 before spawning more.";
|
|
6735
|
+
case "callout_depth_exceeded":
|
|
6736
|
+
return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
|
|
6737
|
+
case "dispatch_agent_not_found":
|
|
6738
|
+
return "sub_agent: no live agent in this workspace matches that `agentId`. Check `list_agents`, or omit `agentId` to spawn yourself.";
|
|
6739
|
+
case "dispatch_return_requires_turn":
|
|
6740
|
+
case "dispatch_return_requires_agent":
|
|
6741
|
+
case "dispatch_return_requires_dispatch":
|
|
6742
|
+
return `sub_agent: the spawn was rejected (${code}). This is a turn-context problem, not something to retry blindly \u2014 report it rather than looping.`;
|
|
6743
|
+
default:
|
|
6744
|
+
return `sub_agent: the spawn failed (${code ?? `HTTP ${status2}`}).`;
|
|
6745
|
+
}
|
|
6746
|
+
}
|
|
6747
|
+
var CABANE_NATIVE_TURN_CONTROL_TOOLS = [
|
|
6748
|
+
{
|
|
6749
|
+
type: "function",
|
|
6750
|
+
function: {
|
|
6751
|
+
name: "ask",
|
|
6752
|
+
description: "Ask a HUMAN a structured question you need answered to continue, then END your turn (do not wait \u2014 a reply may take days). Pass `targetUserId` (a workspace member id). Single form: a one-sentence `headline` (the question, capitalized, ending in `?`) + a short `question` body of framing, with 2\u20134 `options` when the answer is a bounded/yes-no choice. Or `questions`: 1\u20135 items each `{ headline, body?, options? }` when several decisions land at once. Provide EITHER `question` or `questions`, never both. Your surrounding context goes in your reply; the ask carries the question.",
|
|
6753
|
+
parameters: {
|
|
6754
|
+
type: "object",
|
|
6755
|
+
properties: {
|
|
6756
|
+
targetUserId: {
|
|
6757
|
+
type: "string",
|
|
6758
|
+
description: "The workspace member (human) to ask \u2014 a user id from the roster."
|
|
6759
|
+
},
|
|
6760
|
+
question: {
|
|
6761
|
+
type: "string",
|
|
6762
|
+
description: "Single-question form: a short body of framing (one or two sentences)."
|
|
6763
|
+
},
|
|
6764
|
+
headline: {
|
|
6765
|
+
type: "string",
|
|
6766
|
+
description: "Single-question form: the question itself as one clear capitalized sentence ending in `?`."
|
|
6767
|
+
},
|
|
6768
|
+
options: {
|
|
6769
|
+
type: "array",
|
|
6770
|
+
items: { type: "string" },
|
|
6771
|
+
description: "Single-question form: 2\u20134 suggested one-click answers."
|
|
6772
|
+
},
|
|
6773
|
+
questions: {
|
|
6774
|
+
type: "array",
|
|
6775
|
+
items: {
|
|
6776
|
+
type: "object",
|
|
6777
|
+
properties: {
|
|
6778
|
+
headline: { type: "string" },
|
|
6779
|
+
body: { type: "string" },
|
|
6780
|
+
options: { type: "array", items: { type: "string" } }
|
|
6781
|
+
},
|
|
6782
|
+
required: ["headline"]
|
|
6783
|
+
},
|
|
6784
|
+
description: "Multi-question form: 1\u20135 questions to ask at once."
|
|
6785
|
+
}
|
|
6786
|
+
},
|
|
6787
|
+
required: ["targetUserId"]
|
|
6788
|
+
}
|
|
6789
|
+
}
|
|
6790
|
+
},
|
|
6791
|
+
{
|
|
6792
|
+
type: "function",
|
|
6793
|
+
function: {
|
|
6794
|
+
name: "wake_me",
|
|
6795
|
+
description: 'Wake yourself later \u2014 end this turn now and be re-dispatched at a time you pick, to CHECK a condition that has not happened yet (a PR merging, a reply landing, "check back in five minutes"). Pass EXACTLY ONE of `afterSeconds` (a relative delay, \u226560) or `at` (an absolute ISO-8601 timestamp WITH a zone you compute yourself). `note` is the message to your future self \u2014 write the condition to re-check. It arms when the turn ends; one wake per turn.',
|
|
6796
|
+
parameters: {
|
|
6797
|
+
type: "object",
|
|
6798
|
+
properties: {
|
|
6799
|
+
afterSeconds: {
|
|
6800
|
+
type: "number",
|
|
6801
|
+
description: "Relative delay in seconds from when this turn ends (floor 60)."
|
|
6802
|
+
},
|
|
6803
|
+
at: {
|
|
6804
|
+
type: "string",
|
|
6805
|
+
description: "Absolute ISO-8601 timestamp with a zone (e.g. 2026-07-16T09:00:00-07:00)."
|
|
6806
|
+
},
|
|
6807
|
+
note: {
|
|
6808
|
+
type: "string",
|
|
6809
|
+
description: "A note to your future self \u2014 becomes the wake body (the condition to re-check)."
|
|
6810
|
+
}
|
|
6811
|
+
},
|
|
6812
|
+
required: ["note"]
|
|
6813
|
+
}
|
|
6814
|
+
}
|
|
6815
|
+
},
|
|
6816
|
+
{
|
|
6817
|
+
type: "function",
|
|
6818
|
+
function: {
|
|
6819
|
+
name: "summon_agent",
|
|
6820
|
+
description: "Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Pass the peer `agentId` (from the agent roster). The peer is dispatched on your final reply and picks up this conversation as context, so write the context/ask into your reply first. Writing `@handle` in prose summons no one \u2014 this tool is the only in-thread lever. Single target, last call wins; summoning yourself is a no-op.",
|
|
6821
|
+
parameters: {
|
|
6822
|
+
type: "object",
|
|
6823
|
+
properties: {
|
|
6824
|
+
agentId: {
|
|
6825
|
+
type: "string",
|
|
6826
|
+
description: "The peer agent to summon \u2014 a workspace agent id from the roster."
|
|
6827
|
+
}
|
|
6828
|
+
},
|
|
6829
|
+
required: ["agentId"]
|
|
6830
|
+
}
|
|
6831
|
+
}
|
|
6832
|
+
},
|
|
6833
|
+
{
|
|
6834
|
+
type: "function",
|
|
6835
|
+
function: {
|
|
6836
|
+
name: "sub_agent",
|
|
6837
|
+
description: "Spawn a sub-agent \u2014 hand a piece of work to a private worker with a fresh context window whose result comes back to you automatically. It does NOT return inline: this returns the child's id immediately and the outcome lands LATER as a message in this conversation (you're woken once every sub-agent you have out here has returned). So don't wait \u2014 finish what else this turn can do and end your turn. `prompt` is the child's self-contained opening instruction; `agentId` (optional) dispatches a peer instead of yourself; `title` (optional) names the child thread. Reach for it to isolate a big read or fan out N independent pieces in parallel.",
|
|
6838
|
+
parameters: {
|
|
6839
|
+
type: "object",
|
|
6840
|
+
properties: {
|
|
6841
|
+
prompt: {
|
|
6842
|
+
type: "string",
|
|
6843
|
+
description: "The sub-agent's self-contained opening instruction."
|
|
6844
|
+
},
|
|
6845
|
+
agentId: {
|
|
6846
|
+
type: "string",
|
|
6847
|
+
description: "Optional peer to run the sub-agent as; omit to spawn yourself."
|
|
6848
|
+
},
|
|
6849
|
+
title: { type: "string", description: "Optional title for the child thread." }
|
|
6850
|
+
},
|
|
6851
|
+
required: ["prompt"]
|
|
6852
|
+
}
|
|
6853
|
+
}
|
|
6854
|
+
},
|
|
6855
|
+
{
|
|
6856
|
+
type: "function",
|
|
6857
|
+
function: {
|
|
6858
|
+
name: "skip_turn",
|
|
6859
|
+
description: "End your current turn WITHOUT posting a reply. Call this when you've been dispatched but the message genuinely doesn't need a response from you \u2014 an aside, a question already answered, a pile-on someone else has. Your turn ends silently (no message bubble). `reason` is a short free-text note for telemetry. Prefer this over posting a low-value \"ok!\"; skipping IS the whole turn.",
|
|
6860
|
+
parameters: {
|
|
6861
|
+
type: "object",
|
|
6862
|
+
properties: {
|
|
6863
|
+
reason: {
|
|
6864
|
+
type: "string",
|
|
6865
|
+
description: "Short reason you are declining \u2014 used for telemetry."
|
|
6866
|
+
}
|
|
6867
|
+
},
|
|
6868
|
+
required: ["reason"]
|
|
6869
|
+
}
|
|
6870
|
+
}
|
|
6871
|
+
}
|
|
6872
|
+
];
|
|
6873
|
+
var CABANE_NATIVE_TURN_CONTROL_NAMES = new Set(
|
|
6874
|
+
CABANE_NATIVE_TURN_CONTROL_TOOLS.map((t) => t.function.name)
|
|
6875
|
+
);
|
|
6876
|
+
function summarizeTurnControlArgs(name, args) {
|
|
6877
|
+
if (name === "ask") return str3(args.headline) ?? str3(args.question) ?? "";
|
|
6878
|
+
if (name === "wake_me") return str3(args.note) ?? "";
|
|
6879
|
+
if (name === "summon_agent") return str3(args.agentId) ?? "";
|
|
6880
|
+
if (name === "sub_agent") return str3(args.title) ?? truncateHead(str3(args.prompt) ?? "", 60);
|
|
6881
|
+
if (name === "skip_turn") return str3(args.reason) ?? "";
|
|
6882
|
+
return "";
|
|
6883
|
+
}
|
|
6884
|
+
async function executeNativeTurnControl(name, args, tc) {
|
|
6885
|
+
switch (name) {
|
|
6886
|
+
case "summon_agent": {
|
|
6887
|
+
const agentId = str3(args.agentId);
|
|
6888
|
+
if (!agentId) return err("summon_agent: `agentId` is required.");
|
|
6889
|
+
tc.summon(agentId);
|
|
6890
|
+
return ok({ summoned: agentId });
|
|
6891
|
+
}
|
|
6892
|
+
case "skip_turn": {
|
|
6893
|
+
const reason = str3(args.reason);
|
|
6894
|
+
if (!reason) return err("skip_turn: `reason` is required.");
|
|
6895
|
+
tc.skip(reason);
|
|
6896
|
+
return ok({ skipped: true });
|
|
6897
|
+
}
|
|
6898
|
+
case "ask": {
|
|
6899
|
+
const targetUserId = str3(args.targetUserId);
|
|
6900
|
+
if (!targetUserId) return err("ask: `targetUserId` is required.");
|
|
6901
|
+
const hasSingle = args.question !== void 0;
|
|
6902
|
+
const hasArray = Array.isArray(args.questions) && args.questions.length > 0;
|
|
6903
|
+
if (hasSingle && hasArray)
|
|
6904
|
+
return err("ask: provide either `question` or `questions`, not both.");
|
|
6905
|
+
if (!hasSingle && !hasArray) return err("ask: provide `question` or `questions`.");
|
|
6906
|
+
const payload = hasArray ? { targetUserId, questions: args.questions } : {
|
|
6907
|
+
targetUserId,
|
|
6908
|
+
question: str3(args.question),
|
|
6909
|
+
...str3(args.headline) ? { headline: str3(args.headline) } : {},
|
|
6910
|
+
...Array.isArray(args.options) ? { options: args.options } : {}
|
|
6911
|
+
};
|
|
6912
|
+
tc.ask(payload);
|
|
6913
|
+
return ok({ asked: targetUserId });
|
|
6914
|
+
}
|
|
6915
|
+
case "wake_me": {
|
|
6916
|
+
const note = str3(args.note);
|
|
6917
|
+
if (!note) return err("wake_me: `note` is required.");
|
|
6918
|
+
const hasAfter = typeof args.afterSeconds === "number";
|
|
6919
|
+
const hasAt = typeof args.at === "string";
|
|
6920
|
+
if (hasAfter && hasAt)
|
|
6921
|
+
return err("wake_me: provide either `afterSeconds` or `at`, not both.");
|
|
6922
|
+
if (!hasAfter && !hasAt) return err("wake_me: provide `afterSeconds` or `at`.");
|
|
6923
|
+
tc.wake({
|
|
6924
|
+
...hasAfter ? { afterSeconds: args.afterSeconds } : {},
|
|
6925
|
+
...hasAt ? { at: args.at } : {},
|
|
6926
|
+
note
|
|
6927
|
+
});
|
|
6928
|
+
return ok({ armed: hasAt ? { at: args.at } : { afterSeconds: args.afterSeconds } });
|
|
6929
|
+
}
|
|
6930
|
+
case "sub_agent": {
|
|
6931
|
+
const prompt = str3(args.prompt);
|
|
6932
|
+
if (!prompt) return err("sub_agent: `prompt` is required.");
|
|
6933
|
+
const result = await tc.subAgent({
|
|
6934
|
+
prompt,
|
|
6935
|
+
...str3(args.agentId) ? { agentId: str3(args.agentId) } : {},
|
|
6936
|
+
...str3(args.title) ? { title: str3(args.title) } : {}
|
|
6937
|
+
});
|
|
6938
|
+
if (!result.ok) return err(result.error);
|
|
6939
|
+
return ok({
|
|
6940
|
+
conversationId: result.conversationId,
|
|
6941
|
+
note: "Spawned. Don't wait \u2014 finish what else this turn can do, then end your turn; the result posts back here."
|
|
6942
|
+
});
|
|
6943
|
+
}
|
|
6944
|
+
default:
|
|
6945
|
+
return err(`unknown turn-control tool "${name}"`);
|
|
6946
|
+
}
|
|
6947
|
+
}
|
|
6948
|
+
function ok(payload) {
|
|
6949
|
+
return { ok: true, result: JSON.stringify(payload) };
|
|
6950
|
+
}
|
|
6951
|
+
function err(message) {
|
|
6952
|
+
return { ok: false, result: `error: ${message}` };
|
|
6953
|
+
}
|
|
6954
|
+
function str3(v) {
|
|
6955
|
+
return typeof v === "string" ? v : void 0;
|
|
6956
|
+
}
|
|
6957
|
+
function truncateHead(s, n) {
|
|
6958
|
+
return s.length > n ? `${s.slice(0, n)}\u2026` : s;
|
|
6959
|
+
}
|
|
6960
|
+
|
|
6242
6961
|
// packages/agent-runtime/src/cabane-native/loop.ts
|
|
6243
6962
|
var DEFAULT_MAX_ITERATIONS = 12;
|
|
6244
6963
|
async function* runCabaneNativeTurn(req, signal, deps) {
|
|
@@ -6247,10 +6966,20 @@ async function* runCabaneNativeTurn(req, signal, deps) {
|
|
|
6247
6966
|
return;
|
|
6248
6967
|
}
|
|
6249
6968
|
const model = parseCabaneNativeModel(req.config.model);
|
|
6969
|
+
const surface = cabaneNativeSurface(req.config.runtimeOptions);
|
|
6970
|
+
const turnControl = req.extra.turnControl;
|
|
6971
|
+
const tools = surface === "code" ? [
|
|
6972
|
+
CABANE_NATIVE_CODE_TOOL,
|
|
6973
|
+
CABANE_NATIVE_RENDER_TOKEN_TOOL,
|
|
6974
|
+
...turnControl ? CABANE_NATIVE_TURN_CONTROL_TOOLS : []
|
|
6975
|
+
] : CABANE_NATIVE_TOOLS;
|
|
6250
6976
|
const toolCtx = {
|
|
6251
6977
|
apiRoot: deps.apiRoot,
|
|
6252
6978
|
workspaceId: deps.workspaceId,
|
|
6253
6979
|
bearer: deps.bearer,
|
|
6980
|
+
// CT666: the origin conversation, forwarded by the `sdk` tool to the `/sdk`
|
|
6981
|
+
// endpoint so a code-mode program's returning callout binds to this turn.
|
|
6982
|
+
...deps.conversationId ? { conversationId: deps.conversationId } : {},
|
|
6254
6983
|
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
|
|
6255
6984
|
};
|
|
6256
6985
|
const messages = await assembleMessages(req.systemPrompt, req.content, req.prompt, {
|
|
@@ -6270,10 +6999,7 @@ async function* runCabaneNativeTurn(req, signal, deps) {
|
|
|
6270
6999
|
const toolAcc = /* @__PURE__ */ new Map();
|
|
6271
7000
|
let finishReason;
|
|
6272
7001
|
let errored2;
|
|
6273
|
-
for await (const ev of deps.provider.stream(
|
|
6274
|
-
{ model, messages, tools: CABANE_NATIVE_TOOLS },
|
|
6275
|
-
signal
|
|
6276
|
-
)) {
|
|
7002
|
+
for await (const ev of deps.provider.stream({ model, messages, tools }, signal)) {
|
|
6277
7003
|
if (signal.aborted) return;
|
|
6278
7004
|
switch (ev.type) {
|
|
6279
7005
|
case "text":
|
|
@@ -6288,7 +7014,11 @@ async function* runCabaneNativeTurn(req, signal, deps) {
|
|
|
6288
7014
|
break;
|
|
6289
7015
|
}
|
|
6290
7016
|
case "usage":
|
|
6291
|
-
usage = {
|
|
7017
|
+
usage = {
|
|
7018
|
+
inputTokens: ev.inputTokens,
|
|
7019
|
+
outputTokens: ev.outputTokens,
|
|
7020
|
+
contextTokens: ev.inputTokens
|
|
7021
|
+
};
|
|
6292
7022
|
break;
|
|
6293
7023
|
case "model":
|
|
6294
7024
|
resolvedModel = ev.model;
|
|
@@ -6339,9 +7069,10 @@ async function* runCabaneNativeTurn(req, signal, deps) {
|
|
|
6339
7069
|
if (signal.aborted) return;
|
|
6340
7070
|
const args = parseArgs(t.args);
|
|
6341
7071
|
const displayName = prettyToolName(t.name);
|
|
6342
|
-
const
|
|
7072
|
+
const isTurnControl = CABANE_NATIVE_TURN_CONTROL_NAMES.has(t.name);
|
|
7073
|
+
const summary = isTurnControl ? summarizeTurnControlArgs(t.name, args) : summarizeCabaneToolArgs(t.name, args);
|
|
6343
7074
|
yield { type: "tool", id: t.id, name: displayName, phase: "start", summary, input: args };
|
|
6344
|
-
const result = await executeCabaneNativeTool(t.name, args, toolCtx, signal);
|
|
7075
|
+
const result = isTurnControl && turnControl ? await executeNativeTurnControl(t.name, args, turnControl) : await executeCabaneNativeTool(t.name, args, toolCtx, signal);
|
|
6345
7076
|
if (signal.aborted) return;
|
|
6346
7077
|
yield {
|
|
6347
7078
|
type: "tool",
|
|
@@ -6385,10 +7116,6 @@ function parseArgs(raw) {
|
|
|
6385
7116
|
}
|
|
6386
7117
|
}
|
|
6387
7118
|
|
|
6388
|
-
// packages/agent-runtime/src/cabane-native/policy.ts
|
|
6389
|
-
import { z as z13 } from "zod";
|
|
6390
|
-
var cabaneNativeDialectSchema = z13.object({}).loose();
|
|
6391
|
-
|
|
6392
7119
|
// packages/agent-runtime/src/cabane-native/provider.ts
|
|
6393
7120
|
var DEFAULT_OPENROUTER_BASE = "https://openrouter.ai/api/v1";
|
|
6394
7121
|
function createOpenRouterProvider(opts) {
|
|
@@ -6418,9 +7145,9 @@ function createOpenRouterProvider(opts) {
|
|
|
6418
7145
|
}),
|
|
6419
7146
|
signal
|
|
6420
7147
|
});
|
|
6421
|
-
} catch (
|
|
7148
|
+
} catch (err2) {
|
|
6422
7149
|
if (signal.aborted) return;
|
|
6423
|
-
yield { type: "error", message: `request failed: ${errText(
|
|
7150
|
+
yield { type: "error", message: `request failed: ${errText(err2)}` };
|
|
6424
7151
|
return;
|
|
6425
7152
|
}
|
|
6426
7153
|
if (!res.ok || !res.body) {
|
|
@@ -6490,9 +7217,9 @@ function createOpenRouterProvider(opts) {
|
|
|
6490
7217
|
}
|
|
6491
7218
|
}
|
|
6492
7219
|
}
|
|
6493
|
-
} catch (
|
|
7220
|
+
} catch (err2) {
|
|
6494
7221
|
if (signal.aborted) return;
|
|
6495
|
-
yield { type: "error", message: `stream read failed: ${errText(
|
|
7222
|
+
yield { type: "error", message: `stream read failed: ${errText(err2)}` };
|
|
6496
7223
|
return;
|
|
6497
7224
|
}
|
|
6498
7225
|
yield { type: "done", ...finishReason ? { finishReason } : {} };
|
|
@@ -6508,8 +7235,8 @@ function providerErrorMessage(status2, body) {
|
|
|
6508
7235
|
}
|
|
6509
7236
|
return `HTTP ${status2}: ${detail}`;
|
|
6510
7237
|
}
|
|
6511
|
-
function errText(
|
|
6512
|
-
return
|
|
7238
|
+
function errText(err2) {
|
|
7239
|
+
return err2 instanceof Error ? err2.message : String(err2);
|
|
6513
7240
|
}
|
|
6514
7241
|
|
|
6515
7242
|
// packages/agent-runtime/src/cabane-native/index.ts
|
|
@@ -6522,13 +7249,15 @@ function createCabaneNativeAdapter(deps = {}) {
|
|
|
6522
7249
|
}) : null);
|
|
6523
7250
|
return {
|
|
6524
7251
|
name: CABANE_NATIVE_RUNTIME_NAME,
|
|
6525
|
-
//
|
|
6526
|
-
//
|
|
6527
|
-
//
|
|
6528
|
-
//
|
|
6529
|
-
//
|
|
6530
|
-
//
|
|
6531
|
-
|
|
7252
|
+
// CT666: the native runtime's addendum is now SURFACE-aware. Code mode (the
|
|
7253
|
+
// default In-Cabane surface) mounts the single `sdk` tool, so it's taught the
|
|
7254
|
+
// code-mode addendum (act by writing a `cabane` SDK program; the `cabane_*`
|
|
7255
|
+
// names don't exist here); classic (the retained CT663 10-tool fallback) is
|
|
7256
|
+
// taught the granular `cabane_*` listing. The caller (turn-context) passes
|
|
7257
|
+
// `codeMode` off the SAME lifted surface value the loop mounts tools from, so
|
|
7258
|
+
// the prompt and the mounted surface can't drift. (Supersedes CT614's
|
|
7259
|
+
// flag-independent addendum: native no longer ignores this arg.)
|
|
7260
|
+
promptAddendum: (codeMode) => codeMode ? CABANE_NATIVE_ADDENDUM_CODE_MODE : CABANE_NATIVE_ADDENDUM,
|
|
6532
7261
|
dialectSchema: cabaneNativeDialectSchema,
|
|
6533
7262
|
async *runTurn(req, signal) {
|
|
6534
7263
|
if (!provider) {
|
|
@@ -6602,8 +7331,8 @@ var ConnectorHealthStore = class {
|
|
|
6602
7331
|
return this.byRuntime.get(runtime);
|
|
6603
7332
|
}
|
|
6604
7333
|
// The per-connector reports to attach to a heartbeat — one entry per runtime the
|
|
6605
|
-
//
|
|
6606
|
-
// so a
|
|
7334
|
+
// companion has an observation for. Empty until the first classified failure/heal,
|
|
7335
|
+
// so a companion that has seen nothing sends no `connectors[]` and the server's
|
|
6607
7336
|
// manifest synthesis (status-less rows) is unaffected.
|
|
6608
7337
|
reports() {
|
|
6609
7338
|
return [...this.byRuntime.entries()].map(([runtime, h]) => ({
|
|
@@ -6617,22 +7346,23 @@ var ConnectorHealthStore = class {
|
|
|
6617
7346
|
|
|
6618
7347
|
// src/dispatcher.ts
|
|
6619
7348
|
import { randomUUID } from "crypto";
|
|
6620
|
-
import { existsSync as existsSync9 } from "fs";
|
|
7349
|
+
import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync10 } from "fs";
|
|
7350
|
+
import { join as join12 } from "path";
|
|
6621
7351
|
|
|
6622
7352
|
// src/summon.ts
|
|
6623
7353
|
import { z as z14 } from "zod";
|
|
6624
|
-
var
|
|
7354
|
+
var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
|
|
6625
7355
|
var SUMMON_AGENT_TOOL = "summon_agent";
|
|
6626
|
-
var SUMMON_AGENT_TOOL_NAME = `mcp__${
|
|
6627
|
-
var
|
|
7356
|
+
var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
|
|
7357
|
+
var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
|
|
6628
7358
|
var SKIP_TURN_TOOL = "skip_turn";
|
|
6629
|
-
var SKIP_TURN_TOOL_NAME = `mcp__${
|
|
7359
|
+
var SKIP_TURN_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SKIP_TURN_TOOL}`;
|
|
6630
7360
|
var ASK_TOOL = "ask";
|
|
6631
|
-
var ASK_TOOL_NAME = `mcp__${
|
|
7361
|
+
var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
|
|
6632
7362
|
var SUB_AGENT_TOOL = "sub_agent";
|
|
6633
|
-
var SUB_AGENT_TOOL_NAME = `mcp__${
|
|
7363
|
+
var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
|
|
6634
7364
|
var WAKE_ME_TOOL = "wake_me";
|
|
6635
|
-
var WAKE_ME_TOOL_NAME = `mcp__${
|
|
7365
|
+
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
6636
7366
|
function createSummonState() {
|
|
6637
7367
|
return { agentId: null };
|
|
6638
7368
|
}
|
|
@@ -6647,7 +7377,7 @@ function createWakeState() {
|
|
|
6647
7377
|
}
|
|
6648
7378
|
function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
|
|
6649
7379
|
return createSdkMcpServer({
|
|
6650
|
-
name:
|
|
7380
|
+
name: COMPANION_LOCAL_MCP_SERVER,
|
|
6651
7381
|
version: "0.0.0",
|
|
6652
7382
|
tools: [
|
|
6653
7383
|
tool(
|
|
@@ -6849,7 +7579,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6849
7579
|
function cabaneMcpUrl(baseUrl) {
|
|
6850
7580
|
return `${trimSlash3(baseUrl)}/api/mcp`;
|
|
6851
7581
|
}
|
|
6852
|
-
function
|
|
7582
|
+
function turnControlMcpUrl(baseUrl) {
|
|
7583
|
+
return `${trimSlash3(baseUrl)}/api/turn-control`;
|
|
7584
|
+
}
|
|
7585
|
+
function buildCompanionTurnRequest(params) {
|
|
6853
7586
|
const { turnContext: t } = params;
|
|
6854
7587
|
return {
|
|
6855
7588
|
systemPrompt: t.systemPrompt,
|
|
@@ -6860,23 +7593,35 @@ function buildBridgeTurnRequest(params) {
|
|
|
6860
7593
|
session: t.session,
|
|
6861
7594
|
cabane: {
|
|
6862
7595
|
mcpUrl: cabaneMcpUrl(params.baseUrl),
|
|
6863
|
-
// CT306: prefer the per-turn OBO credential; fall back to the
|
|
7596
|
+
// CT306: prefer the per-turn OBO credential; fall back to the companion PAT
|
|
6864
7597
|
// when the API didn't mint one (older API / unresolvable delegation).
|
|
6865
7598
|
bearer: params.turnToken ?? params.agentPat,
|
|
6866
7599
|
activeConversationId: params.activeConversationId,
|
|
6867
|
-
workspaceId: params.workspaceId
|
|
7600
|
+
workspaceId: params.workspaceId,
|
|
7601
|
+
...t.workspaceToolSurface ? { workspaceToolSurface: t.workspaceToolSurface } : {},
|
|
7602
|
+
// CT714: mount the turn-control surface ONLY when a real turn token backs
|
|
7603
|
+
// this turn — the surface admits `turn_token` auth exclusively, so a
|
|
7604
|
+
// PAT-fallback bearer (older API / unresolved delegation) would be rejected
|
|
7605
|
+
// there. Absent it, external adapters simply don't mount it that turn (the
|
|
7606
|
+
// same graceful degrade as the rest of the OBO path).
|
|
7607
|
+
...params.turnToken ? { turnControlUrl: turnControlMcpUrl(params.baseUrl) } : {}
|
|
6868
7608
|
},
|
|
6869
7609
|
local: {
|
|
6870
7610
|
...params.cwd ? { cwd: params.cwd } : {},
|
|
6871
7611
|
...params.env ? { env: params.env } : {},
|
|
7612
|
+
...params.nativeWorkAssignment ? { nativeWorkAssignment: params.nativeWorkAssignment } : {},
|
|
6872
7613
|
// User MCP servers (already `${PLACEHOLDER}`-resolved). Structurally the
|
|
6873
7614
|
// adapter's `ResolvedMcpServers`.
|
|
6874
7615
|
...Object.keys(params.mcpServers).length > 0 ? { mcpServers: params.mcpServers } : {},
|
|
6875
7616
|
// CT289: the auto-memory escape hatch, when the operator set it.
|
|
6876
7617
|
...params.claudeCode ? { claudeCode: params.claudeCode } : {}
|
|
6877
7618
|
},
|
|
6878
|
-
// Host-injected: the
|
|
6879
|
-
|
|
7619
|
+
// Host-injected: the companion-local summon server (for the subprocess adapters,
|
|
7620
|
+
// under its own namespace) + the cabane-native turn-control handler (CT666).
|
|
7621
|
+
extra: {
|
|
7622
|
+
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.summonServer },
|
|
7623
|
+
turnControl: params.turnControl
|
|
7624
|
+
}
|
|
6880
7625
|
};
|
|
6881
7626
|
}
|
|
6882
7627
|
function trimSlash3(s) {
|
|
@@ -6889,18 +7634,26 @@ import { join as join9 } from "path";
|
|
|
6889
7634
|
function dirFor(workspaceId) {
|
|
6890
7635
|
return join9(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
|
|
6891
7636
|
}
|
|
6892
|
-
function
|
|
6893
|
-
return join9(dirFor(workspaceId),
|
|
7637
|
+
function conversationDir(workspaceId, conversationId) {
|
|
7638
|
+
return join9(dirFor(workspaceId), encodeURIComponent(conversationId));
|
|
6894
7639
|
}
|
|
6895
|
-
function
|
|
6896
|
-
const
|
|
7640
|
+
function pathFor3(workspaceId, conversationId, agentId, assignmentKey) {
|
|
7641
|
+
const suffix = assignmentKey ? `--${encodeURIComponent(assignmentKey)}` : "";
|
|
7642
|
+
return join9(
|
|
7643
|
+
conversationDir(workspaceId, conversationId),
|
|
7644
|
+
`${encodeURIComponent(agentId)}${suffix}.json`
|
|
7645
|
+
);
|
|
7646
|
+
}
|
|
7647
|
+
function readPrepared(workspaceId, conversationId, agentId, assignmentKey) {
|
|
7648
|
+
const path3 = pathFor3(workspaceId, conversationId, agentId, assignmentKey);
|
|
6897
7649
|
if (!existsSync7(path3)) return null;
|
|
6898
7650
|
try {
|
|
6899
7651
|
const parsed = JSON.parse(readFileSync7(path3, "utf8"));
|
|
6900
7652
|
if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
|
|
6901
7653
|
return {
|
|
6902
7654
|
cwd: parsed.cwd,
|
|
6903
|
-
...parsed.env && typeof parsed.env === "object" ? { env: parsed.env } : {}
|
|
7655
|
+
...parsed.env && typeof parsed.env === "object" ? { env: parsed.env } : {},
|
|
7656
|
+
...parsed.nativeWorkAssignment && typeof parsed.nativeWorkAssignment === "object" ? { nativeWorkAssignment: parsed.nativeWorkAssignment } : {}
|
|
6904
7657
|
};
|
|
6905
7658
|
}
|
|
6906
7659
|
return null;
|
|
@@ -6908,9 +7661,13 @@ function readPrepared(workspaceId, conversationId) {
|
|
|
6908
7661
|
return null;
|
|
6909
7662
|
}
|
|
6910
7663
|
}
|
|
6911
|
-
function writePrepared(workspaceId, conversationId, result) {
|
|
6912
|
-
mkdirSync8(
|
|
6913
|
-
writeFileSync6(
|
|
7664
|
+
function writePrepared(workspaceId, conversationId, agentId, result, assignmentKey) {
|
|
7665
|
+
mkdirSync8(conversationDir(workspaceId, conversationId), { recursive: true });
|
|
7666
|
+
writeFileSync6(
|
|
7667
|
+
pathFor3(workspaceId, conversationId, agentId, assignmentKey),
|
|
7668
|
+
JSON.stringify(result) + "\n",
|
|
7669
|
+
"utf8"
|
|
7670
|
+
);
|
|
6914
7671
|
}
|
|
6915
7672
|
|
|
6916
7673
|
// src/secrets.ts
|
|
@@ -6928,18 +7685,18 @@ function loadSecretStore() {
|
|
|
6928
7685
|
let raw;
|
|
6929
7686
|
try {
|
|
6930
7687
|
raw = readFileSync8(path3, "utf8");
|
|
6931
|
-
} catch (
|
|
7688
|
+
} catch (err2) {
|
|
6932
7689
|
throw new ConfigError(
|
|
6933
|
-
`couldn't read ${path3}: ${
|
|
7690
|
+
`couldn't read ${path3}: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
6934
7691
|
);
|
|
6935
7692
|
}
|
|
6936
7693
|
if (raw.trim().length === 0) return makeStore({});
|
|
6937
7694
|
let parsed;
|
|
6938
7695
|
try {
|
|
6939
7696
|
parsed = JSON.parse(raw);
|
|
6940
|
-
} catch (
|
|
7697
|
+
} catch (err2) {
|
|
6941
7698
|
throw new ConfigError(
|
|
6942
|
-
`${path3} is not valid JSON: ${
|
|
7699
|
+
`${path3} is not valid JSON: ${err2 instanceof Error ? err2.message : String(err2)}. It must be a flat object of "NAME": "value" secret pairs.`
|
|
6943
7700
|
);
|
|
6944
7701
|
}
|
|
6945
7702
|
const result = secretStoreSchema.safeParse(parsed);
|
|
@@ -6953,8 +7710,8 @@ function loadSecretStore() {
|
|
|
6953
7710
|
function loadSecretStoreTolerant(onWarn) {
|
|
6954
7711
|
try {
|
|
6955
7712
|
return loadSecretStore();
|
|
6956
|
-
} catch (
|
|
6957
|
-
onWarn?.(
|
|
7713
|
+
} catch (err2) {
|
|
7714
|
+
onWarn?.(err2 instanceof Error ? err2.message : String(err2));
|
|
6958
7715
|
return makeStore({});
|
|
6959
7716
|
}
|
|
6960
7717
|
}
|
|
@@ -7025,8 +7782,8 @@ var TranscriptWriter = class {
|
|
|
7025
7782
|
} catch {
|
|
7026
7783
|
}
|
|
7027
7784
|
pruneOld(dir2, RETAIN);
|
|
7028
|
-
} catch (
|
|
7029
|
-
this.fail(
|
|
7785
|
+
} catch (err2) {
|
|
7786
|
+
this.fail(err2);
|
|
7030
7787
|
}
|
|
7031
7788
|
this.line({ type: "_meta", ...meta });
|
|
7032
7789
|
}
|
|
@@ -7042,14 +7799,14 @@ var TranscriptWriter = class {
|
|
|
7042
7799
|
if (this.broken) return;
|
|
7043
7800
|
try {
|
|
7044
7801
|
appendFileSync(this.path, JSON.stringify(obj) + "\n", { mode: 384 });
|
|
7045
|
-
} catch (
|
|
7046
|
-
this.fail(
|
|
7802
|
+
} catch (err2) {
|
|
7803
|
+
this.fail(err2);
|
|
7047
7804
|
}
|
|
7048
7805
|
}
|
|
7049
|
-
fail(
|
|
7806
|
+
fail(err2) {
|
|
7050
7807
|
if (this.broken) return;
|
|
7051
7808
|
this.broken = true;
|
|
7052
|
-
this.onWarn?.(`transcript write failed (${
|
|
7809
|
+
this.onWarn?.(`transcript write failed (${err2 instanceof Error ? err2.message : String(err2)})`);
|
|
7053
7810
|
}
|
|
7054
7811
|
};
|
|
7055
7812
|
function fileName(meta) {
|
|
@@ -7087,9 +7844,9 @@ var EMPTY_FINAL_BODY = "Done \u2014 see the changes above.";
|
|
|
7087
7844
|
var TurnCommitter = class {
|
|
7088
7845
|
constructor(deps) {
|
|
7089
7846
|
this.deps = deps;
|
|
7090
|
-
this.onError = (
|
|
7847
|
+
this.onError = (err2, hook) => {
|
|
7091
7848
|
deps.log.warn(
|
|
7092
|
-
{ err:
|
|
7849
|
+
{ err: err2 instanceof Error ? err2.message : String(err2), hook },
|
|
7093
7850
|
"dispatcher: transcript callback failed"
|
|
7094
7851
|
);
|
|
7095
7852
|
};
|
|
@@ -7154,9 +7911,9 @@ var TurnCommitter = class {
|
|
|
7154
7911
|
signal: deps.signal,
|
|
7155
7912
|
nextSeq: deps.nextSeq,
|
|
7156
7913
|
emptyFinalBody: EMPTY_FINAL_BODY,
|
|
7157
|
-
onError: (
|
|
7914
|
+
onError: (err2) => {
|
|
7158
7915
|
deps.log.warn(
|
|
7159
|
-
{ err:
|
|
7916
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7160
7917
|
"dispatcher: empty-final commit failed"
|
|
7161
7918
|
);
|
|
7162
7919
|
}
|
|
@@ -7179,8 +7936,8 @@ var TurnCommitter = class {
|
|
|
7179
7936
|
if (event.type === "session" || event.type === "result") return;
|
|
7180
7937
|
try {
|
|
7181
7938
|
await this.emit(event);
|
|
7182
|
-
} catch (
|
|
7183
|
-
this.onError(
|
|
7939
|
+
} catch (err2) {
|
|
7940
|
+
this.onError(err2, event.type);
|
|
7184
7941
|
}
|
|
7185
7942
|
}
|
|
7186
7943
|
// End-of-turn empty-final promotion. The held-text flush is now the adapter's
|
|
@@ -7251,13 +8008,128 @@ var TurnCommitter = class {
|
|
|
7251
8008
|
}
|
|
7252
8009
|
};
|
|
7253
8010
|
|
|
8011
|
+
// src/workspace-readiness.ts
|
|
8012
|
+
var CLASSIC_REQUIRED = ["read", "list", "search", "write", "edit"];
|
|
8013
|
+
async function proveWorkspaceTools(req, runtime, opts = {}) {
|
|
8014
|
+
const base = {
|
|
8015
|
+
ok: false,
|
|
8016
|
+
proofType: "authenticated_mcp_tools_list",
|
|
8017
|
+
runtime,
|
|
8018
|
+
harnessFingerprint: opts.harnessFingerprint ?? runtime,
|
|
8019
|
+
endpoint: safeEndpoint(req.cabane.mcpUrl),
|
|
8020
|
+
initialized: false,
|
|
8021
|
+
authenticated: false,
|
|
8022
|
+
discoveredTools: [],
|
|
8023
|
+
requiredTools: [],
|
|
8024
|
+
acceptedNames: ["sdk", "mcp__cabane__sdk"],
|
|
8025
|
+
failedCapability: null,
|
|
8026
|
+
detail: null
|
|
8027
|
+
};
|
|
8028
|
+
if (!req.cabane.mcpUrl) return fail(base, "server_not_configured", "Cabane MCP URL absent");
|
|
8029
|
+
if (!req.cabane.bearer) return fail(base, "authentication_failed", "Cabane bearer absent");
|
|
8030
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
8031
|
+
const headers = {
|
|
8032
|
+
authorization: `Bearer ${req.cabane.bearer}`,
|
|
8033
|
+
accept: "application/json, text/event-stream",
|
|
8034
|
+
"content-type": "application/json",
|
|
8035
|
+
"x-cabane-active-conversation": req.cabane.activeConversationId
|
|
8036
|
+
};
|
|
8037
|
+
try {
|
|
8038
|
+
const initialized = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
|
|
8039
|
+
jsonrpc: "2.0",
|
|
8040
|
+
id: 1,
|
|
8041
|
+
method: "initialize",
|
|
8042
|
+
params: {
|
|
8043
|
+
protocolVersion: "2025-03-26",
|
|
8044
|
+
capabilities: {},
|
|
8045
|
+
clientInfo: { name: "cabane-companion-readiness", version: "1" }
|
|
8046
|
+
}
|
|
8047
|
+
});
|
|
8048
|
+
if (initialized.status === 401 || initialized.status === 403)
|
|
8049
|
+
return fail(base, "authentication_failed", `initialize returned HTTP ${initialized.status}`);
|
|
8050
|
+
if (!initialized.ok) return fail(base, "initialization_failed", initialized.detail);
|
|
8051
|
+
base.initialized = true;
|
|
8052
|
+
base.authenticated = true;
|
|
8053
|
+
if (initialized.sessionId) headers["mcp-session-id"] = initialized.sessionId;
|
|
8054
|
+
const listed = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
|
|
8055
|
+
jsonrpc: "2.0",
|
|
8056
|
+
id: 2,
|
|
8057
|
+
method: "tools/list",
|
|
8058
|
+
params: {}
|
|
8059
|
+
});
|
|
8060
|
+
if (listed.status === 401 || listed.status === 403)
|
|
8061
|
+
return fail(base, "authentication_failed", `tools/list returned HTTP ${listed.status}`);
|
|
8062
|
+
if (!listed.ok) return fail(base, "tool_discovery_failed", listed.detail);
|
|
8063
|
+
const result = asRecord3(asRecord3(listed.value)?.result);
|
|
8064
|
+
const tools = Array.isArray(result?.tools) ? result.tools : null;
|
|
8065
|
+
if (!tools) return fail(base, "tool_discovery_failed", "tools/list returned no tool inventory");
|
|
8066
|
+
base.discoveredTools = tools.map(
|
|
8067
|
+
(tool2) => tool2 && typeof tool2 === "object" && typeof tool2.name === "string" ? tool2.name : null
|
|
8068
|
+
).filter((name) => name !== null).sort();
|
|
8069
|
+
if (!req.cabane.workspaceToolSurface)
|
|
8070
|
+
return fail(base, "required_tool_missing", "resolved workspace tool surface absent");
|
|
8071
|
+
base.requiredTools = req.cabane.workspaceToolSurface === "code" ? ["sdk"] : CLASSIC_REQUIRED;
|
|
8072
|
+
const missing = base.requiredTools.filter((name) => !base.discoveredTools.includes(name));
|
|
8073
|
+
if (missing.length > 0)
|
|
8074
|
+
return fail(
|
|
8075
|
+
base,
|
|
8076
|
+
"required_tool_missing",
|
|
8077
|
+
`missing initialized tools: ${missing.join(", ")}`
|
|
8078
|
+
);
|
|
8079
|
+
base.ok = true;
|
|
8080
|
+
return base;
|
|
8081
|
+
} catch (error) {
|
|
8082
|
+
return fail(
|
|
8083
|
+
base,
|
|
8084
|
+
"initialization_failed",
|
|
8085
|
+
error instanceof Error ? error.message : String(error)
|
|
8086
|
+
);
|
|
8087
|
+
}
|
|
8088
|
+
}
|
|
8089
|
+
function fail(proof, capability, detail) {
|
|
8090
|
+
proof.failedCapability = capability;
|
|
8091
|
+
proof.detail = detail.slice(0, 300);
|
|
8092
|
+
return proof;
|
|
8093
|
+
}
|
|
8094
|
+
function safeEndpoint(value) {
|
|
8095
|
+
try {
|
|
8096
|
+
const url = new URL(value);
|
|
8097
|
+
return `${url.origin}${url.pathname}`;
|
|
8098
|
+
} catch {
|
|
8099
|
+
return null;
|
|
8100
|
+
}
|
|
8101
|
+
}
|
|
8102
|
+
async function rpc(fetchImpl, url, headers, body) {
|
|
8103
|
+
const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
8104
|
+
const text = await response.text();
|
|
8105
|
+
const value = parseRpcBody(text);
|
|
8106
|
+
return {
|
|
8107
|
+
ok: response.ok && !!value && !value.error,
|
|
8108
|
+
status: response.status,
|
|
8109
|
+
sessionId: response.headers.get("mcp-session-id"),
|
|
8110
|
+
value,
|
|
8111
|
+
detail: typeof asRecord3(value?.error)?.message === "string" ? String(asRecord3(value?.error)?.message) : `HTTP ${response.status}`
|
|
8112
|
+
};
|
|
8113
|
+
}
|
|
8114
|
+
function parseRpcBody(text) {
|
|
8115
|
+
const trimmed = text.trim();
|
|
8116
|
+
if (trimmed.startsWith("{")) return JSON.parse(trimmed);
|
|
8117
|
+
for (const line of trimmed.split("\n")) {
|
|
8118
|
+
if (line.startsWith("data:")) return JSON.parse(line.slice(5).trim());
|
|
8119
|
+
}
|
|
8120
|
+
return null;
|
|
8121
|
+
}
|
|
8122
|
+
function asRecord3(value) {
|
|
8123
|
+
return value !== null && typeof value === "object" ? value : null;
|
|
8124
|
+
}
|
|
8125
|
+
|
|
7254
8126
|
// src/dispatcher.ts
|
|
7255
8127
|
var PREPARING_TOOL_NAME = "preparing";
|
|
7256
8128
|
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:";
|
|
7257
|
-
var MISSING_SECRET_PREFIX = "**Missing secret on this
|
|
8129
|
+
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:";
|
|
7258
8130
|
var STOPPED_MARKER_BODY = "(stopped)";
|
|
7259
|
-
var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this
|
|
7260
|
-
var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this
|
|
8131
|
+
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:";
|
|
8132
|
+
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`;
|
|
7261
8133
|
var SKIPPED_MARKER_BODY = "(skipped)";
|
|
7262
8134
|
var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
|
|
7263
8135
|
var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
|
|
@@ -7265,23 +8137,6 @@ var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 45 * 6e4;
|
|
|
7265
8137
|
function runKey(conversationId, agentId) {
|
|
7266
8138
|
return `${conversationId}|${agentId}`;
|
|
7267
8139
|
}
|
|
7268
|
-
function describeSubAgentError(status2, body) {
|
|
7269
|
-
const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
|
|
7270
|
-
switch (code) {
|
|
7271
|
-
case "callout_cap_exceeded":
|
|
7272
|
-
return "sub_agent: you already have the maximum open sub-agents for this thread. Wait for some to return \u2014 you're woken once they're all back \u2014 before spawning more.";
|
|
7273
|
-
case "callout_depth_exceeded":
|
|
7274
|
-
return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
|
|
7275
|
-
case "dispatch_agent_not_found":
|
|
7276
|
-
return "sub_agent: no live agent in this workspace matches that `agentId`. Check `list_agents`, or omit `agentId` to spawn yourself.";
|
|
7277
|
-
case "dispatch_return_requires_turn":
|
|
7278
|
-
case "dispatch_return_requires_agent":
|
|
7279
|
-
case "dispatch_return_requires_dispatch":
|
|
7280
|
-
return `sub_agent: the spawn was rejected (${code}). This is a turn-context problem, not something to retry blindly \u2014 report it rather than looping.`;
|
|
7281
|
-
default:
|
|
7282
|
-
return `sub_agent: the spawn failed (${code ?? `HTTP ${status2}`}).`;
|
|
7283
|
-
}
|
|
7284
|
-
}
|
|
7285
8140
|
var Dispatcher = class {
|
|
7286
8141
|
constructor(opts) {
|
|
7287
8142
|
this.opts = opts;
|
|
@@ -7302,9 +8157,9 @@ var Dispatcher = class {
|
|
|
7302
8157
|
}
|
|
7303
8158
|
}
|
|
7304
8159
|
// CT138: shared pre-run teardown for every early-return that happens BEFORE
|
|
7305
|
-
// the active-run flag is flipped (the `
|
|
8160
|
+
// the active-run flag is flipped (the `setActiveRun` working-flip below).
|
|
7306
8161
|
// The server lights the "X is replying…" indicator eagerly at dispatch
|
|
7307
|
-
// (chat-dispatch.ts `scheduleRun`), and from that point only the
|
|
8162
|
+
// (chat-dispatch.ts `scheduleRun`), and from that point only the companion can
|
|
7308
8163
|
// clear it — the SJ383 `finally` after the SDK loop is the one clear, and
|
|
7309
8164
|
// every pre-run exit returns before reaching it. So each pre-run failure has
|
|
7310
8165
|
// to clear `active_run_started_at` itself, mirroring that `finally`, or the
|
|
@@ -7322,15 +8177,15 @@ var Dispatcher = class {
|
|
|
7322
8177
|
const body = { activeRunStartedAt: null };
|
|
7323
8178
|
if (errorReason) body.errorReason = errorReason.slice(0, 200);
|
|
7324
8179
|
try {
|
|
7325
|
-
await this.opts.api.
|
|
8180
|
+
await this.opts.api.setActiveRun(
|
|
7326
8181
|
this.opts.workspaceId,
|
|
7327
8182
|
payload.conversationId,
|
|
7328
8183
|
payload.agentId,
|
|
7329
8184
|
body
|
|
7330
8185
|
);
|
|
7331
|
-
} catch (
|
|
8186
|
+
} catch (err2) {
|
|
7332
8187
|
turnLog.warn(
|
|
7333
|
-
{ err:
|
|
8188
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7334
8189
|
"dispatcher: pre-run active-run clear failed terminally; server age-sweep is the backstop"
|
|
7335
8190
|
);
|
|
7336
8191
|
}
|
|
@@ -7348,19 +8203,24 @@ var Dispatcher = class {
|
|
|
7348
8203
|
agentId: payload.agentId,
|
|
7349
8204
|
messageId: payload.messageId
|
|
7350
8205
|
});
|
|
8206
|
+
const turnId = randomUUID();
|
|
7351
8207
|
let turnContext;
|
|
7352
8208
|
try {
|
|
7353
|
-
turnContext = await this.opts.api.getTurnContext(
|
|
7354
|
-
|
|
7355
|
-
|
|
8209
|
+
turnContext = await this.opts.api.getTurnContext(
|
|
8210
|
+
payload.conversationId,
|
|
8211
|
+
payload.messageId,
|
|
8212
|
+
turnId
|
|
8213
|
+
);
|
|
8214
|
+
} catch (err2) {
|
|
8215
|
+
const status2 = err2 instanceof ApiError ? err2.status : 0;
|
|
7356
8216
|
if (status2 === 404) {
|
|
7357
8217
|
turnLog.warn(
|
|
7358
|
-
{ err:
|
|
8218
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7359
8219
|
"dispatcher: turn-context 404 (stale event \u2014 conversation/message/participant gone); skipping"
|
|
7360
8220
|
);
|
|
7361
8221
|
return this.concludeBeforeRun(payload, turnLog, startedAt, "turn_context_not_found");
|
|
7362
8222
|
}
|
|
7363
|
-
const reason =
|
|
8223
|
+
const reason = err2 instanceof Error ? err2.message : String(err2);
|
|
7364
8224
|
turnLog.error({ err: reason }, "dispatcher: failed to fetch turn context");
|
|
7365
8225
|
const fetchReason = `fetch_failed: ${reason}`;
|
|
7366
8226
|
return this.concludeBeforeRun(payload, turnLog, startedAt, fetchReason, fetchReason);
|
|
@@ -7391,7 +8251,7 @@ var Dispatcher = class {
|
|
|
7391
8251
|
);
|
|
7392
8252
|
if (missing.length > 0) {
|
|
7393
8253
|
const list = missing.map((n) => `\`${n}\``).join(", ");
|
|
7394
|
-
turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this
|
|
8254
|
+
turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this companion");
|
|
7395
8255
|
try {
|
|
7396
8256
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7397
8257
|
body: `${MISSING_SECRET_PREFIX} ${list}`,
|
|
@@ -7412,7 +8272,6 @@ var Dispatcher = class {
|
|
|
7412
8272
|
const localCwd = this.opts.local.cwd;
|
|
7413
8273
|
const prepareHook = this.opts.local.prepareHook;
|
|
7414
8274
|
const cabaneCwd = turnContext.cwd;
|
|
7415
|
-
const turnId = randomUUID();
|
|
7416
8275
|
let seqCounter = 0;
|
|
7417
8276
|
const nextSeq = () => ++seqCounter;
|
|
7418
8277
|
let effectiveCwd = localCwd ?? cabaneCwd;
|
|
@@ -7424,11 +8283,20 @@ var Dispatcher = class {
|
|
|
7424
8283
|
effectiveCwd = void 0;
|
|
7425
8284
|
}
|
|
7426
8285
|
let hookEnv;
|
|
8286
|
+
let preparedNativeAssignment;
|
|
7427
8287
|
if (prepareHook) {
|
|
7428
|
-
const
|
|
8288
|
+
const assignment = turnContext.conversation.nativeWorkAssignment;
|
|
8289
|
+
const assignmentKey = assignment ? `${assignment.executionId}:${assignment.activationEpoch}` : void 0;
|
|
8290
|
+
const cached2 = readPrepared(
|
|
8291
|
+
workspaceId,
|
|
8292
|
+
payload.conversationId,
|
|
8293
|
+
payload.agentId,
|
|
8294
|
+
assignmentKey
|
|
8295
|
+
);
|
|
7429
8296
|
if (cached2) {
|
|
7430
8297
|
effectiveCwd = cached2.cwd;
|
|
7431
8298
|
hookEnv = cached2.env;
|
|
8299
|
+
preparedNativeAssignment = cached2.nativeWorkAssignment;
|
|
7432
8300
|
} else {
|
|
7433
8301
|
const delayMs = this.opts.preparingRowDelayMs ?? DEFAULT_PREPARING_ROW_DELAY_MS;
|
|
7434
8302
|
let preparingStarted = false;
|
|
@@ -7443,9 +8311,9 @@ var Dispatcher = class {
|
|
|
7443
8311
|
summary: "",
|
|
7444
8312
|
phase,
|
|
7445
8313
|
seq
|
|
7446
|
-
}).catch((
|
|
8314
|
+
}).catch((err2) => {
|
|
7447
8315
|
turnLog.warn(
|
|
7448
|
-
{ err:
|
|
8316
|
+
{ err: err2 instanceof Error ? err2.message : String(err2), phase },
|
|
7449
8317
|
"dispatcher: preparing-activity report failed (continuing with the hook)"
|
|
7450
8318
|
);
|
|
7451
8319
|
});
|
|
@@ -7462,21 +8330,30 @@ var Dispatcher = class {
|
|
|
7462
8330
|
conversationId: payload.conversationId,
|
|
7463
8331
|
agentId: payload.agentId,
|
|
7464
8332
|
agentUsername: this.opts.agentUsername,
|
|
8333
|
+
runtime: turnContext.runtime,
|
|
7465
8334
|
// CT317/CT319: the trigger message's referenced-entry paths — what the
|
|
7466
8335
|
// tasker prepare hook keys its per-task env off. Defaults to `[]` for
|
|
7467
8336
|
// an older API. The conversation anchor is gone (CT319).
|
|
7468
8337
|
triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
|
|
8338
|
+
...turnContext.conversation.nativeWorkAssignment ? { nativeWorkAssignment: turnContext.conversation.nativeWorkAssignment } : {},
|
|
7469
8339
|
title: turnContext.conversation.title
|
|
7470
8340
|
});
|
|
7471
8341
|
clearTimeout(preparingTimer);
|
|
7472
8342
|
if (preparingStarted) reportPreparing("done");
|
|
7473
|
-
writePrepared(
|
|
8343
|
+
writePrepared(
|
|
8344
|
+
workspaceId,
|
|
8345
|
+
payload.conversationId,
|
|
8346
|
+
payload.agentId,
|
|
8347
|
+
result,
|
|
8348
|
+
assignmentKey
|
|
8349
|
+
);
|
|
7474
8350
|
effectiveCwd = result.cwd;
|
|
7475
8351
|
hookEnv = result.env;
|
|
7476
|
-
|
|
8352
|
+
preparedNativeAssignment = result.nativeWorkAssignment;
|
|
8353
|
+
} catch (err2) {
|
|
7477
8354
|
clearTimeout(preparingTimer);
|
|
7478
8355
|
if (preparingStarted) reportPreparing("error");
|
|
7479
|
-
const reason =
|
|
8356
|
+
const reason = err2 instanceof Error ? err2.message : String(err2);
|
|
7480
8357
|
turnLog.error({ err: reason }, "dispatcher: prepare hook failed");
|
|
7481
8358
|
try {
|
|
7482
8359
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
@@ -7504,7 +8381,7 @@ ${reason}`,
|
|
|
7504
8381
|
this.aborts.set(key, abortController);
|
|
7505
8382
|
let timeoutReason = null;
|
|
7506
8383
|
try {
|
|
7507
|
-
await this.opts.api.
|
|
8384
|
+
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
7508
8385
|
activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7509
8386
|
// CT33: hand the server this turn's id so the new-run chokepoint's
|
|
7510
8387
|
// `closeAbandonedTurns` sweep excludes it. The prepare hook may have
|
|
@@ -7513,9 +8390,9 @@ ${reason}`,
|
|
|
7513
8390
|
// this live turn for an abandoned one and close it with a `stopped`.
|
|
7514
8391
|
turnId
|
|
7515
8392
|
});
|
|
7516
|
-
} catch (
|
|
8393
|
+
} catch (err2) {
|
|
7517
8394
|
turnLog.warn(
|
|
7518
|
-
{ err:
|
|
8395
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7519
8396
|
"dispatcher: active-run flag set failed terminally; proceeding"
|
|
7520
8397
|
);
|
|
7521
8398
|
}
|
|
@@ -7552,18 +8429,49 @@ ${reason}`,
|
|
|
7552
8429
|
subAgentCreate,
|
|
7553
8430
|
wakeState
|
|
7554
8431
|
);
|
|
7555
|
-
const
|
|
8432
|
+
const nativeTurnControl = {
|
|
8433
|
+
summon: (agentId) => {
|
|
8434
|
+
summonState.agentId = agentId;
|
|
8435
|
+
},
|
|
8436
|
+
skip: (reason) => {
|
|
8437
|
+
skipState.skipped = true;
|
|
8438
|
+
skipState.reason = reason;
|
|
8439
|
+
},
|
|
8440
|
+
ask: (p) => {
|
|
8441
|
+
askState.targetUserId = p.targetUserId;
|
|
8442
|
+
if (p.questions && p.questions.length > 0) {
|
|
8443
|
+
askState.questions = p.questions;
|
|
8444
|
+
askState.question = null;
|
|
8445
|
+
askState.headline = null;
|
|
8446
|
+
askState.options = null;
|
|
8447
|
+
} else {
|
|
8448
|
+
askState.question = p.question ?? null;
|
|
8449
|
+
askState.headline = p.headline ?? null;
|
|
8450
|
+
askState.options = p.options ?? null;
|
|
8451
|
+
askState.questions = null;
|
|
8452
|
+
}
|
|
8453
|
+
},
|
|
8454
|
+
wake: (p) => {
|
|
8455
|
+
wakeState.afterSeconds = p.afterSeconds ?? null;
|
|
8456
|
+
wakeState.at = p.at ?? null;
|
|
8457
|
+
wakeState.note = p.note;
|
|
8458
|
+
},
|
|
8459
|
+
subAgent: subAgentCreate
|
|
8460
|
+
};
|
|
8461
|
+
const request = buildCompanionTurnRequest({
|
|
7556
8462
|
turnContext,
|
|
7557
8463
|
baseUrl: this.opts.baseUrl,
|
|
7558
8464
|
agentPat: this.opts.credential,
|
|
7559
8465
|
// CT306: the per-turn OBO credential when the API minted one; falls back to
|
|
7560
|
-
// the
|
|
8466
|
+
// the companion PAT (`agentPat`) inside `buildCompanionTurnRequest` otherwise.
|
|
7561
8467
|
...turnContext.turnToken ? { turnToken: turnContext.turnToken } : {},
|
|
7562
8468
|
// SJ524: the hook-resolved cwd overrides the static local cwd.
|
|
7563
8469
|
...effectiveCwd ? { cwd: effectiveCwd } : {},
|
|
7564
8470
|
...hookEnv ? { env: hookEnv } : {},
|
|
8471
|
+
...preparedNativeAssignment ? { nativeWorkAssignment: preparedNativeAssignment } : {},
|
|
7565
8472
|
mcpServers: resolvedMcpServers,
|
|
7566
8473
|
summonServer,
|
|
8474
|
+
turnControl: nativeTurnControl,
|
|
7567
8475
|
// CT238: this turn's conversation, forwarded as the active-conversation
|
|
7568
8476
|
// header so a cross-thread post/spawn stamps its origin.
|
|
7569
8477
|
activeConversationId: payload.conversationId,
|
|
@@ -7588,15 +8496,15 @@ ${reason}`,
|
|
|
7588
8496
|
let adapter;
|
|
7589
8497
|
try {
|
|
7590
8498
|
adapter = selectAdapter(registry, turnContext.runtime);
|
|
7591
|
-
} catch (
|
|
7592
|
-
if (!(
|
|
8499
|
+
} catch (err2) {
|
|
8500
|
+
if (!(err2 instanceof RuntimeUnavailableError)) throw err2;
|
|
7593
8501
|
turnLog.error(
|
|
7594
|
-
{ runtime:
|
|
8502
|
+
{ runtime: err2.runtime, available: err2.available },
|
|
7595
8503
|
"dispatcher: turn runtime not available on this device"
|
|
7596
8504
|
);
|
|
7597
8505
|
try {
|
|
7598
8506
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7599
|
-
body: `${RUNTIME_UNAVAILABLE_PREFIX} ${
|
|
8507
|
+
body: `${RUNTIME_UNAVAILABLE_PREFIX} ${err2.message}`,
|
|
7600
8508
|
kind: "final",
|
|
7601
8509
|
turnId,
|
|
7602
8510
|
parentMessageId: payload.messageId
|
|
@@ -7611,9 +8519,61 @@ ${reason}`,
|
|
|
7611
8519
|
payload,
|
|
7612
8520
|
turnLog,
|
|
7613
8521
|
startedAt,
|
|
7614
|
-
`runtime_unavailable:${
|
|
8522
|
+
`runtime_unavailable:${err2.runtime}`
|
|
7615
8523
|
);
|
|
7616
8524
|
}
|
|
8525
|
+
if (prepareHook && hookEnv?.CABANE_TASK_ID) {
|
|
8526
|
+
const proof = await proveWorkspaceTools(request, adapter.name, {
|
|
8527
|
+
...this.opts.workspaceProofFetch ? { fetchImpl: this.opts.workspaceProofFetch } : {},
|
|
8528
|
+
harnessFingerprint: turnContext.runtime
|
|
8529
|
+
});
|
|
8530
|
+
turnLog[proof.ok ? "info" : "error"](
|
|
8531
|
+
{ workspaceProof: proof, checkout: effectiveCwd ?? null },
|
|
8532
|
+
`dispatcher: workspace tool proof ${proof.ok ? "passed" : "failed"}`
|
|
8533
|
+
);
|
|
8534
|
+
if (effectiveCwd) {
|
|
8535
|
+
try {
|
|
8536
|
+
const diagnosticDir = join12(effectiveCwd, ".git", "cabane");
|
|
8537
|
+
mkdirSync10(diagnosticDir, { recursive: true });
|
|
8538
|
+
appendFileSync2(
|
|
8539
|
+
join12(diagnosticDir, "readiness.jsonl"),
|
|
8540
|
+
`${JSON.stringify({
|
|
8541
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8542
|
+
taskId: hookEnv.CABANE_TASK_ID,
|
|
8543
|
+
binding: hookEnv.CABANE_TASK_BINDING ?? null,
|
|
8544
|
+
checkout: effectiveCwd,
|
|
8545
|
+
classification: proof.ok ? "ready" : "workspace_tools_missing",
|
|
8546
|
+
failedCapability: proof.failedCapability,
|
|
8547
|
+
workspaceTools: proof
|
|
8548
|
+
})}
|
|
8549
|
+
`,
|
|
8550
|
+
{ mode: 384 }
|
|
8551
|
+
);
|
|
8552
|
+
} catch (error) {
|
|
8553
|
+
turnLog.warn(
|
|
8554
|
+
{ err: error instanceof Error ? error.message : String(error) },
|
|
8555
|
+
"dispatcher: workspace-proof diagnostic write failed"
|
|
8556
|
+
);
|
|
8557
|
+
}
|
|
8558
|
+
}
|
|
8559
|
+
if (!proof.ok) {
|
|
8560
|
+
const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd ?? "unknown"}; runtime=${adapter.name}; recovery=restart the connector after restoring the Cabane workspace tool mount`;
|
|
8561
|
+
try {
|
|
8562
|
+
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
8563
|
+
body: `**Couldn't prepare your environment.** ${reason}`,
|
|
8564
|
+
kind: "final",
|
|
8565
|
+
turnId,
|
|
8566
|
+
parentMessageId: payload.messageId
|
|
8567
|
+
});
|
|
8568
|
+
} catch (postErr) {
|
|
8569
|
+
turnLog.warn(
|
|
8570
|
+
{ err: postErr instanceof Error ? postErr.message : String(postErr) },
|
|
8571
|
+
"dispatcher: workspace-proof failure notice post failed"
|
|
8572
|
+
);
|
|
8573
|
+
}
|
|
8574
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
|
|
8575
|
+
}
|
|
8576
|
+
}
|
|
7617
8577
|
const transcript2 = this.opts.transcriptDir ? new TranscriptWriter(
|
|
7618
8578
|
this.opts.transcriptDir,
|
|
7619
8579
|
{
|
|
@@ -7657,6 +8617,49 @@ ${reason}`,
|
|
|
7657
8617
|
// server arms the wake schedule atomically with the reply it rode on.
|
|
7658
8618
|
wakeState
|
|
7659
8619
|
});
|
|
8620
|
+
const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
|
|
8621
|
+
let turnControlIntentFetched = false;
|
|
8622
|
+
const applyRecordedTurnControlIntent = async () => {
|
|
8623
|
+
if (turnControlIntentFetched || !usesHttpTurnControl || !turnContext.turnToken) return;
|
|
8624
|
+
turnControlIntentFetched = true;
|
|
8625
|
+
try {
|
|
8626
|
+
const intent = await this.opts.api.getTurnIntent(
|
|
8627
|
+
workspaceId,
|
|
8628
|
+
payload.conversationId,
|
|
8629
|
+
payload.agentId,
|
|
8630
|
+
turnId
|
|
8631
|
+
);
|
|
8632
|
+
if (intent.ask) {
|
|
8633
|
+
askState.targetUserId = intent.ask.targetUserId;
|
|
8634
|
+
if (intent.ask.questions && intent.ask.questions.length > 0) {
|
|
8635
|
+
askState.questions = intent.ask.questions;
|
|
8636
|
+
askState.question = null;
|
|
8637
|
+
askState.headline = null;
|
|
8638
|
+
askState.options = null;
|
|
8639
|
+
} else {
|
|
8640
|
+
askState.question = intent.ask.question ?? null;
|
|
8641
|
+
askState.headline = intent.ask.headline ?? null;
|
|
8642
|
+
askState.options = intent.ask.options ?? null;
|
|
8643
|
+
askState.questions = null;
|
|
8644
|
+
}
|
|
8645
|
+
}
|
|
8646
|
+
if (intent.wake) {
|
|
8647
|
+
wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
|
|
8648
|
+
wakeState.at = intent.wake.at ?? null;
|
|
8649
|
+
wakeState.note = intent.wake.note;
|
|
8650
|
+
}
|
|
8651
|
+
if (intent.summonAgentId) summonState.agentId = intent.summonAgentId;
|
|
8652
|
+
if (intent.skipped) {
|
|
8653
|
+
skipState.skipped = true;
|
|
8654
|
+
skipState.reason = intent.skipReason;
|
|
8655
|
+
}
|
|
8656
|
+
} catch (err2) {
|
|
8657
|
+
turnLog.warn(
|
|
8658
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
8659
|
+
"dispatcher: turn-control intent fetch failed; turn-control effects for this turn are dropped"
|
|
8660
|
+
);
|
|
8661
|
+
}
|
|
8662
|
+
};
|
|
7660
8663
|
const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
|
|
7661
8664
|
const totalTimeoutMs = this.opts.totalTimeoutMs ?? DEFAULT_AGENT_TOTAL_TIMEOUT_MS;
|
|
7662
8665
|
const fireTimeout = (reason) => {
|
|
@@ -7692,15 +8695,15 @@ ${reason}`,
|
|
|
7692
8695
|
if (!sessionWritten) {
|
|
7693
8696
|
sessionWritten = true;
|
|
7694
8697
|
try {
|
|
7695
|
-
await this.opts.api.
|
|
8698
|
+
await this.opts.api.setActiveRun(
|
|
7696
8699
|
workspaceId,
|
|
7697
8700
|
payload.conversationId,
|
|
7698
8701
|
payload.agentId,
|
|
7699
8702
|
{ agentSessionId: event.state }
|
|
7700
8703
|
);
|
|
7701
|
-
} catch (
|
|
8704
|
+
} catch (err2) {
|
|
7702
8705
|
turnLog.warn(
|
|
7703
|
-
{ err:
|
|
8706
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7704
8707
|
"dispatcher: session-id write failed (will retry next turn)"
|
|
7705
8708
|
);
|
|
7706
8709
|
}
|
|
@@ -7713,6 +8716,9 @@ ${reason}`,
|
|
|
7713
8716
|
turnResolvedConfig = event.resolvedConfig;
|
|
7714
8717
|
} else if (event.type === "text" && skipState.skipped) {
|
|
7715
8718
|
} else {
|
|
8719
|
+
if (event.type === "text" && event.terminal) {
|
|
8720
|
+
await applyRecordedTurnControlIntent();
|
|
8721
|
+
}
|
|
7716
8722
|
await committer.ingestEvent(event);
|
|
7717
8723
|
}
|
|
7718
8724
|
}
|
|
@@ -7723,31 +8729,41 @@ ${reason}`,
|
|
|
7723
8729
|
if (!okResult && !resultReason) {
|
|
7724
8730
|
resultReason = "no_result";
|
|
7725
8731
|
}
|
|
8732
|
+
if (!abortController.signal.aborted) {
|
|
8733
|
+
await applyRecordedTurnControlIntent();
|
|
8734
|
+
}
|
|
7726
8735
|
if (!abortController.signal.aborted && skipState.skipped) {
|
|
7727
8736
|
turnLog.info(
|
|
7728
8737
|
{ reason: skipState.reason, turnId, ok: okResult },
|
|
7729
8738
|
"agent skipped turn (skip_turn)"
|
|
7730
8739
|
);
|
|
8740
|
+
const { afterSeconds: wakeAfter, at: wakeAt, note: wakeNote } = wakeState;
|
|
8741
|
+
const skipWake = wakeNote && (wakeAfter !== null || wakeAt !== null) ? {
|
|
8742
|
+
...wakeAfter !== null ? { afterSeconds: wakeAfter } : {},
|
|
8743
|
+
...wakeAt !== null ? { at: wakeAt } : {},
|
|
8744
|
+
note: wakeNote
|
|
8745
|
+
} : void 0;
|
|
7731
8746
|
try {
|
|
7732
8747
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7733
8748
|
body: SKIPPED_MARKER_BODY,
|
|
7734
8749
|
kind: "skipped",
|
|
7735
8750
|
turnId,
|
|
7736
8751
|
seq: nextSeq(),
|
|
7737
|
-
parentMessageId: payload.messageId
|
|
8752
|
+
parentMessageId: payload.messageId,
|
|
8753
|
+
...skipWake ? { wake: skipWake } : {}
|
|
7738
8754
|
});
|
|
7739
|
-
} catch (
|
|
8755
|
+
} catch (err2) {
|
|
7740
8756
|
turnLog.warn(
|
|
7741
|
-
{ err:
|
|
8757
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7742
8758
|
"dispatcher: skipped-marker commit failed"
|
|
7743
8759
|
);
|
|
7744
8760
|
}
|
|
7745
8761
|
} else {
|
|
7746
8762
|
await committer.finalize(okResult);
|
|
7747
8763
|
}
|
|
7748
|
-
} catch (
|
|
8764
|
+
} catch (err2) {
|
|
7749
8765
|
okResult = false;
|
|
7750
|
-
resultReason =
|
|
8766
|
+
resultReason = err2 instanceof Error ? err2.message : String(err2);
|
|
7751
8767
|
turnLog.error({ err: resultReason }, "dispatcher: SDK query threw");
|
|
7752
8768
|
} finally {
|
|
7753
8769
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -7783,9 +8799,9 @@ ${reason}`,
|
|
|
7783
8799
|
// CT113: the stopped marker is still "about" the triggering message.
|
|
7784
8800
|
parentMessageId: payload.messageId
|
|
7785
8801
|
});
|
|
7786
|
-
} catch (
|
|
8802
|
+
} catch (err2) {
|
|
7787
8803
|
turnLog.warn(
|
|
7788
|
-
{ err:
|
|
8804
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7789
8805
|
"dispatcher: stopped-marker commit failed"
|
|
7790
8806
|
);
|
|
7791
8807
|
}
|
|
@@ -7820,15 +8836,15 @@ ${reason}`,
|
|
|
7820
8836
|
errorReason: body.errorReason ?? null
|
|
7821
8837
|
});
|
|
7822
8838
|
try {
|
|
7823
|
-
await this.opts.api.
|
|
8839
|
+
await this.opts.api.setActiveRun(
|
|
7824
8840
|
workspaceId,
|
|
7825
8841
|
payload.conversationId,
|
|
7826
8842
|
payload.agentId,
|
|
7827
8843
|
body
|
|
7828
8844
|
);
|
|
7829
|
-
} catch (
|
|
8845
|
+
} catch (err2) {
|
|
7830
8846
|
turnLog.warn(
|
|
7831
|
-
{ err:
|
|
8847
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
7832
8848
|
"dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
|
|
7833
8849
|
);
|
|
7834
8850
|
}
|
|
@@ -7872,7 +8888,7 @@ ${reason}`,
|
|
|
7872
8888
|
};
|
|
7873
8889
|
}
|
|
7874
8890
|
// SJ383: cancel a specific (conversation, agent) run if one is in flight in
|
|
7875
|
-
// THIS
|
|
8891
|
+
// THIS companion process. Returns true if an in-flight run was aborted.
|
|
7876
8892
|
cancel(conversationId, agentId) {
|
|
7877
8893
|
const key = runKey(conversationId, agentId);
|
|
7878
8894
|
const ac = this.aborts.get(key);
|
|
@@ -7886,27 +8902,18 @@ ${reason}`,
|
|
|
7886
8902
|
};
|
|
7887
8903
|
|
|
7888
8904
|
// src/manifest.ts
|
|
7889
|
-
var
|
|
8905
|
+
var DEVICE_MANIFEST = {
|
|
7890
8906
|
runtimes: [{ name: "claude-code", version: null }],
|
|
7891
8907
|
capabilities: { hostFs: true, browser: true, userMcp: true }
|
|
7892
8908
|
};
|
|
7893
|
-
|
|
7894
|
-
runtimes: [{ name: "claude-code", version: null }],
|
|
7895
|
-
capabilities: { hostFs: false, browser: false, userMcp: false }
|
|
7896
|
-
};
|
|
7897
|
-
function buildBridgeManifest(opts) {
|
|
7898
|
-
if (process.env.CABANE_BRIDGE_CLASS === "house") {
|
|
7899
|
-
const runtimes2 = [{ name: "claude-code", version: null }];
|
|
7900
|
-
if (opts.cabaneNative) runtimes2.push({ name: "cabane-native", version: null });
|
|
7901
|
-
return { runtimes: runtimes2, capabilities: { ...HOUSE_MANIFEST.capabilities } };
|
|
7902
|
-
}
|
|
8909
|
+
function buildCompanionManifest(opts) {
|
|
7903
8910
|
const v = opts.versions ?? {};
|
|
7904
8911
|
const runtimes = [];
|
|
7905
8912
|
if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
|
|
7906
8913
|
if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
|
|
7907
8914
|
if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
|
|
7908
8915
|
if (opts.cabaneNative) runtimes.push({ name: "cabane-native", version: null });
|
|
7909
|
-
return { runtimes, capabilities: { ...
|
|
8916
|
+
return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
|
|
7910
8917
|
}
|
|
7911
8918
|
|
|
7912
8919
|
// src/harness-status.ts
|
|
@@ -7917,7 +8924,7 @@ var LABELS = {
|
|
|
7917
8924
|
};
|
|
7918
8925
|
function deriveHarnessSnapshot(signals) {
|
|
7919
8926
|
const advertised = new Set(
|
|
7920
|
-
|
|
8927
|
+
buildCompanionManifest({
|
|
7921
8928
|
claudeCode: signals.claudeOnPath,
|
|
7922
8929
|
opencode: signals.opencodeConfigured,
|
|
7923
8930
|
codex: signals.codexEnabled
|
|
@@ -8107,14 +9114,14 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
|
|
|
8107
9114
|
// src/outbox.ts
|
|
8108
9115
|
import {
|
|
8109
9116
|
existsSync as existsSync10,
|
|
8110
|
-
mkdirSync as
|
|
9117
|
+
mkdirSync as mkdirSync11,
|
|
8111
9118
|
readdirSync as readdirSync2,
|
|
8112
9119
|
readFileSync as readFileSync9,
|
|
8113
9120
|
renameSync as renameSync3,
|
|
8114
9121
|
rmSync as rmSync5,
|
|
8115
9122
|
writeFileSync as writeFileSync7
|
|
8116
9123
|
} from "fs";
|
|
8117
|
-
import { join as
|
|
9124
|
+
import { join as join13 } from "path";
|
|
8118
9125
|
var MAX_ENTRIES = 2e3;
|
|
8119
9126
|
var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
8120
9127
|
var Outbox = class {
|
|
@@ -8127,30 +9134,30 @@ var Outbox = class {
|
|
|
8127
9134
|
// Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
|
|
8128
9135
|
// cases route writes at the right tmpdir.
|
|
8129
9136
|
dir() {
|
|
8130
|
-
return
|
|
9137
|
+
return join13(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
|
|
8131
9138
|
}
|
|
8132
9139
|
fileFor(turnId, seq) {
|
|
8133
|
-
return
|
|
9140
|
+
return join13(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
|
|
8134
9141
|
}
|
|
8135
9142
|
// Persist a commit for later draining. Atomic (temp file + rename) so a
|
|
8136
9143
|
// concurrent `list()` never reads a half-written entry, then enforces the
|
|
8137
9144
|
// per-workspace bounds.
|
|
8138
9145
|
persist(entry) {
|
|
8139
9146
|
const dir2 = this.dir();
|
|
8140
|
-
|
|
9147
|
+
mkdirSync11(dir2, { recursive: true });
|
|
8141
9148
|
const target = this.fileFor(entry.turnId, entry.seq);
|
|
8142
9149
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
8143
9150
|
try {
|
|
8144
9151
|
writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
|
|
8145
9152
|
renameSync3(tmp, target);
|
|
8146
|
-
} catch (
|
|
9153
|
+
} catch (err2) {
|
|
8147
9154
|
try {
|
|
8148
9155
|
rmSync5(tmp, { force: true });
|
|
8149
9156
|
} catch {
|
|
8150
9157
|
}
|
|
8151
9158
|
this.log?.warn(
|
|
8152
|
-
{ workspaceId: this.workspaceId, err:
|
|
8153
|
-
"
|
|
9159
|
+
{ workspaceId: this.workspaceId, err: err2 instanceof Error ? err2.message : String(err2) },
|
|
9160
|
+
"companion outbox: failed to persist entry"
|
|
8154
9161
|
);
|
|
8155
9162
|
return;
|
|
8156
9163
|
}
|
|
@@ -8172,7 +9179,7 @@ var Outbox = class {
|
|
|
8172
9179
|
const entries = [];
|
|
8173
9180
|
for (const name of names) {
|
|
8174
9181
|
if (!name.endsWith(".json")) continue;
|
|
8175
|
-
const full =
|
|
9182
|
+
const full = join13(dir2, name);
|
|
8176
9183
|
try {
|
|
8177
9184
|
const parsed = JSON.parse(readFileSync9(full, "utf8"));
|
|
8178
9185
|
if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
|
|
@@ -8208,7 +9215,7 @@ var Outbox = class {
|
|
|
8208
9215
|
dropCorrupt(full) {
|
|
8209
9216
|
this.log?.warn(
|
|
8210
9217
|
{ workspaceId: this.workspaceId, file: full },
|
|
8211
|
-
"
|
|
9218
|
+
"companion outbox: dropping unreadable entry"
|
|
8212
9219
|
);
|
|
8213
9220
|
try {
|
|
8214
9221
|
rmSync5(full, { force: true });
|
|
@@ -8225,7 +9232,7 @@ var Outbox = class {
|
|
|
8225
9232
|
if (now - e.enqueuedAt > MAX_AGE_MS) {
|
|
8226
9233
|
this.log?.warn(
|
|
8227
9234
|
{ workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
|
|
8228
|
-
"
|
|
9235
|
+
"companion outbox: evicting entry past max age (undeliverable)"
|
|
8229
9236
|
);
|
|
8230
9237
|
this.remove(e.turnId, e.seq);
|
|
8231
9238
|
} else {
|
|
@@ -8237,7 +9244,7 @@ var Outbox = class {
|
|
|
8237
9244
|
for (const e of survivors.slice(0, overflow)) {
|
|
8238
9245
|
this.log?.warn(
|
|
8239
9246
|
{ workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
|
|
8240
|
-
"
|
|
9247
|
+
"companion outbox: evicting oldest entry past max size"
|
|
8241
9248
|
);
|
|
8242
9249
|
this.remove(e.turnId, e.seq);
|
|
8243
9250
|
}
|
|
@@ -8324,20 +9331,20 @@ var SseSubscriber = class {
|
|
|
8324
9331
|
try {
|
|
8325
9332
|
await this.connect();
|
|
8326
9333
|
backoff = 500;
|
|
8327
|
-
} catch (
|
|
9334
|
+
} catch (err2) {
|
|
8328
9335
|
if (this.aborted) return;
|
|
8329
|
-
if (
|
|
9336
|
+
if (err2 instanceof ApiError && (err2.status === 401 || err2.status === 403)) {
|
|
8330
9337
|
this.opts.log.error(
|
|
8331
|
-
{ workspaceId: this.opts.workspaceId, status:
|
|
9338
|
+
{ workspaceId: this.opts.workspaceId, status: err2.status },
|
|
8332
9339
|
"SSE auth failed \u2014 tearing down this workspace subscriber"
|
|
8333
9340
|
);
|
|
8334
|
-
this.opts.onAuthFailure(
|
|
9341
|
+
this.opts.onAuthFailure(err2.status);
|
|
8335
9342
|
return;
|
|
8336
9343
|
}
|
|
8337
9344
|
this.opts.log.warn(
|
|
8338
9345
|
{
|
|
8339
9346
|
workspaceId: this.opts.workspaceId,
|
|
8340
|
-
err:
|
|
9347
|
+
err: err2 instanceof Error ? err2.message : String(err2),
|
|
8341
9348
|
backoff
|
|
8342
9349
|
},
|
|
8343
9350
|
"SSE disconnected; reconnecting"
|
|
@@ -8400,7 +9407,7 @@ function sleep3(ms) {
|
|
|
8400
9407
|
// src/version.ts
|
|
8401
9408
|
import { createRequire as createRequire2 } from "module";
|
|
8402
9409
|
var pkg = createRequire2(import.meta.url)("../package.json");
|
|
8403
|
-
var
|
|
9410
|
+
var COMPANION_VERSION = pkg.version;
|
|
8404
9411
|
|
|
8405
9412
|
// src/supervisor.ts
|
|
8406
9413
|
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
@@ -8414,7 +9421,7 @@ var ASSIGNMENTS_POLL_MS = 6e4;
|
|
|
8414
9421
|
var DRAIN_BASE_MS = 1e3;
|
|
8415
9422
|
var DRAIN_MAX_MS = 3e4;
|
|
8416
9423
|
var DRAIN_IDLE_MS = 15e3;
|
|
8417
|
-
var
|
|
9424
|
+
var CompanionSupervisor = class {
|
|
8418
9425
|
workspaces = /* @__PURE__ */ new Map();
|
|
8419
9426
|
config;
|
|
8420
9427
|
log;
|
|
@@ -8438,10 +9445,12 @@ var BridgeSupervisor = class {
|
|
|
8438
9445
|
dispatcherFactory;
|
|
8439
9446
|
deviceApi = null;
|
|
8440
9447
|
heartbeatTimer = null;
|
|
9448
|
+
inFlightHeartbeat = null;
|
|
8441
9449
|
pollTimer = null;
|
|
8442
9450
|
refreshing = false;
|
|
8443
9451
|
stopped = false;
|
|
8444
|
-
|
|
9452
|
+
draining = false;
|
|
9453
|
+
// CT484: latch so the companion/server version-skew warning is logged once, not
|
|
8445
9454
|
// on every 30s heartbeat.
|
|
8446
9455
|
versionSkewWarned = false;
|
|
8447
9456
|
// This device's id, captured from the heartbeat / assignments response. The SSE
|
|
@@ -8467,18 +9476,18 @@ var BridgeSupervisor = class {
|
|
|
8467
9476
|
this.dispatcherFactory = opts.dispatcherFactory;
|
|
8468
9477
|
}
|
|
8469
9478
|
// Stand up the data plane: pair check, initial assignments pull, then the
|
|
8470
|
-
// heartbeat + poll loops. A
|
|
9479
|
+
// heartbeat + poll loops. A companion with no device token (logged out) does
|
|
8471
9480
|
// nothing but say so.
|
|
8472
9481
|
async start() {
|
|
8473
9482
|
this.log.info(
|
|
8474
|
-
{ protocolVersion: TURN_PROTOCOL_VERSION, version:
|
|
8475
|
-
"
|
|
9483
|
+
{ protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
|
|
9484
|
+
"companion: starting"
|
|
8476
9485
|
);
|
|
8477
9486
|
void this.refreshHarnessStatuses();
|
|
8478
9487
|
if (!this.config.deviceToken) {
|
|
8479
|
-
this.log.warn("
|
|
9488
|
+
this.log.warn("companion: not paired (no device token) \u2014 run `cabane-companion pair`");
|
|
8480
9489
|
process.stdout.write(
|
|
8481
|
-
"
|
|
9490
|
+
"companion: this device is not paired \u2014 run `cabane-companion pair` and paste the string from the cabane app.\n"
|
|
8482
9491
|
);
|
|
8483
9492
|
return;
|
|
8484
9493
|
}
|
|
@@ -8487,8 +9496,8 @@ var BridgeSupervisor = class {
|
|
|
8487
9496
|
deviceToken: this.config.deviceToken
|
|
8488
9497
|
});
|
|
8489
9498
|
await this.refreshAssignments();
|
|
8490
|
-
|
|
8491
|
-
this.heartbeatTimer = setInterval(() =>
|
|
9499
|
+
this.kickHeartbeat();
|
|
9500
|
+
this.heartbeatTimer = setInterval(() => this.kickHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
|
8492
9501
|
this.heartbeatTimer.unref?.();
|
|
8493
9502
|
this.pollTimer = setInterval(() => void this.refreshAssignments(), ASSIGNMENTS_POLL_MS);
|
|
8494
9503
|
this.pollTimer.unref?.();
|
|
@@ -8501,20 +9510,28 @@ var BridgeSupervisor = class {
|
|
|
8501
9510
|
return [...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : []);
|
|
8502
9511
|
}
|
|
8503
9512
|
// ---- device-level loops ----
|
|
9513
|
+
kickHeartbeat() {
|
|
9514
|
+
if (this.draining || this.inFlightHeartbeat) return;
|
|
9515
|
+
const pending = this.sendHeartbeat();
|
|
9516
|
+
this.inFlightHeartbeat = pending;
|
|
9517
|
+
void pending.finally(() => {
|
|
9518
|
+
if (this.inFlightHeartbeat === pending) this.inFlightHeartbeat = null;
|
|
9519
|
+
});
|
|
9520
|
+
}
|
|
8504
9521
|
async sendHeartbeat() {
|
|
8505
9522
|
if (!this.deviceApi) return;
|
|
8506
9523
|
await this.refreshHarnessStatuses();
|
|
8507
9524
|
try {
|
|
8508
|
-
const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "
|
|
9525
|
+
const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "companion: secrets"));
|
|
8509
9526
|
const connectorReports = this.connectorHealth.reports();
|
|
8510
9527
|
const opencodeModels = this.config.opencode?.serverUrl ? await enumerateOpencodeModels(this.config.opencode.serverUrl) : null;
|
|
8511
9528
|
const res = await this.deviceApi.heartbeat({
|
|
8512
|
-
version:
|
|
9529
|
+
version: COMPANION_VERSION,
|
|
8513
9530
|
exposedSecretNames: store.names(),
|
|
8514
9531
|
// Report each runtime only when this device can actually run it: CT309
|
|
8515
9532
|
// claude-code when `claude` is on PATH, CT270 opencode when the operator
|
|
8516
9533
|
// configured an `opencode serve`.
|
|
8517
|
-
manifest:
|
|
9534
|
+
manifest: buildCompanionManifest({
|
|
8518
9535
|
// CT586: prefer the live re-probe's presence; fall back to the boot probe
|
|
8519
9536
|
// until the first re-probe lands. Same exit-0 `claude --version` signal
|
|
8520
9537
|
// either way, so the manifest's claude-code advertising is unchanged in
|
|
@@ -8525,17 +9542,16 @@ var BridgeSupervisor = class {
|
|
|
8525
9542
|
// like opencode — the CLI's presence is the operator's responsibility;
|
|
8526
9543
|
// a misconfigured device fails the turn loudly, never silently).
|
|
8527
9544
|
codex: isCodexEnabled(this.config),
|
|
8528
|
-
// CT598: advertise the native runtime when an OpenRouter key is set
|
|
8529
|
-
//
|
|
8530
|
-
// user). Key absent → not advertised, so a native turn never routes here.
|
|
9545
|
+
// CT598: advertise the native runtime when an OpenRouter key is set. Key
|
|
9546
|
+
// absent → not advertised, so a native turn never routes here.
|
|
8531
9547
|
cabaneNative: isCabaneNativeEnabled(),
|
|
8532
9548
|
// CT571/CT586: each runtime's `version` from the latest harness probe
|
|
8533
9549
|
// (fail-soft to null). Informational only — the server matches on name.
|
|
8534
9550
|
versions: this.harnessVersions
|
|
8535
9551
|
}),
|
|
8536
9552
|
// CT566: echo the last classified credential state per runtime, when the
|
|
8537
|
-
//
|
|
8538
|
-
// failure/heal, so a fresh
|
|
9553
|
+
// companion has seen any. Omitted (undefined) until the first observed
|
|
9554
|
+
// failure/heal, so a fresh companion's beat is unchanged and the server's
|
|
8539
9555
|
// manifest synthesis (status-less rows) still runs.
|
|
8540
9556
|
...connectorReports.length > 0 ? { connectors: connectorReports } : {},
|
|
8541
9557
|
// CT584: include enumerated models only when the probe SUCCEEDED (non-null).
|
|
@@ -8546,25 +9562,25 @@ var BridgeSupervisor = class {
|
|
|
8546
9562
|
this.hub.setDevice({ deviceId: res.deviceId });
|
|
8547
9563
|
this.deviceId = res.deviceId;
|
|
8548
9564
|
this.checkVersionSkew(res.serverVersion);
|
|
8549
|
-
} catch (
|
|
9565
|
+
} catch (err2) {
|
|
8550
9566
|
this.log.warn(
|
|
8551
|
-
{ err:
|
|
8552
|
-
"
|
|
9567
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
9568
|
+
"companion: device heartbeat failed (will retry on next tick)"
|
|
8553
9569
|
);
|
|
8554
9570
|
}
|
|
8555
9571
|
}
|
|
8556
|
-
// CT484: belt-and-suspenders beside shipping the
|
|
9572
|
+
// CT484: belt-and-suspenders beside shipping the companion in lockstep inside the
|
|
8557
9573
|
// artifact — if the server reports a build version that differs from this
|
|
8558
|
-
//
|
|
8559
|
-
// an npm-pinned
|
|
8560
|
-
//
|
|
9574
|
+
// companion's, warn loudly (once). This is exactly the skew that bit the M1 walk:
|
|
9575
|
+
// an npm-pinned companion lagging a from-develop server. The elimination (bundled
|
|
9576
|
+
// companion) makes it match by construction; this catches a companion run out of band.
|
|
8561
9577
|
checkVersionSkew(serverVersion) {
|
|
8562
9578
|
if (this.versionSkewWarned) return;
|
|
8563
|
-
if (!serverVersion || serverVersion ===
|
|
9579
|
+
if (!serverVersion || serverVersion === COMPANION_VERSION) return;
|
|
8564
9580
|
this.versionSkewWarned = true;
|
|
8565
9581
|
this.log.warn(
|
|
8566
|
-
{
|
|
8567
|
-
"
|
|
9582
|
+
{ companionVersion: COMPANION_VERSION, serverVersion },
|
|
9583
|
+
"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."
|
|
8568
9584
|
);
|
|
8569
9585
|
}
|
|
8570
9586
|
// Pull assignments and reconcile the live runner set against them. Re-entrancy
|
|
@@ -8584,12 +9600,12 @@ var BridgeSupervisor = class {
|
|
|
8584
9600
|
const resp = await this.deviceApi.getAssignments();
|
|
8585
9601
|
items = resp.assignments;
|
|
8586
9602
|
device = resp.device;
|
|
8587
|
-
} catch (
|
|
9603
|
+
} catch (err2) {
|
|
8588
9604
|
this.log.error(
|
|
8589
|
-
{ err:
|
|
8590
|
-
"
|
|
9605
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
9606
|
+
"companion: assignments pull failed \u2014 check the device is still active in the cabane app"
|
|
8591
9607
|
);
|
|
8592
|
-
this.hub.setDeviceError(
|
|
9608
|
+
this.hub.setDeviceError(err2 instanceof Error ? err2.message : String(err2));
|
|
8593
9609
|
return;
|
|
8594
9610
|
}
|
|
8595
9611
|
this.hub.setDevice({ deviceId: device.id, deviceLabel: device.label });
|
|
@@ -8652,14 +9668,14 @@ var BridgeSupervisor = class {
|
|
|
8652
9668
|
const credential = it.credential ?? getCredential(it.agentId);
|
|
8653
9669
|
const runConfig = parseRunConfig(
|
|
8654
9670
|
it.runConfig,
|
|
8655
|
-
(m) => this.log.warn({ agentId: it.agentId, msg: m }, "
|
|
9671
|
+
(m) => this.log.warn({ agentId: it.agentId, msg: m }, "companion: run-config")
|
|
8656
9672
|
);
|
|
8657
9673
|
const required = requiredSecretNames(runConfig.mcpServers);
|
|
8658
9674
|
const missing = required.filter((n) => !exposed.has(n));
|
|
8659
9675
|
if (!credential) {
|
|
8660
9676
|
this.log.error(
|
|
8661
9677
|
{ workspaceId, agentId: it.agentId, username: it.agentUsername },
|
|
8662
|
-
"
|
|
9678
|
+
"companion: agent assigned but no credential on this device \u2014 re-assign it in the cabane app"
|
|
8663
9679
|
);
|
|
8664
9680
|
this.removeAgent(wr, it.agentId);
|
|
8665
9681
|
this.hub.setAgent(workspaceId, {
|
|
@@ -8693,7 +9709,7 @@ var BridgeSupervisor = class {
|
|
|
8693
9709
|
if (missing.length > 0) {
|
|
8694
9710
|
this.log.warn(
|
|
8695
9711
|
{ workspaceId, agentId: it.agentId, missing },
|
|
8696
|
-
"
|
|
9712
|
+
"companion: agent needs secrets this device does not expose (turns using them will fail)"
|
|
8697
9713
|
);
|
|
8698
9714
|
}
|
|
8699
9715
|
}
|
|
@@ -8732,7 +9748,7 @@ var BridgeSupervisor = class {
|
|
|
8732
9748
|
drain2.kick();
|
|
8733
9749
|
this.log.info(
|
|
8734
9750
|
{ workspaceId: it.workspaceId, agentId: it.agentId, username: it.agentUsername },
|
|
8735
|
-
"
|
|
9751
|
+
"companion: running agent"
|
|
8736
9752
|
);
|
|
8737
9753
|
}
|
|
8738
9754
|
removeAgent(wr, agentId) {
|
|
@@ -8741,7 +9757,10 @@ var BridgeSupervisor = class {
|
|
|
8741
9757
|
runner.cancelDrain();
|
|
8742
9758
|
wr.agents.delete(agentId);
|
|
8743
9759
|
this.hub.removeAgent(wr.workspaceId, agentId);
|
|
8744
|
-
this.log.info(
|
|
9760
|
+
this.log.info(
|
|
9761
|
+
{ workspaceId: wr.workspaceId, agentId },
|
|
9762
|
+
"companion: stopped agent (unassigned)"
|
|
9763
|
+
);
|
|
8745
9764
|
}
|
|
8746
9765
|
buildDispatcher(ctx) {
|
|
8747
9766
|
if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
|
|
@@ -8770,7 +9789,7 @@ var BridgeSupervisor = class {
|
|
|
8770
9789
|
// CT598: register the cabane-native adapter when an OpenRouter key is set;
|
|
8771
9790
|
// unset leaves a `cabane-native/…` turn to fail loudly (no silent fallback).
|
|
8772
9791
|
...cabaneNativeApiKey() ? { cabaneNativeApiKey: cabaneNativeApiKey() } : {},
|
|
8773
|
-
// CT556: per-turn timeout watchdog windows, from the
|
|
9792
|
+
// CT556: per-turn timeout watchdog windows, from the companion's own env
|
|
8774
9793
|
// (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
|
|
8775
9794
|
// dispatcher's baked-in defaults (10 min idle / 45 min total).
|
|
8776
9795
|
...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
|
|
@@ -8809,14 +9828,14 @@ var BridgeSupervisor = class {
|
|
|
8809
9828
|
this.hub.setAuthFailed(wr.workspaceId);
|
|
8810
9829
|
this.log.error(
|
|
8811
9830
|
{ workspaceId: wr.workspaceId, status: status2, sseAgentId: wr.sseAgentId },
|
|
8812
|
-
"
|
|
9831
|
+
"companion: workspace stream auth failed \u2014 re-pulling assignments"
|
|
8813
9832
|
);
|
|
8814
9833
|
wr.sseAgentId = null;
|
|
8815
9834
|
void this.refreshAssignments();
|
|
8816
9835
|
}
|
|
8817
9836
|
});
|
|
8818
9837
|
wr.sub.start();
|
|
8819
|
-
this.log.info({ workspaceId: wr.workspaceId }, "
|
|
9838
|
+
this.log.info({ workspaceId: wr.workspaceId }, "companion: subscribed");
|
|
8820
9839
|
}
|
|
8821
9840
|
async removeWorkspace(workspaceId) {
|
|
8822
9841
|
const wr = this.workspaces.get(workspaceId);
|
|
@@ -8839,17 +9858,17 @@ var BridgeSupervisor = class {
|
|
|
8839
9858
|
let wire;
|
|
8840
9859
|
try {
|
|
8841
9860
|
wire = JSON.parse(ev.data);
|
|
8842
|
-
} catch (
|
|
9861
|
+
} catch (err2) {
|
|
8843
9862
|
this.log.warn(
|
|
8844
|
-
{ err:
|
|
9863
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
8845
9864
|
"malformed SSE payload"
|
|
8846
9865
|
);
|
|
8847
9866
|
return;
|
|
8848
9867
|
}
|
|
8849
9868
|
if (ev.id) wr.cursor.observe(ev.id);
|
|
8850
|
-
if (wire.type === "
|
|
9869
|
+
if (wire.type === "device:cancel_requested") {
|
|
8851
9870
|
const payload2 = {
|
|
8852
|
-
type: "
|
|
9871
|
+
type: "device:cancel_requested",
|
|
8853
9872
|
...wire.payload
|
|
8854
9873
|
};
|
|
8855
9874
|
const agent2 = wr.agents.get(payload2.agentId);
|
|
@@ -8869,12 +9888,13 @@ var BridgeSupervisor = class {
|
|
|
8869
9888
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
8870
9889
|
return;
|
|
8871
9890
|
}
|
|
8872
|
-
if (wire.type !== "
|
|
9891
|
+
if (wire.type !== "device:dispatch_requested") {
|
|
8873
9892
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
8874
9893
|
return;
|
|
8875
9894
|
}
|
|
9895
|
+
if (this.draining) return;
|
|
8876
9896
|
const payload = {
|
|
8877
|
-
type: "
|
|
9897
|
+
type: "device:dispatch_requested",
|
|
8878
9898
|
...wire.payload
|
|
8879
9899
|
};
|
|
8880
9900
|
let agent = wr.agents.get(payload.agentId);
|
|
@@ -8887,15 +9907,15 @@ var BridgeSupervisor = class {
|
|
|
8887
9907
|
}
|
|
8888
9908
|
const chainKey = `${payload.conversationId}|${payload.agentId}`;
|
|
8889
9909
|
const prev = wr.chains.get(chainKey) ?? Promise.resolve();
|
|
8890
|
-
const tail = prev.then(() => this.runDispatch(wr, ev, payload, agent)).catch((
|
|
9910
|
+
const tail = prev.then(() => this.runDispatch(wr, ev, payload, agent)).catch((err2) => {
|
|
8891
9911
|
this.log.warn(
|
|
8892
9912
|
{
|
|
8893
9913
|
workspaceId: wr.workspaceId,
|
|
8894
9914
|
conversationId: payload.conversationId,
|
|
8895
9915
|
agentId: payload.agentId,
|
|
8896
|
-
err:
|
|
9916
|
+
err: err2 instanceof Error ? err2.message : String(err2)
|
|
8897
9917
|
},
|
|
8898
|
-
"
|
|
9918
|
+
"companion: conversation turn handler threw"
|
|
8899
9919
|
);
|
|
8900
9920
|
});
|
|
8901
9921
|
wr.chains.set(chainKey, tail);
|
|
@@ -8923,7 +9943,7 @@ var BridgeSupervisor = class {
|
|
|
8923
9943
|
if (hasCompleted(workspaceId, ev.id)) {
|
|
8924
9944
|
this.log.info(
|
|
8925
9945
|
{ workspaceId, eventId: ev.id },
|
|
8926
|
-
"
|
|
9946
|
+
"companion: skipping already-completed event (resume after restart)"
|
|
8927
9947
|
);
|
|
8928
9948
|
wr.cursor.settle(ev.id);
|
|
8929
9949
|
return;
|
|
@@ -8931,7 +9951,7 @@ var BridgeSupervisor = class {
|
|
|
8931
9951
|
if (noResume()) {
|
|
8932
9952
|
this.log.warn(
|
|
8933
9953
|
{ workspaceId, eventId: ev.id },
|
|
8934
|
-
"
|
|
9954
|
+
"companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
|
|
8935
9955
|
);
|
|
8936
9956
|
markCompleted(workspaceId, ev.id);
|
|
8937
9957
|
wr.cursor.settle(ev.id);
|
|
@@ -8941,7 +9961,7 @@ var BridgeSupervisor = class {
|
|
|
8941
9961
|
if (attempt > MAX_RESUME_ATTEMPTS) {
|
|
8942
9962
|
this.log.error(
|
|
8943
9963
|
{ workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
8944
|
-
"
|
|
9964
|
+
"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)"
|
|
8945
9965
|
);
|
|
8946
9966
|
markCompleted(workspaceId, ev.id);
|
|
8947
9967
|
wr.cursor.settle(ev.id);
|
|
@@ -8949,7 +9969,7 @@ var BridgeSupervisor = class {
|
|
|
8949
9969
|
}
|
|
8950
9970
|
this.log.info(
|
|
8951
9971
|
{ workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
8952
|
-
"
|
|
9972
|
+
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
8953
9973
|
);
|
|
8954
9974
|
}
|
|
8955
9975
|
if (ev.id) markDispatched(workspaceId, ev.id);
|
|
@@ -8985,11 +10005,11 @@ var BridgeSupervisor = class {
|
|
|
8985
10005
|
} else {
|
|
8986
10006
|
drainDelay = DRAIN_BASE_MS;
|
|
8987
10007
|
}
|
|
8988
|
-
} catch (
|
|
10008
|
+
} catch (err2) {
|
|
8989
10009
|
drainDelay = Math.min(drainDelay * 2, DRAIN_MAX_MS);
|
|
8990
10010
|
this.log.warn(
|
|
8991
|
-
{ agentId, err:
|
|
8992
|
-
"
|
|
10011
|
+
{ agentId, err: err2 instanceof Error ? err2.message : String(err2) },
|
|
10012
|
+
"companion: outbox drain pass threw (will retry with backoff)"
|
|
8993
10013
|
);
|
|
8994
10014
|
} finally {
|
|
8995
10015
|
if (!drainStopped) {
|
|
@@ -9055,10 +10075,10 @@ var BridgeSupervisor = class {
|
|
|
9055
10075
|
codex: signals.codexVersion
|
|
9056
10076
|
};
|
|
9057
10077
|
this.hub.setHarnesses(deriveHarnessSnapshot(signals).harnesses);
|
|
9058
|
-
} catch (
|
|
10078
|
+
} catch (err2) {
|
|
9059
10079
|
this.log.warn(
|
|
9060
|
-
{ err:
|
|
9061
|
-
"
|
|
10080
|
+
{ err: err2 instanceof Error ? err2.message : String(err2) },
|
|
10081
|
+
"companion: harness probe failed (will retry on next beat)"
|
|
9062
10082
|
);
|
|
9063
10083
|
}
|
|
9064
10084
|
}
|
|
@@ -9083,7 +10103,7 @@ var BridgeSupervisor = class {
|
|
|
9083
10103
|
next = { ...this.config, codex: { enabled: true } };
|
|
9084
10104
|
} else {
|
|
9085
10105
|
const serverUrl = input.serverUrl.trim();
|
|
9086
|
-
const parsed =
|
|
10106
|
+
const parsed = companionConfigSchema.shape.opencode.safeParse({ serverUrl });
|
|
9087
10107
|
if (!parsed.success) {
|
|
9088
10108
|
return {
|
|
9089
10109
|
ok: false,
|
|
@@ -9104,7 +10124,7 @@ var BridgeSupervisor = class {
|
|
|
9104
10124
|
saveConfig(next);
|
|
9105
10125
|
this.rebuildDispatchers();
|
|
9106
10126
|
await this.refreshHarnessStatuses();
|
|
9107
|
-
|
|
10127
|
+
this.kickHeartbeat();
|
|
9108
10128
|
return { ok: true };
|
|
9109
10129
|
}
|
|
9110
10130
|
// Re-create every running agent's Dispatcher from the CURRENT config, keeping
|
|
@@ -9140,6 +10160,38 @@ var BridgeSupervisor = class {
|
|
|
9140
10160
|
[...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
|
|
9141
10161
|
);
|
|
9142
10162
|
}
|
|
10163
|
+
async drainForRestart(graceMs) {
|
|
10164
|
+
this.draining = true;
|
|
10165
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
10166
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
10167
|
+
if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
|
|
10168
|
+
if (!this.deviceApi) throw new Error("cannot establish deploy drain before pairing");
|
|
10169
|
+
await this.deviceApi.beginDrain();
|
|
10170
|
+
for (const wr of this.workspaces.values()) wr.sub?.stop();
|
|
10171
|
+
const turns = [...this.workspaces.values()].flatMap((wr) => [...wr.chains.values()]);
|
|
10172
|
+
let timedOut = false;
|
|
10173
|
+
if (turns.length > 0) {
|
|
10174
|
+
let timer;
|
|
10175
|
+
await Promise.race([
|
|
10176
|
+
Promise.allSettled(turns),
|
|
10177
|
+
new Promise((resolve) => {
|
|
10178
|
+
timer = setTimeout(() => {
|
|
10179
|
+
timedOut = true;
|
|
10180
|
+
resolve();
|
|
10181
|
+
}, Math.max(0, graceMs));
|
|
10182
|
+
timer.unref?.();
|
|
10183
|
+
})
|
|
10184
|
+
]);
|
|
10185
|
+
if (timer) clearTimeout(timer);
|
|
10186
|
+
}
|
|
10187
|
+
await Promise.allSettled(
|
|
10188
|
+
[...this.workspaces.values()].flatMap(
|
|
10189
|
+
(wr) => [...wr.agents.values()].map((agent) => agent.api.drainOutbox())
|
|
10190
|
+
)
|
|
10191
|
+
);
|
|
10192
|
+
await this.shutdown();
|
|
10193
|
+
return { drained: !timedOut };
|
|
10194
|
+
}
|
|
9143
10195
|
async requestStop() {
|
|
9144
10196
|
await this.shutdown();
|
|
9145
10197
|
this.exitFn(0);
|
|
@@ -9180,17 +10232,17 @@ var RECOVERABLE_CODES = /* @__PURE__ */ new Set([
|
|
|
9180
10232
|
"ERR_STREAM_DESTROYED",
|
|
9181
10233
|
"ERR_STREAM_WRITE_AFTER_END"
|
|
9182
10234
|
]);
|
|
9183
|
-
function errorCode(
|
|
9184
|
-
if (
|
|
9185
|
-
const code =
|
|
10235
|
+
function errorCode(err2) {
|
|
10236
|
+
if (err2 && typeof err2 === "object" && "code" in err2) {
|
|
10237
|
+
const code = err2.code;
|
|
9186
10238
|
if (typeof code === "string") return code;
|
|
9187
10239
|
}
|
|
9188
10240
|
return void 0;
|
|
9189
10241
|
}
|
|
9190
|
-
function isRecoverableSocketError(
|
|
9191
|
-
const code = errorCode(
|
|
10242
|
+
function isRecoverableSocketError(err2) {
|
|
10243
|
+
const code = errorCode(err2);
|
|
9192
10244
|
if (code && RECOVERABLE_CODES.has(code)) return true;
|
|
9193
|
-
const message =
|
|
10245
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
9194
10246
|
return /\bEPIPE\b|\bECONNRESET\b/.test(message);
|
|
9195
10247
|
}
|
|
9196
10248
|
function installProcessSafetyNet(log, opts = {}) {
|
|
@@ -9199,37 +10251,37 @@ function installProcessSafetyNet(log, opts = {}) {
|
|
|
9199
10251
|
stream.on("error", () => {
|
|
9200
10252
|
});
|
|
9201
10253
|
}
|
|
9202
|
-
proc.on("uncaughtException", (
|
|
10254
|
+
proc.on("uncaughtException", (err2) => handleUncaught(log, err2, "uncaughtException"));
|
|
9203
10255
|
proc.on(
|
|
9204
10256
|
"unhandledRejection",
|
|
9205
10257
|
(reason) => handleUncaught(log, reason, "unhandledRejection")
|
|
9206
10258
|
);
|
|
9207
10259
|
}
|
|
9208
|
-
function handleUncaught(log,
|
|
9209
|
-
const message =
|
|
9210
|
-
const code = errorCode(
|
|
9211
|
-
if (isRecoverableSocketError(
|
|
10260
|
+
function handleUncaught(log, err2, origin) {
|
|
10261
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
10262
|
+
const code = errorCode(err2);
|
|
10263
|
+
if (isRecoverableSocketError(err2)) {
|
|
9212
10264
|
log.warn(
|
|
9213
10265
|
{ origin, code, err: message },
|
|
9214
|
-
"
|
|
10266
|
+
"companion: recovered from a socket error (kept running \u2014 a broken pipe never kills the companion)"
|
|
9215
10267
|
);
|
|
9216
10268
|
return;
|
|
9217
10269
|
}
|
|
9218
10270
|
log.error(
|
|
9219
|
-
{ origin, code, err: message, stack:
|
|
9220
|
-
"
|
|
10271
|
+
{ origin, code, err: message, stack: err2 instanceof Error ? err2.stack : void 0 },
|
|
10272
|
+
"companion: uncaught error (kept running \u2014 see the stack above)"
|
|
9221
10273
|
);
|
|
9222
10274
|
}
|
|
9223
10275
|
|
|
9224
10276
|
// src/crash-marker.ts
|
|
9225
|
-
import { existsSync as existsSync11, mkdirSync as
|
|
9226
|
-
import { join as
|
|
10277
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync12, readFileSync as readFileSync10, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
|
|
10278
|
+
import { join as join14 } from "path";
|
|
9227
10279
|
function crashMarkerPath() {
|
|
9228
|
-
return
|
|
10280
|
+
return join14(cabaneDir(), "last-error.json");
|
|
9229
10281
|
}
|
|
9230
10282
|
function recordCrash(rec2) {
|
|
9231
10283
|
try {
|
|
9232
|
-
|
|
10284
|
+
mkdirSync12(cabaneDir(), { recursive: true });
|
|
9233
10285
|
writeFileSync8(crashMarkerPath(), JSON.stringify(rec2, null, 2) + "\n");
|
|
9234
10286
|
} catch {
|
|
9235
10287
|
}
|
|
@@ -9243,7 +10295,7 @@ function clearCrash() {
|
|
|
9243
10295
|
}
|
|
9244
10296
|
|
|
9245
10297
|
// src/runtime.ts
|
|
9246
|
-
async function
|
|
10298
|
+
async function createCompanionRuntime(opts = {}) {
|
|
9247
10299
|
const log = getLogger();
|
|
9248
10300
|
installProcessSafetyNet(log);
|
|
9249
10301
|
const probeClaude = opts.probeClaude ?? claudeOnPath;
|
|
@@ -9253,14 +10305,14 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
9253
10305
|
cfg = requireConfig();
|
|
9254
10306
|
claudeCode = await probeClaude();
|
|
9255
10307
|
await ensureRuntimeAvailable(cfg, { probeClaude: async () => claudeCode });
|
|
9256
|
-
} catch (
|
|
10308
|
+
} catch (err2) {
|
|
9257
10309
|
recordCrash({
|
|
9258
|
-
reason:
|
|
9259
|
-
...errorCode(
|
|
10310
|
+
reason: err2 instanceof Error ? err2.message : String(err2),
|
|
10311
|
+
...errorCode(err2) ? { code: errorCode(err2) } : {},
|
|
9260
10312
|
origin: "startup",
|
|
9261
10313
|
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
9262
10314
|
});
|
|
9263
|
-
throw
|
|
10315
|
+
throw err2;
|
|
9264
10316
|
}
|
|
9265
10317
|
if (cfg.logLevel) log.level = cfg.logLevel;
|
|
9266
10318
|
const harnessVersions = await probeHarnessVersions({
|
|
@@ -9277,23 +10329,29 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
9277
10329
|
url: "",
|
|
9278
10330
|
port: 0,
|
|
9279
10331
|
startedAt,
|
|
9280
|
-
daemon: process.env.
|
|
10332
|
+
daemon: process.env.CABANE_COMPANION_DAEMON === "1",
|
|
9281
10333
|
instanceId
|
|
9282
10334
|
});
|
|
9283
10335
|
if (!claim.acquired) {
|
|
9284
10336
|
return { ok: false, reason: "already-running", existing: claim.existing ?? null };
|
|
9285
10337
|
}
|
|
9286
10338
|
process.on("exit", () => clearRuntimeState());
|
|
9287
|
-
const hub = new
|
|
10339
|
+
const hub = new CompanionStateHub({
|
|
9288
10340
|
// CT29: one device, one base URL — the cabane instance this device is paired
|
|
9289
10341
|
// with. The dashboard's connection line shows it.
|
|
9290
10342
|
baseUrl: cfg.baseUrl,
|
|
9291
|
-
|
|
10343
|
+
companionVersion: COMPANION_VERSION,
|
|
9292
10344
|
// SJ516 F4: surfaced on `/api/status` so `stop`/`status` can confirm the
|
|
9293
|
-
// process behind the marker pid is this
|
|
10345
|
+
// process behind the marker pid is this companion (not a recycled pid).
|
|
9294
10346
|
instanceId
|
|
9295
10347
|
});
|
|
9296
|
-
const supervisor = new
|
|
10348
|
+
const supervisor = new CompanionSupervisor({
|
|
10349
|
+
config: cfg,
|
|
10350
|
+
log,
|
|
10351
|
+
hub,
|
|
10352
|
+
claudeCode,
|
|
10353
|
+
harnessVersions
|
|
10354
|
+
});
|
|
9297
10355
|
await supervisor.start();
|
|
9298
10356
|
const preferredPort = opts.port ?? cfg.dashboardPort;
|
|
9299
10357
|
const dashboard = await startDashboard({
|
|
@@ -9308,9 +10366,9 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
9308
10366
|
port: dashboard.port,
|
|
9309
10367
|
startedAt,
|
|
9310
10368
|
// SJ495: the daemon launcher sets this env on the detached child, so the
|
|
9311
|
-
// marker records whether this
|
|
10369
|
+
// marker records whether this companion is backgrounded (foreground start
|
|
9312
10370
|
// leaves it unset → false).
|
|
9313
|
-
daemon: process.env.
|
|
10371
|
+
daemon: process.env.CABANE_COMPANION_DAEMON === "1",
|
|
9314
10372
|
instanceId
|
|
9315
10373
|
});
|
|
9316
10374
|
clearCrash();
|
|
@@ -9327,20 +10385,32 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
9327
10385
|
};
|
|
9328
10386
|
return {
|
|
9329
10387
|
ok: true,
|
|
9330
|
-
runtime: {
|
|
10388
|
+
runtime: {
|
|
10389
|
+
url: dashboard.url,
|
|
10390
|
+
port: dashboard.port,
|
|
10391
|
+
config: cfg,
|
|
10392
|
+
stop: stop2,
|
|
10393
|
+
drainForRestart: async (graceMs) => {
|
|
10394
|
+
clearRuntimeState();
|
|
10395
|
+
const result = await supervisor.drainForRestart(graceMs);
|
|
10396
|
+
await dashboard.close();
|
|
10397
|
+
stopped = true;
|
|
10398
|
+
return result;
|
|
10399
|
+
}
|
|
10400
|
+
}
|
|
9331
10401
|
};
|
|
9332
10402
|
}
|
|
9333
10403
|
|
|
9334
10404
|
// src/commands/start.ts
|
|
9335
10405
|
var FORCE_EXIT_MS = 4e3;
|
|
9336
10406
|
async function start(opts = {}) {
|
|
9337
|
-
const result = await
|
|
10407
|
+
const result = await createCompanionRuntime({
|
|
9338
10408
|
...opts.port !== void 0 ? { port: opts.port } : {}
|
|
9339
10409
|
});
|
|
9340
10410
|
if (!result.ok) {
|
|
9341
10411
|
const existing = result.existing;
|
|
9342
10412
|
process.stdout.write(
|
|
9343
|
-
`Cabane
|
|
10413
|
+
`Cabane Companion is already running (pid ${existing?.pid ?? "?"}).
|
|
9344
10414
|
` + (existing?.url ? `\u2192 Dashboard: ${existing.url}
|
|
9345
10415
|
` : "") + `Stop it first with \`cabane-companion stop\` if you want to relaunch.
|
|
9346
10416
|
`
|
|
@@ -9349,7 +10419,7 @@ async function start(opts = {}) {
|
|
|
9349
10419
|
}
|
|
9350
10420
|
const runtime = result.runtime;
|
|
9351
10421
|
process.stdout.write(`
|
|
9352
|
-
Cabane
|
|
10422
|
+
Cabane Companion is running.
|
|
9353
10423
|
`);
|
|
9354
10424
|
process.stdout.write(`\u2192 Dashboard: ${runtime.url}
|
|
9355
10425
|
|
|
@@ -9371,16 +10441,16 @@ Cabane Bridge is running.
|
|
|
9371
10441
|
const shutdown = async (signal) => {
|
|
9372
10442
|
if (shuttingDown) {
|
|
9373
10443
|
process.stdout.write(`
|
|
9374
|
-
|
|
10444
|
+
companion: second ${signal}, force-quitting.
|
|
9375
10445
|
`);
|
|
9376
10446
|
process.exit(1);
|
|
9377
10447
|
}
|
|
9378
10448
|
shuttingDown = true;
|
|
9379
10449
|
process.stdout.write(`
|
|
9380
|
-
|
|
10450
|
+
companion: received ${signal}, shutting down\u2026
|
|
9381
10451
|
`);
|
|
9382
10452
|
const forceExit = setTimeout(() => {
|
|
9383
|
-
process.stdout.write(`
|
|
10453
|
+
process.stdout.write(`companion: shutdown timed out, force-quitting.
|
|
9384
10454
|
`);
|
|
9385
10455
|
process.exit(1);
|
|
9386
10456
|
}, FORCE_EXIT_MS);
|
|
@@ -9392,6 +10462,31 @@ bridge: received ${signal}, shutting down\u2026
|
|
|
9392
10462
|
};
|
|
9393
10463
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
9394
10464
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
10465
|
+
process.on("SIGUSR2", () => {
|
|
10466
|
+
if (shuttingDown) return;
|
|
10467
|
+
shuttingDown = true;
|
|
10468
|
+
const configuredGrace = Number.parseInt(
|
|
10469
|
+
process.env.CABANE_COMPANION_DEPLOY_GRACE_MS ?? "60000",
|
|
10470
|
+
10
|
|
10471
|
+
);
|
|
10472
|
+
const graceMs = Number.isFinite(configuredGrace) ? configuredGrace : 6e4;
|
|
10473
|
+
process.stdout.write(`
|
|
10474
|
+
companion: deploy drain requested (${graceMs}ms grace)\u2026
|
|
10475
|
+
`);
|
|
10476
|
+
void runtime.drainForRestart(graceMs).then(({ drained }) => {
|
|
10477
|
+
process.stdout.write(
|
|
10478
|
+
drained ? "companion: deploy drain complete.\n" : "companion: deploy grace expired; unfinished turns will resume after restart.\n"
|
|
10479
|
+
);
|
|
10480
|
+
resolve();
|
|
10481
|
+
process.exit(0);
|
|
10482
|
+
}).catch((err2) => {
|
|
10483
|
+
process.stderr.write(
|
|
10484
|
+
`companion: deploy drain failed: ${err2 instanceof Error ? err2.message : String(err2)}
|
|
10485
|
+
`
|
|
10486
|
+
);
|
|
10487
|
+
process.exit(1);
|
|
10488
|
+
});
|
|
10489
|
+
});
|
|
9395
10490
|
});
|
|
9396
10491
|
}
|
|
9397
10492
|
|
|
@@ -9401,7 +10496,7 @@ async function status() {
|
|
|
9401
10496
|
const cfg = loadConfig();
|
|
9402
10497
|
if (!cfg || !cfg.deviceToken) {
|
|
9403
10498
|
process.stdout.write(
|
|
9404
|
-
"
|
|
10499
|
+
"companion: not paired. Register a device in the cabane app (Settings \u2192 Agents \u2192 Companions), then run `cabane-companion pair` and paste the string when prompted.\n"
|
|
9405
10500
|
);
|
|
9406
10501
|
process.exitCode = 1;
|
|
9407
10502
|
return;
|
|
@@ -9410,7 +10505,7 @@ async function status() {
|
|
|
9410
10505
|
`);
|
|
9411
10506
|
if (cfg.deviceLabel) process.stdout.write(`device: ${cfg.deviceLabel}
|
|
9412
10507
|
`);
|
|
9413
|
-
process.stdout.write(`log file: ${
|
|
10508
|
+
process.stdout.write(`log file: ${companionLogPath()}
|
|
9414
10509
|
`);
|
|
9415
10510
|
process.stdout.write(`transcripts: ${transcriptsLine()}
|
|
9416
10511
|
`);
|
|
@@ -9423,7 +10518,7 @@ async function status() {
|
|
|
9423
10518
|
const mode = running.daemon ? "background" : "foreground";
|
|
9424
10519
|
const uptime = formatUptime(running.startedAt);
|
|
9425
10520
|
process.stdout.write(
|
|
9426
|
-
`
|
|
10521
|
+
`companion: running in ${mode} (pid ${running.pid}${uptime ? `, up ${uptime}` : ""})
|
|
9427
10522
|
`
|
|
9428
10523
|
);
|
|
9429
10524
|
process.stdout.write(`dashboard: ${running.url} (assigned agents + run state live here)
|
|
@@ -9432,7 +10527,7 @@ async function status() {
|
|
|
9432
10527
|
`);
|
|
9433
10528
|
} else {
|
|
9434
10529
|
process.stdout.write(
|
|
9435
|
-
`
|
|
10530
|
+
`companion: not running \u2014 \`cabane-companion start\` (foreground) or \`cabane-companion start --daemon\` (background)
|
|
9436
10531
|
`
|
|
9437
10532
|
);
|
|
9438
10533
|
}
|
|
@@ -9486,40 +10581,42 @@ async function stop(deps = {}) {
|
|
|
9486
10581
|
const now = deps.now ?? (() => Date.now());
|
|
9487
10582
|
const state = readState();
|
|
9488
10583
|
if (!state) {
|
|
9489
|
-
process.stdout.write("
|
|
10584
|
+
process.stdout.write("companion: not running (nothing to stop).\n");
|
|
9490
10585
|
return;
|
|
9491
10586
|
}
|
|
9492
10587
|
if (await verify(state) === "stale") {
|
|
9493
10588
|
clearRuntimeState();
|
|
9494
|
-
process.stdout.write("
|
|
10589
|
+
process.stdout.write("companion: not running (stale marker swept).\n");
|
|
9495
10590
|
return;
|
|
9496
10591
|
}
|
|
9497
10592
|
const { pid } = state;
|
|
9498
|
-
process.stdout.write(`
|
|
10593
|
+
process.stdout.write(`companion: stopping (pid ${pid})\u2026
|
|
9499
10594
|
`);
|
|
9500
10595
|
try {
|
|
9501
10596
|
kill(pid, "SIGTERM");
|
|
9502
10597
|
} catch {
|
|
9503
10598
|
clearRuntimeState();
|
|
9504
|
-
process.stdout.write("
|
|
10599
|
+
process.stdout.write("companion: already stopped.\n");
|
|
9505
10600
|
return;
|
|
9506
10601
|
}
|
|
9507
10602
|
const deadline = now() + TERM_GRACE_MS;
|
|
9508
10603
|
while (now() < deadline) {
|
|
9509
10604
|
if (!isAlive(kill, pid)) {
|
|
9510
|
-
process.stdout.write("
|
|
10605
|
+
process.stdout.write("companion: stopped.\n");
|
|
9511
10606
|
return;
|
|
9512
10607
|
}
|
|
9513
10608
|
await sleep4(POLL_INTERVAL_MS2);
|
|
9514
10609
|
}
|
|
9515
|
-
process.stdout.write(
|
|
9516
|
-
`
|
|
10610
|
+
process.stdout.write(
|
|
10611
|
+
`companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
|
|
10612
|
+
`
|
|
10613
|
+
);
|
|
9517
10614
|
try {
|
|
9518
10615
|
kill(pid, "SIGKILL");
|
|
9519
10616
|
} catch {
|
|
9520
10617
|
}
|
|
9521
10618
|
clearRuntimeState();
|
|
9522
|
-
process.stdout.write("
|
|
10619
|
+
process.stdout.write("companion: force-stopped.\n");
|
|
9523
10620
|
}
|
|
9524
10621
|
function isAlive(kill, pid) {
|
|
9525
10622
|
try {
|
|
@@ -9532,7 +10629,7 @@ function isAlive(kill, pid) {
|
|
|
9532
10629
|
|
|
9533
10630
|
// src/commands/transcript.ts
|
|
9534
10631
|
import { existsSync as existsSync12, readFileSync as readFileSync11, readdirSync as readdirSync4 } from "fs";
|
|
9535
|
-
import { isAbsolute, join as
|
|
10632
|
+
import { isAbsolute, join as join15 } from "path";
|
|
9536
10633
|
async function transcript(opts = {}) {
|
|
9537
10634
|
const dir2 = transcriptsDir();
|
|
9538
10635
|
if (opts.follow) {
|
|
@@ -9549,7 +10646,7 @@ async function transcript(opts = {}) {
|
|
|
9549
10646
|
process.stdout.write(emptyMessage(dir2));
|
|
9550
10647
|
return;
|
|
9551
10648
|
}
|
|
9552
|
-
process.stdout.write(renderFile(
|
|
10649
|
+
process.stdout.write(renderFile(join15(dir2, newest)) + "\n");
|
|
9553
10650
|
return;
|
|
9554
10651
|
}
|
|
9555
10652
|
printList(dir2);
|
|
@@ -9625,14 +10722,14 @@ var TranscriptFollower = class {
|
|
|
9625
10722
|
function isComplete(content) {
|
|
9626
10723
|
for (const line of content.split("\n")) {
|
|
9627
10724
|
if (!line.trim()) continue;
|
|
9628
|
-
if (
|
|
10725
|
+
if (str4(rec(safeParse(line))?.type) === "_outcome") return true;
|
|
9629
10726
|
}
|
|
9630
10727
|
return false;
|
|
9631
10728
|
}
|
|
9632
10729
|
async function followTranscripts(dir2) {
|
|
9633
10730
|
const follower = new TranscriptFollower({
|
|
9634
10731
|
listFiles: () => listFiles(dir2),
|
|
9635
|
-
read: (f) => readFileSync11(
|
|
10732
|
+
read: (f) => readFileSync11(join15(dir2, f), "utf8"),
|
|
9636
10733
|
write: (s) => process.stdout.write(s),
|
|
9637
10734
|
// CSI: cursor up `n` lines, then erase from cursor to end of screen.
|
|
9638
10735
|
clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
|
|
@@ -9672,15 +10769,15 @@ function printList(dir2) {
|
|
|
9672
10769
|
|
|
9673
10770
|
`);
|
|
9674
10771
|
for (const f of files.slice(0, 20)) {
|
|
9675
|
-
const { meta, outcome } = peek(
|
|
10772
|
+
const { meta, outcome } = peek(join15(dir2, f));
|
|
9676
10773
|
const when = fmtTime(rec(meta)?.ts);
|
|
9677
|
-
const ws =
|
|
10774
|
+
const ws = str4(rec(meta)?.workspaceSlug);
|
|
9678
10775
|
const o = rec(outcome);
|
|
9679
|
-
const verdict = o ? o.ok === true ? "ok" : `ERROR${
|
|
10776
|
+
const verdict = o ? o.ok === true ? "ok" : `ERROR${str4(o.reason) ? ` (${str4(o.reason)})` : ""}` : "\u2026";
|
|
9680
10777
|
process.stdout.write(
|
|
9681
10778
|
` ${f}
|
|
9682
10779
|
${when} \xB7 ${ws} \xB7 ${verdict}
|
|
9683
|
-
\u201C${excerpt(
|
|
10780
|
+
\u201C${excerpt(str4(rec(meta)?.message), 70)}\u201D
|
|
9684
10781
|
|
|
9685
10782
|
`
|
|
9686
10783
|
);
|
|
@@ -9696,7 +10793,7 @@ function peek(path3) {
|
|
|
9696
10793
|
for (const line of readFileSync11(path3, "utf8").split("\n")) {
|
|
9697
10794
|
if (!line.trim()) continue;
|
|
9698
10795
|
const o = safeParse(line);
|
|
9699
|
-
const t =
|
|
10796
|
+
const t = str4(rec(o)?.type);
|
|
9700
10797
|
if (t === "_meta") meta = o;
|
|
9701
10798
|
else if (t === "_outcome") outcome = o;
|
|
9702
10799
|
}
|
|
@@ -9707,18 +10804,18 @@ function peek(path3) {
|
|
|
9707
10804
|
function resolveTarget(dir2, target) {
|
|
9708
10805
|
if (isAbsolute(target) || target.includes("/")) {
|
|
9709
10806
|
if (existsSync12(target)) return target;
|
|
9710
|
-
throw new
|
|
10807
|
+
throw new CompanionError(`no transcript at ${target}.`);
|
|
9711
10808
|
}
|
|
9712
|
-
const exact =
|
|
10809
|
+
const exact = join15(dir2, target);
|
|
9713
10810
|
if (existsSync12(exact)) return exact;
|
|
9714
10811
|
const matches = listFiles(dir2).filter((f) => f.includes(target));
|
|
9715
|
-
if (matches.length === 1) return
|
|
10812
|
+
if (matches.length === 1) return join15(dir2, matches[0]);
|
|
9716
10813
|
if (matches.length === 0) {
|
|
9717
|
-
throw new
|
|
10814
|
+
throw new CompanionError(
|
|
9718
10815
|
`no transcript matching "${target}" in ${dir2}. Run \`cabane-companion transcript\` to list them.`
|
|
9719
10816
|
);
|
|
9720
10817
|
}
|
|
9721
|
-
throw new
|
|
10818
|
+
throw new CompanionError(
|
|
9722
10819
|
`"${target}" matches ${matches.length} transcripts \u2014 be more specific:
|
|
9723
10820
|
` + matches.slice(0, 10).map((m) => ` ${m}`).join("\n")
|
|
9724
10821
|
);
|
|
@@ -9727,9 +10824,9 @@ function renderFile(path3) {
|
|
|
9727
10824
|
let content;
|
|
9728
10825
|
try {
|
|
9729
10826
|
content = readFileSync11(path3, "utf8");
|
|
9730
|
-
} catch (
|
|
9731
|
-
throw new
|
|
9732
|
-
`couldn't read ${path3}: ${
|
|
10827
|
+
} catch (err2) {
|
|
10828
|
+
throw new CompanionError(
|
|
10829
|
+
`couldn't read ${path3}: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
9733
10830
|
);
|
|
9734
10831
|
}
|
|
9735
10832
|
return renderTranscript(content.split("\n"));
|
|
@@ -9741,19 +10838,19 @@ function renderTranscript(jsonlLines) {
|
|
|
9741
10838
|
if (!raw.trim()) continue;
|
|
9742
10839
|
const obj = rec(safeParse(raw));
|
|
9743
10840
|
if (!obj) continue;
|
|
9744
|
-
switch (
|
|
10841
|
+
switch (str4(obj.type)) {
|
|
9745
10842
|
case "_meta":
|
|
9746
10843
|
out.push(
|
|
9747
|
-
`${fmtTime(obj.ts)} \xB7 workspace=${
|
|
10844
|
+
`${fmtTime(obj.ts)} \xB7 workspace=${str4(obj.workspaceSlug)} \xB7 conversation=${str4(obj.conversationId)}`
|
|
9748
10845
|
);
|
|
9749
|
-
out.push("", `> USER: ${
|
|
10846
|
+
out.push("", `> USER: ${str4(obj.message)}`, "");
|
|
9750
10847
|
break;
|
|
9751
10848
|
case "system":
|
|
9752
|
-
if (
|
|
9753
|
-
out.push(`[session ${
|
|
10849
|
+
if (str4(obj.subtype) === "init") {
|
|
10850
|
+
out.push(`[session ${str4(obj.session_id) || "?"} \xB7 model ${str4(obj.model) || "?"}]`);
|
|
9754
10851
|
const servers = Array.isArray(obj.mcp_servers) ? obj.mcp_servers.map((s) => {
|
|
9755
10852
|
const r = rec(s);
|
|
9756
|
-
return r ? `${
|
|
10853
|
+
return r ? `${str4(r.name) || "?"}${str4(r.status) ? `(${str4(r.status)})` : ""}` : "";
|
|
9757
10854
|
}).filter(Boolean).join(", ") : "";
|
|
9758
10855
|
if (servers) out.push(` MCP servers: ${servers}`);
|
|
9759
10856
|
if (Array.isArray(obj.tools)) out.push(` tools: ${obj.tools.length} available`);
|
|
@@ -9779,8 +10876,8 @@ function renderTranscript(jsonlLines) {
|
|
|
9779
10876
|
if (!Array.isArray(content)) break;
|
|
9780
10877
|
for (const b of content) {
|
|
9781
10878
|
const block = rec(b);
|
|
9782
|
-
if (!block ||
|
|
9783
|
-
const id =
|
|
10879
|
+
if (!block || str4(block.type) !== "tool_result") continue;
|
|
10880
|
+
const id = str4(block.tool_use_id);
|
|
9784
10881
|
const label = pending.get(id) ?? "tool";
|
|
9785
10882
|
pending.delete(id);
|
|
9786
10883
|
const tag = block.is_error === true ? "ERROR" : "ok";
|
|
@@ -9789,18 +10886,18 @@ function renderTranscript(jsonlLines) {
|
|
|
9789
10886
|
break;
|
|
9790
10887
|
}
|
|
9791
10888
|
case "result": {
|
|
9792
|
-
const isErr = obj.is_error === true ||
|
|
10889
|
+
const isErr = obj.is_error === true || str4(obj.subtype) !== "success";
|
|
9793
10890
|
const dur = typeof obj.duration_ms === "number" ? ` \xB7 ${obj.duration_ms}ms` : "";
|
|
9794
10891
|
out.push(
|
|
9795
|
-
`[result ${isErr ? "error" : "ok"}${
|
|
10892
|
+
`[result ${isErr ? "error" : "ok"}${str4(obj.subtype) ? ` \xB7 ${str4(obj.subtype)}` : ""}${dur}]`
|
|
9796
10893
|
);
|
|
9797
|
-
if (isErr &&
|
|
10894
|
+
if (isErr && str4(obj.result).trim()) out.push(` ${indent(str4(obj.result))}`);
|
|
9798
10895
|
break;
|
|
9799
10896
|
}
|
|
9800
10897
|
case "_outcome": {
|
|
9801
10898
|
const dur = typeof obj.durationMs === "number" ? ` \xB7 ${obj.durationMs}ms` : "";
|
|
9802
10899
|
out.push(
|
|
9803
|
-
`[outcome ${obj.ok === true ? "ok" : "error"}${
|
|
10900
|
+
`[outcome ${obj.ok === true ? "ok" : "error"}${str4(obj.reason) ? ` \xB7 ${str4(obj.reason)}` : ""}${dur}]`
|
|
9804
10901
|
);
|
|
9805
10902
|
break;
|
|
9806
10903
|
}
|
|
@@ -9818,7 +10915,7 @@ function safeParse(s) {
|
|
|
9818
10915
|
function rec(v) {
|
|
9819
10916
|
return v && typeof v === "object" ? v : null;
|
|
9820
10917
|
}
|
|
9821
|
-
function
|
|
10918
|
+
function str4(v) {
|
|
9822
10919
|
return typeof v === "string" ? v : "";
|
|
9823
10920
|
}
|
|
9824
10921
|
function fmtTime(ts) {
|
|
@@ -9854,7 +10951,7 @@ They are written per dispatch while \`cabane-companion start\` is running.
|
|
|
9854
10951
|
var program = new Command();
|
|
9855
10952
|
program.name("cabane-companion").description(
|
|
9856
10953
|
"Connect a coding agent on your machine to your Cabane workspace as a responder \u2014 reply to Cabane messages while staying a full local AI client (Claude Code or OpenCode)."
|
|
9857
|
-
).version(
|
|
10954
|
+
).version(COMPANION_VERSION);
|
|
9858
10955
|
program.command("pair").description(
|
|
9859
10956
|
"pair this device with cabane \u2014 shows a short code you confirm in Settings \u2192 Devices."
|
|
9860
10957
|
).argument(
|
|
@@ -9887,10 +10984,10 @@ program.command("start").description("pull this device\u2019s assigned agents fr
|
|
|
9887
10984
|
...opts.port !== void 0 ? { port: opts.port } : {}
|
|
9888
10985
|
});
|
|
9889
10986
|
});
|
|
9890
|
-
program.command("stop").description("stop a running
|
|
10987
|
+
program.command("stop").description("stop a running companion (SIGTERM, then force-kill after a timeout).").action(async () => {
|
|
9891
10988
|
await stop();
|
|
9892
10989
|
});
|
|
9893
|
-
program.command("status").description("print the
|
|
10990
|
+
program.command("status").description("print the companion's local state (pairing, secrets, log path).").action(async () => {
|
|
9894
10991
|
await status();
|
|
9895
10992
|
});
|
|
9896
10993
|
program.command("transcript").description("show the full agent transcript for a recent dispatch (the agent's whole turn).").argument("[file]", "a transcript filename or substring; omit to list recent transcripts").option("--last", "render the most recent transcript").option("-f, --follow", "watch for new turns and live-render them as they land (Ctrl-C to stop)").action(async (file, opts) => {
|
|
@@ -9914,23 +11011,23 @@ program.command("logout").description(
|
|
|
9914
11011
|
function parsePort(raw) {
|
|
9915
11012
|
const n = Number(raw);
|
|
9916
11013
|
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
9917
|
-
throw new
|
|
11014
|
+
throw new CompanionError(`invalid --port "${raw}": expected an integer between 1 and 65535.`);
|
|
9918
11015
|
}
|
|
9919
11016
|
return n;
|
|
9920
11017
|
}
|
|
9921
|
-
program.parseAsync(process.argv).catch((
|
|
9922
|
-
if (
|
|
9923
|
-
process.stderr.write(`error: ${
|
|
11018
|
+
program.parseAsync(process.argv).catch((err2) => {
|
|
11019
|
+
if (err2 instanceof CompanionError) {
|
|
11020
|
+
process.stderr.write(`error: ${err2.message}
|
|
9924
11021
|
`);
|
|
9925
11022
|
process.exitCode = 1;
|
|
9926
11023
|
return;
|
|
9927
11024
|
}
|
|
9928
|
-
if (
|
|
11025
|
+
if (err2 && typeof err2 === "object" && "name" in err2 && err2.name === "ExitPromptError") {
|
|
9929
11026
|
process.stderr.write("cancelled\n");
|
|
9930
11027
|
process.exitCode = 130;
|
|
9931
11028
|
return;
|
|
9932
11029
|
}
|
|
9933
|
-
process.stderr.write(`${
|
|
11030
|
+
process.stderr.write(`${err2 instanceof Error ? err2.stack ?? err2.message : String(err2)}
|
|
9934
11031
|
`);
|
|
9935
11032
|
process.exitCode = 1;
|
|
9936
11033
|
});
|