@cabane/companion 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -9
- package/dist/cli.js +1150 -1226
- package/dist/pairing-config.js +58 -77
- package/dist/runtime.js +1008 -1043
- package/dist/static/app.js +22 -19
- package/dist/static/index.html +6 -7
- package/dist/static/styles.css +1 -1
- package/package.json +3 -4
package/dist/cli.js
CHANGED
|
@@ -20,16 +20,16 @@ import {
|
|
|
20
20
|
} from "fs";
|
|
21
21
|
import { homedir, userInfo } from "os";
|
|
22
22
|
import { dirname, join } from "path";
|
|
23
|
-
import { z as
|
|
23
|
+
import { z as z2 } 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";
|
|
@@ -53,9 +53,6 @@ var PrepareHookError = class extends BridgeError {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
// src/pairing.ts
|
|
56
|
-
import { z } from "zod";
|
|
57
|
-
var PAIRING_VERSION = 1;
|
|
58
|
-
var DEVICE_TOKEN_PREFIX = "cabdev_";
|
|
59
56
|
function isAllowedBaseUrl(raw) {
|
|
60
57
|
let url;
|
|
61
58
|
try {
|
|
@@ -70,73 +67,24 @@ function isAllowedBaseUrl(raw) {
|
|
|
70
67
|
}
|
|
71
68
|
return false;
|
|
72
69
|
}
|
|
73
|
-
var pairingSchema = z.object({
|
|
74
|
-
// Bumped if the wire shape changes incompatibly. We only accept v1.
|
|
75
|
-
v: z.literal(PAIRING_VERSION),
|
|
76
|
-
baseUrl: z.string().url().refine(isAllowedBaseUrl, {
|
|
77
|
-
message: "baseUrl must be https (loopback http is allowed for local dev only)"
|
|
78
|
-
}),
|
|
79
|
-
// The `cabdev_` device token plaintext — the bridge's one durable credential.
|
|
80
|
-
deviceToken: z.string().min(1).startsWith(DEVICE_TOKEN_PREFIX, {
|
|
81
|
-
message: 'deviceToken must be a cabane device token (starts with "cabdev_")'
|
|
82
|
-
}),
|
|
83
|
-
// Optional identity hints the app may include for nicer local display. The
|
|
84
|
-
// bridge also learns these from the first assignments pull, so they're not
|
|
85
|
-
// required.
|
|
86
|
-
deviceId: z.string().min(1).optional(),
|
|
87
|
-
deviceLabel: z.string().min(1).optional()
|
|
88
|
-
});
|
|
89
|
-
function decodePairing(raw) {
|
|
90
|
-
const cleaned = raw.replace(/\s+/g, "");
|
|
91
|
-
if (cleaned.length === 0) {
|
|
92
|
-
throw new BridgeError("empty pairing string. Copy it from the cabane app and try again.");
|
|
93
|
-
}
|
|
94
|
-
const bytes = Buffer.from(cleaned, "base64url");
|
|
95
|
-
const json = bytes.toString("utf8");
|
|
96
|
-
let parsed;
|
|
97
|
-
try {
|
|
98
|
-
parsed = JSON.parse(json);
|
|
99
|
-
} catch {
|
|
100
|
-
throw new BridgeError(
|
|
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
|
-
);
|
|
103
|
-
}
|
|
104
|
-
if (parsed && typeof parsed === "object" && "v" in parsed && parsed.v !== PAIRING_VERSION) {
|
|
105
|
-
throw new BridgeError(
|
|
106
|
-
`this pairing string is version ${String(parsed.v)}, but this bridge only understands version ${PAIRING_VERSION}. Update cabane-companion (\`git pull\` + rebuild) and try again.`
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
const result = pairingSchema.safeParse(parsed);
|
|
110
|
-
if (!result.success) {
|
|
111
|
-
if (result.error.issues.some((i) => i.path[0] === "baseUrl")) {
|
|
112
|
-
throw new BridgeError(
|
|
113
|
-
"this pairing string points at a non-https cabane URL. The bridge 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
|
-
);
|
|
115
|
-
}
|
|
116
|
-
throw new BridgeError(
|
|
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
|
-
);
|
|
119
|
-
}
|
|
120
|
-
return result.data;
|
|
121
|
-
}
|
|
122
70
|
|
|
123
71
|
// src/prepare-hook.ts
|
|
124
72
|
import { spawn } from "child_process";
|
|
125
|
-
import { z
|
|
126
|
-
var prepareHookSchema =
|
|
127
|
-
command:
|
|
128
|
-
args:
|
|
73
|
+
import { z } from "zod";
|
|
74
|
+
var prepareHookSchema = z.object({
|
|
75
|
+
command: z.string().min(1),
|
|
76
|
+
args: z.array(z.string()).optional(),
|
|
129
77
|
// Extra env handed to the hook process itself (merged over process.env).
|
|
130
|
-
env:
|
|
78
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
131
79
|
// Wall-clock cap for the hook. Provisioning is slow (minutes), so the
|
|
132
80
|
// 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
|
|
134
|
-
timeoutMs:
|
|
81
|
+
// fails with a clear timeout message rather than pinning the companion.
|
|
82
|
+
timeoutMs: z.number().int().positive().optional()
|
|
135
83
|
}).strict();
|
|
136
84
|
var DEFAULT_TIMEOUT_MS = 10 * 6e4;
|
|
137
|
-
var prepareResultSchema =
|
|
138
|
-
cwd:
|
|
139
|
-
env:
|
|
85
|
+
var prepareResultSchema = z.object({
|
|
86
|
+
cwd: z.string().min(1),
|
|
87
|
+
env: z.record(z.string(), z.string()).optional()
|
|
140
88
|
});
|
|
141
89
|
function parsePrepareOutput(stdout) {
|
|
142
90
|
const last = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).at(-1);
|
|
@@ -155,10 +103,13 @@ function parsePrepareOutput(stdout) {
|
|
|
155
103
|
const r = prepareResultSchema.safeParse(parsed);
|
|
156
104
|
if (!r.success) {
|
|
157
105
|
throw new PrepareHookError(
|
|
158
|
-
'prepare hook JSON must carry a non-empty string "cwd" (
|
|
106
|
+
'prepare hook JSON must carry a non-empty string "cwd" (plus optional "env")'
|
|
159
107
|
);
|
|
160
108
|
}
|
|
161
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
cwd: r.data.cwd,
|
|
111
|
+
...r.data.env ? { env: r.data.env } : {}
|
|
112
|
+
};
|
|
162
113
|
}
|
|
163
114
|
return { cwd: last };
|
|
164
115
|
}
|
|
@@ -183,6 +134,7 @@ var runPrepareHook = (hook, input) => {
|
|
|
183
134
|
CABANE_CONVERSATION_ID: input.conversationId,
|
|
184
135
|
CABANE_AGENT_ID: input.agentId,
|
|
185
136
|
CABANE_AGENT_USERNAME: input.agentUsername,
|
|
137
|
+
CABANE_RUNTIME: input.runtime ?? "",
|
|
186
138
|
// CT319: the conversation anchor is gone. These are kept as DEPRECATED
|
|
187
139
|
// back-compat constants so an old user prepare script that still reads
|
|
188
140
|
// them doesn't crash; a hook should key off CABANE_TRIGGER_ENTRY_PATHS.
|
|
@@ -257,12 +209,12 @@ function realAccountHome() {
|
|
|
257
209
|
return realHomeCache;
|
|
258
210
|
}
|
|
259
211
|
function cabaneDir() {
|
|
260
|
-
const dir2 = join(process.env.
|
|
212
|
+
const dir2 = join(process.env.CABANE_COMPANION_HOME || homedir(), ".cabane");
|
|
261
213
|
if (process.env.VITEST) {
|
|
262
214
|
const real = realAccountHome();
|
|
263
215
|
if (real && dir2 === join(real, ".cabane")) {
|
|
264
216
|
throw new Error(
|
|
265
|
-
`cabaneDir() resolved to the real ${dir2} during a test run.
|
|
217
|
+
`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
218
|
);
|
|
267
219
|
}
|
|
268
220
|
}
|
|
@@ -271,55 +223,55 @@ function cabaneDir() {
|
|
|
271
223
|
function configPath() {
|
|
272
224
|
return join(cabaneDir(), "config.json");
|
|
273
225
|
}
|
|
274
|
-
var localAgentConfigSchema =
|
|
275
|
-
cwd:
|
|
226
|
+
var localAgentConfigSchema = z2.object({
|
|
227
|
+
cwd: z2.string().optional(),
|
|
276
228
|
prepareHook: prepareHookSchema.optional(),
|
|
277
229
|
// CT289: the Claude Code auto-memory escape hatch. Auto-memory is forced OFF
|
|
278
|
-
// by default on every
|
|
279
|
-
//
|
|
280
|
-
// On a
|
|
230
|
+
// by default on every companion (memory belongs in the Cabane workspace, and a
|
|
231
|
+
// shared companion would otherwise pool one cwd-keyed memory dir across users).
|
|
232
|
+
// On a companion you run yourself, set `claudeCode: { autoMemory: true }` to hand
|
|
281
233
|
// auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
|
|
282
234
|
// injecting the off switch and your normal Claude Code memory workflow applies
|
|
283
235
|
// (in coding mode, where the checkout's project settings are read).
|
|
284
|
-
claudeCode:
|
|
236
|
+
claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
|
|
285
237
|
}).strict();
|
|
286
|
-
var
|
|
238
|
+
var companionConfigSchema = z2.object({
|
|
287
239
|
// The cabane instance this device is paired with. SJ515: https-enforced
|
|
288
240
|
// (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
|
|
289
241
|
// base URL onto the MITM-able channel the device token + prompt ride.
|
|
290
|
-
baseUrl:
|
|
242
|
+
baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
|
|
291
243
|
message: "baseUrl must be https (loopback http is allowed for local dev only)"
|
|
292
244
|
}),
|
|
293
|
-
// The `cabdev_` device token plaintext — the
|
|
245
|
+
// The `cabdev_` device token plaintext — the companion's one durable credential,
|
|
294
246
|
// presented as `Authorization: Bearer <token>` on the device-facing pull +
|
|
295
247
|
// heartbeat endpoints. Optional so `cabane-companion logout` can strip it (a
|
|
296
248
|
// "paired but logged out" state the supervisor refuses to run) while keeping
|
|
297
249
|
// the rest of the config; `pair` always writes one.
|
|
298
|
-
deviceToken:
|
|
299
|
-
// Identity hints, learned from the
|
|
250
|
+
deviceToken: z2.string().optional(),
|
|
251
|
+
// Identity hints, learned from the device flow and refreshed on the first
|
|
300
252
|
// assignments pull. Cosmetic — used for `status`/dashboard display only.
|
|
301
|
-
deviceId:
|
|
302
|
-
deviceLabel:
|
|
253
|
+
deviceId: z2.string().optional(),
|
|
254
|
+
deviceLabel: z2.string().optional(),
|
|
303
255
|
// Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
|
|
304
|
-
// agentId / username / `slug/username`. Hand-added by the operator; the
|
|
256
|
+
// agentId / username / `slug/username`. Hand-added by the operator; the companion
|
|
305
257
|
// never writes this (it only persists credentials + the device, elsewhere).
|
|
306
|
-
agents:
|
|
258
|
+
agents: z2.record(z2.string(), localAgentConfigSchema).optional(),
|
|
307
259
|
// Dashboard settings (all optional). dashboardPort: preferred bind port (next
|
|
308
260
|
// free one if taken); autoOpen: whether `start` opens the browser (the
|
|
309
|
-
// `--no-open` flag / `
|
|
261
|
+
// `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
|
|
310
262
|
// level, live-editable from the dashboard settings panel.
|
|
311
|
-
dashboardPort:
|
|
312
|
-
autoOpen:
|
|
313
|
-
logLevel:
|
|
263
|
+
dashboardPort: z2.number().int().min(1).max(65535).optional(),
|
|
264
|
+
autoOpen: z2.boolean().optional(),
|
|
265
|
+
logLevel: z2.enum(["warn", "info", "debug"]).optional(),
|
|
314
266
|
// CT270: the opencode runtime, when the operator runs one on this machine. The
|
|
315
267
|
// operator installs opencode, starts `opencode serve` (auth via opencode's own
|
|
316
|
-
// `/connect` — Cabane never sees provider keys), and points the
|
|
268
|
+
// `/connect` — Cabane never sees provider keys), and points the companion at it
|
|
317
269
|
// here. Setting this makes the device advertise the `opencode` runtime on its
|
|
318
270
|
// heartbeat manifest (so the server offers DeepSeek/opencode models here and
|
|
319
271
|
// routes those turns to this device) AND registers the opencode adapter in the
|
|
320
272
|
// dispatcher. Absent → the device is claude-code-only, exactly as before.
|
|
321
|
-
opencode:
|
|
322
|
-
serverUrl:
|
|
273
|
+
opencode: z2.object({
|
|
274
|
+
serverUrl: z2.string().url()
|
|
323
275
|
}).strict().optional(),
|
|
324
276
|
// CT481: the codex runtime, when the operator runs Codex on this machine. Unlike
|
|
325
277
|
// opencode (a long-lived `opencode serve` addressed by URL), Codex is a local CLI
|
|
@@ -330,20 +282,13 @@ var bridgeConfigSchema = z3.object({
|
|
|
330
282
|
// keeps the block but turns it off). Enabling makes the device advertise the
|
|
331
283
|
// `codex` runtime on its heartbeat manifest AND registers the codex adapter in
|
|
332
284
|
// the dispatcher. Absent → the device doesn't offer codex, exactly as before.
|
|
333
|
-
codex:
|
|
334
|
-
enabled:
|
|
285
|
+
codex: z2.object({
|
|
286
|
+
enabled: z2.boolean().optional()
|
|
335
287
|
}).strict().optional()
|
|
336
288
|
});
|
|
337
289
|
function isCodexEnabled(cfg) {
|
|
338
290
|
return !!cfg.codex && cfg.codex.enabled !== false;
|
|
339
291
|
}
|
|
340
|
-
function cabaneNativeApiKey() {
|
|
341
|
-
const key = process.env.OPENROUTER_API_KEY?.trim();
|
|
342
|
-
return key ? key : void 0;
|
|
343
|
-
}
|
|
344
|
-
function isCabaneNativeEnabled() {
|
|
345
|
-
return cabaneNativeApiKey() !== void 0;
|
|
346
|
-
}
|
|
347
292
|
function localAgentConfig(cfg, agent) {
|
|
348
293
|
const map = cfg.agents ?? {};
|
|
349
294
|
return map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
|
|
@@ -368,7 +313,7 @@ function loadConfig() {
|
|
|
368
313
|
`${path3} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
369
314
|
);
|
|
370
315
|
}
|
|
371
|
-
const result =
|
|
316
|
+
const result = companionConfigSchema.safeParse(parsed);
|
|
372
317
|
if (!result.success) {
|
|
373
318
|
const agentIssue = result.error.issues.find((i) => i.path[0] === "agents");
|
|
374
319
|
if (agentIssue) {
|
|
@@ -377,7 +322,7 @@ function loadConfig() {
|
|
|
377
322
|
);
|
|
378
323
|
}
|
|
379
324
|
throw new ConfigError(
|
|
380
|
-
`${path3} is from an incompatible or older version of the
|
|
325
|
+
`${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
326
|
);
|
|
382
327
|
}
|
|
383
328
|
return result.data;
|
|
@@ -399,7 +344,7 @@ function loadConfigTolerant() {
|
|
|
399
344
|
} catch {
|
|
400
345
|
return { local: empty, note: `${path3} was unreadable (invalid JSON) and has been reset.` };
|
|
401
346
|
}
|
|
402
|
-
const strict =
|
|
347
|
+
const strict = companionConfigSchema.safeParse(parsed);
|
|
403
348
|
if (strict.success) {
|
|
404
349
|
const { agents: agents2, dashboardPort, autoOpen, logLevel } = strict.data;
|
|
405
350
|
return {
|
|
@@ -414,7 +359,7 @@ function loadConfigTolerant() {
|
|
|
414
359
|
}
|
|
415
360
|
const obj = parsed && typeof parsed === "object" ? parsed : {};
|
|
416
361
|
const local = {};
|
|
417
|
-
const agents =
|
|
362
|
+
const agents = z2.record(z2.string(), localAgentConfigSchema).safeParse(obj.agents);
|
|
418
363
|
if (agents.success) local.agents = agents.data;
|
|
419
364
|
if (typeof obj.dashboardPort === "number") local.dashboardPort = obj.dashboardPort;
|
|
420
365
|
if (typeof obj.autoOpen === "boolean") local.autoOpen = obj.autoOpen;
|
|
@@ -423,7 +368,7 @@ function loadConfigTolerant() {
|
|
|
423
368
|
}
|
|
424
369
|
return {
|
|
425
370
|
local,
|
|
426
|
-
note: `the existing ${path3} was from an older or incompatible
|
|
371
|
+
note: `the existing ${path3} was from an older or incompatible companion; re-pairing rewrote it.`
|
|
427
372
|
};
|
|
428
373
|
}
|
|
429
374
|
function saveConfig(cfg) {
|
|
@@ -453,7 +398,7 @@ function requireConfig() {
|
|
|
453
398
|
const cfg = loadConfig();
|
|
454
399
|
if (!cfg) {
|
|
455
400
|
throw new ConfigError(
|
|
456
|
-
"this
|
|
401
|
+
"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
402
|
);
|
|
458
403
|
}
|
|
459
404
|
return cfg;
|
|
@@ -470,8 +415,8 @@ import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
|
|
|
470
415
|
import { dirname as dirname2, join as join2 } from "path";
|
|
471
416
|
import pino from "pino";
|
|
472
417
|
import pretty from "pino-pretty";
|
|
473
|
-
function
|
|
474
|
-
return join2(cabaneDir(), "
|
|
418
|
+
function companionLogPath() {
|
|
419
|
+
return join2(cabaneDir(), "companion.log");
|
|
475
420
|
}
|
|
476
421
|
var CONSOLE_IGNORE = [
|
|
477
422
|
"pid",
|
|
@@ -481,7 +426,7 @@ var CONSOLE_IGNORE = [
|
|
|
481
426
|
"agentId",
|
|
482
427
|
"messageId",
|
|
483
428
|
"sessionId",
|
|
484
|
-
"
|
|
429
|
+
"companionId"
|
|
485
430
|
].join(",");
|
|
486
431
|
function consoleShortId(log) {
|
|
487
432
|
const id = log.conversationId ?? log.workspaceId;
|
|
@@ -495,10 +440,10 @@ function consoleMessageFormat(log, messageKey) {
|
|
|
495
440
|
var cached = null;
|
|
496
441
|
function getLogger() {
|
|
497
442
|
if (cached) return cached;
|
|
498
|
-
const path3 =
|
|
443
|
+
const path3 = companionLogPath();
|
|
499
444
|
mkdirSync2(dirname2(path3), { recursive: true });
|
|
500
445
|
const streams = [];
|
|
501
|
-
if (process.env.
|
|
446
|
+
if (process.env.CABANE_COMPANION_DAEMON !== "1") {
|
|
502
447
|
const consoleStream = pretty({
|
|
503
448
|
colorize: true,
|
|
504
449
|
ignore: CONSOLE_IGNORE,
|
|
@@ -548,8 +493,8 @@ async function ensureRuntimeAvailable(cfg, deps = {}) {
|
|
|
548
493
|
);
|
|
549
494
|
return;
|
|
550
495
|
}
|
|
551
|
-
throw new
|
|
552
|
-
"Claude Code, the
|
|
496
|
+
throw new CompanionError(
|
|
497
|
+
"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
498
|
);
|
|
554
499
|
}
|
|
555
500
|
|
|
@@ -660,7 +605,7 @@ async function startDaemon(opts = {}, deps = {}) {
|
|
|
660
605
|
if (existing) {
|
|
661
606
|
if (await verify(existing) !== "stale") {
|
|
662
607
|
process.stdout.write(
|
|
663
|
-
`Cabane
|
|
608
|
+
`Cabane Companion is already running (pid ${existing.pid}).
|
|
664
609
|
\u2192 Dashboard: ${existing.url}
|
|
665
610
|
Stop it first with \`cabane-companion stop\` if you want to relaunch.
|
|
666
611
|
`
|
|
@@ -682,17 +627,17 @@ Stop it first with \`cabane-companion stop\` if you want to relaunch.
|
|
|
682
627
|
}
|
|
683
628
|
if (!ready(state)) {
|
|
684
629
|
process.stdout.write(
|
|
685
|
-
`Cabane
|
|
686
|
-
Check ${
|
|
630
|
+
`Cabane Companion was launched (pid ${child.pid ?? "?"}) but didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s.
|
|
631
|
+
Check ${companionLogPath()} for errors, or run \`cabane-companion status\`.
|
|
687
632
|
`
|
|
688
633
|
);
|
|
689
634
|
process.exitCode = 1;
|
|
690
635
|
return;
|
|
691
636
|
}
|
|
692
637
|
process.stdout.write(
|
|
693
|
-
`Cabane
|
|
638
|
+
`Cabane Companion started in the background (pid ${state.pid}).
|
|
694
639
|
\u2192 Dashboard: ${state.url}
|
|
695
|
-
Logs: ${
|
|
640
|
+
Logs: ${companionLogPath()}
|
|
696
641
|
Status: cabane-companion status
|
|
697
642
|
Stop: cabane-companion stop
|
|
698
643
|
`
|
|
@@ -701,12 +646,12 @@ Stop: cabane-companion stop
|
|
|
701
646
|
function defaultSpawnDetached(args) {
|
|
702
647
|
const cliPath = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
703
648
|
mkdirSync4(cabaneDir(), { recursive: true });
|
|
704
|
-
const logFd = openSync2(
|
|
649
|
+
const logFd = openSync2(companionLogPath(), "a");
|
|
705
650
|
try {
|
|
706
651
|
return spawn3(process.execPath, [cliPath, ...args], {
|
|
707
652
|
detached: true,
|
|
708
653
|
stdio: ["ignore", logFd, logFd],
|
|
709
|
-
env: { ...process.env,
|
|
654
|
+
env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
|
|
710
655
|
});
|
|
711
656
|
} finally {
|
|
712
657
|
closeSync2(logFd);
|
|
@@ -727,11 +672,11 @@ import {
|
|
|
727
672
|
writeFileSync as writeFileSync3
|
|
728
673
|
} from "fs";
|
|
729
674
|
import { dirname as dirname3, join as join4 } from "path";
|
|
730
|
-
import { z as
|
|
675
|
+
import { z as z3 } from "zod";
|
|
731
676
|
function credentialsPath() {
|
|
732
677
|
return join4(cabaneDir(), "credentials.json");
|
|
733
678
|
}
|
|
734
|
-
var credentialStoreSchema =
|
|
679
|
+
var credentialStoreSchema = z3.record(z3.string(), z3.string());
|
|
735
680
|
function load() {
|
|
736
681
|
const path3 = credentialsPath();
|
|
737
682
|
if (!existsSync3(path3)) return {};
|
|
@@ -807,14 +752,14 @@ async function logout(opts = {}) {
|
|
|
807
752
|
return;
|
|
808
753
|
}
|
|
809
754
|
if (!opts.yes) {
|
|
810
|
-
const message = opts.purge ? "Purge the entire local
|
|
755
|
+
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)?";
|
|
811
756
|
const ok = await confirm({ message, default: false });
|
|
812
757
|
if (!ok) {
|
|
813
758
|
process.stdout.write("cancelled\n");
|
|
814
759
|
return;
|
|
815
760
|
}
|
|
816
761
|
}
|
|
817
|
-
const serverNote = `the device remains registered server-side \u2014 remove it in the cabane app (Settings \u2192
|
|
762
|
+
const serverNote = `the device remains registered server-side \u2014 remove it in the cabane app (Settings \u2192 Connectors) if you want it gone there too.`;
|
|
818
763
|
if (opts.purge) {
|
|
819
764
|
deleteConfig();
|
|
820
765
|
clearCredentials();
|
|
@@ -826,15 +771,11 @@ async function logout(opts = {}) {
|
|
|
826
771
|
saveConfig(rest);
|
|
827
772
|
clearCredentials();
|
|
828
773
|
process.stdout.write(
|
|
829
|
-
`Logged out \u2014 removed the device token and cached agent credentials, kept your
|
|
774
|
+
`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
775
|
`
|
|
831
776
|
);
|
|
832
777
|
}
|
|
833
778
|
|
|
834
|
-
// src/commands/pair.ts
|
|
835
|
-
import { readFileSync as readFileSync4 } from "fs";
|
|
836
|
-
import { password } from "@inquirer/prompts";
|
|
837
|
-
|
|
838
779
|
// src/enrollment.ts
|
|
839
780
|
function trimBase(baseUrl) {
|
|
840
781
|
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
@@ -848,7 +789,7 @@ async function postJson(baseUrl, path3, body) {
|
|
|
848
789
|
body: JSON.stringify(body)
|
|
849
790
|
});
|
|
850
791
|
} catch (err) {
|
|
851
|
-
throw new
|
|
792
|
+
throw new CompanionError(
|
|
852
793
|
`couldn't reach cabane at ${baseUrl}: ${err instanceof Error ? err.message : String(err)}. Check the server URL (pass --server <url>) and your connection.`
|
|
853
794
|
);
|
|
854
795
|
}
|
|
@@ -863,8 +804,8 @@ async function postJson(baseUrl, path3, body) {
|
|
|
863
804
|
}
|
|
864
805
|
if (res.status >= 400) {
|
|
865
806
|
if (res.status === 404 && path3.endsWith("/code")) {
|
|
866
|
-
throw new
|
|
867
|
-
`this cabane server (${baseUrl}) doesn't support device-flow pairing yet. Update the server
|
|
807
|
+
throw new CompanionError(
|
|
808
|
+
`this cabane server (${baseUrl}) doesn't support device-flow pairing yet. Update the server to a version that supports \`cabane-companion pair\`.`
|
|
868
809
|
);
|
|
869
810
|
}
|
|
870
811
|
const msg = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `${res.status}`;
|
|
@@ -873,16 +814,16 @@ async function postJson(baseUrl, path3, body) {
|
|
|
873
814
|
return parsed;
|
|
874
815
|
}
|
|
875
816
|
function requestEnrollmentCode(baseUrl) {
|
|
876
|
-
return postJson(baseUrl, "/api/
|
|
817
|
+
return postJson(baseUrl, "/api/device-enrollment/code", {});
|
|
877
818
|
}
|
|
878
819
|
function pollOnce(baseUrl, deviceCode) {
|
|
879
|
-
return postJson(baseUrl, "/api/
|
|
820
|
+
return postJson(baseUrl, "/api/device-enrollment/poll", { deviceCode });
|
|
880
821
|
}
|
|
881
822
|
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
882
823
|
async function runDeviceFlow(baseUrl, print, opts = {}) {
|
|
883
824
|
const now = opts.now ?? (() => Date.now());
|
|
884
825
|
const wait = opts.sleepMs ?? sleep;
|
|
885
|
-
if (opts.signal?.aborted) throw new
|
|
826
|
+
if (opts.signal?.aborted) throw new CompanionError("pairing was cancelled.");
|
|
886
827
|
const code = await requestEnrollmentCode(baseUrl);
|
|
887
828
|
if (opts.onCode) await opts.onCode(code);
|
|
888
829
|
print("");
|
|
@@ -897,11 +838,11 @@ async function runDeviceFlow(baseUrl, print, opts = {}) {
|
|
|
897
838
|
let intervalMs = Math.max(1, code.interval) * 1e3;
|
|
898
839
|
while (now() < deadline) {
|
|
899
840
|
await wait(intervalMs);
|
|
900
|
-
if (opts.signal?.aborted) throw new
|
|
841
|
+
if (opts.signal?.aborted) throw new CompanionError("pairing was cancelled.");
|
|
901
842
|
const res = await pollOnce(baseUrl, code.deviceCode);
|
|
902
843
|
if (res.status === "complete") {
|
|
903
844
|
if (!res.deviceToken || !res.baseUrl) {
|
|
904
|
-
throw new
|
|
845
|
+
throw new CompanionError("the server reported the pairing complete but returned no token.");
|
|
905
846
|
}
|
|
906
847
|
return {
|
|
907
848
|
baseUrl: res.baseUrl,
|
|
@@ -911,13 +852,13 @@ async function runDeviceFlow(baseUrl, print, opts = {}) {
|
|
|
911
852
|
};
|
|
912
853
|
}
|
|
913
854
|
if (res.status === "expired") {
|
|
914
|
-
throw new
|
|
855
|
+
throw new CompanionError(
|
|
915
856
|
"this pairing code expired before it was confirmed. Run `cabane-companion pair` again for a fresh one."
|
|
916
857
|
);
|
|
917
858
|
}
|
|
918
859
|
if (res.status === "slow_down") intervalMs += 1e3;
|
|
919
860
|
}
|
|
920
|
-
throw new
|
|
861
|
+
throw new CompanionError(
|
|
921
862
|
"this pairing code expired before it was confirmed. Run `cabane-companion pair` again for a fresh one."
|
|
922
863
|
);
|
|
923
864
|
}
|
|
@@ -927,7 +868,7 @@ var DEFAULT_BASE_URL = "https://cabane.ai";
|
|
|
927
868
|
function resolvePairBaseUrl(server) {
|
|
928
869
|
const raw = (server ?? process.env.CABANE_BASE_URL ?? DEFAULT_BASE_URL).trim();
|
|
929
870
|
if (!isAllowedBaseUrl(raw)) {
|
|
930
|
-
throw new
|
|
871
|
+
throw new CompanionError(
|
|
931
872
|
`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
873
|
);
|
|
933
874
|
}
|
|
@@ -947,34 +888,6 @@ function writePairedConfig(paired) {
|
|
|
947
888
|
}
|
|
948
889
|
|
|
949
890
|
// src/commands/pair.ts
|
|
950
|
-
async function readStdin() {
|
|
951
|
-
const chunks = [];
|
|
952
|
-
for await (const chunk of process.stdin) {
|
|
953
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
954
|
-
}
|
|
955
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
956
|
-
}
|
|
957
|
-
async function resolvePairingString(opts) {
|
|
958
|
-
if (opts.file !== void 0) {
|
|
959
|
-
try {
|
|
960
|
-
return readFileSync4(opts.file, "utf8");
|
|
961
|
-
} catch (err) {
|
|
962
|
-
throw new BridgeError(
|
|
963
|
-
`couldn't read the pairing string from ${opts.file}: ${err instanceof Error ? err.message : String(err)}`
|
|
964
|
-
);
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
if (opts.arg !== void 0) {
|
|
968
|
-
process.stderr.write(
|
|
969
|
-
"warning: passing the pairing string as an argument leaves it in your shell history and is readable by other processes while `pair` runs. Prefer `cabane-companion pair --legacy` (you'll be prompted to paste it) or `cabane-companion pair --legacy --file <path>`.\n"
|
|
970
|
-
);
|
|
971
|
-
return opts.arg;
|
|
972
|
-
}
|
|
973
|
-
if (!process.stdin.isTTY) {
|
|
974
|
-
return await readStdin();
|
|
975
|
-
}
|
|
976
|
-
return await password({ message: "Paste the pairing string from cabane:" });
|
|
977
|
-
}
|
|
978
891
|
function writePairedConfigCli(paired) {
|
|
979
892
|
const { note } = writePairedConfig(paired);
|
|
980
893
|
if (note) process.stderr.write(`note: ${note}
|
|
@@ -986,25 +899,28 @@ Run \`cabane-companion start\` \u2014 it will pull the agents assigned to this d
|
|
|
986
899
|
`
|
|
987
900
|
);
|
|
988
901
|
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
...pairing.deviceLabel ? { deviceLabel: pairing.deviceLabel } : {}
|
|
999
|
-
});
|
|
1000
|
-
return;
|
|
902
|
+
function writeCompletedPairing(raw) {
|
|
903
|
+
let paired;
|
|
904
|
+
try {
|
|
905
|
+
paired = JSON.parse(raw);
|
|
906
|
+
} catch {
|
|
907
|
+
throw new Error("invalid completed pairing payload: expected JSON on stdin.");
|
|
908
|
+
}
|
|
909
|
+
if (!paired || typeof paired !== "object" || typeof paired.baseUrl !== "string" || typeof paired.deviceToken !== "string") {
|
|
910
|
+
throw new Error("invalid completed pairing payload: baseUrl and deviceToken are required.");
|
|
1001
911
|
}
|
|
912
|
+
writePairedConfigCli(paired);
|
|
913
|
+
}
|
|
914
|
+
async function pair(opts = {}) {
|
|
1002
915
|
const baseUrl = resolvePairBaseUrl(opts.server);
|
|
1003
916
|
const paired = await runDeviceFlow(baseUrl, (line) => process.stdout.write(`${line}
|
|
1004
917
|
`));
|
|
1005
918
|
writePairedConfigCli(paired);
|
|
1006
919
|
}
|
|
1007
920
|
|
|
921
|
+
// src/cli.ts
|
|
922
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
923
|
+
|
|
1008
924
|
// src/browser.ts
|
|
1009
925
|
import { spawn as spawn4 } from "child_process";
|
|
1010
926
|
import { platform } from "process";
|
|
@@ -1031,7 +947,7 @@ function openerFor(url) {
|
|
|
1031
947
|
function shouldAutoOpen(opts) {
|
|
1032
948
|
if (opts.flagOpen === true) return true;
|
|
1033
949
|
if (opts.flagOpen === false) return false;
|
|
1034
|
-
if (process.env.
|
|
950
|
+
if (process.env.COMPANION_NO_OPEN === "1") return false;
|
|
1035
951
|
if (opts.configAutoOpen === true) return true;
|
|
1036
952
|
return false;
|
|
1037
953
|
}
|
|
@@ -1070,7 +986,7 @@ var MAX_DISPATCHES = 50;
|
|
|
1070
986
|
function today() {
|
|
1071
987
|
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1072
988
|
}
|
|
1073
|
-
var
|
|
989
|
+
var CompanionStateHub = class {
|
|
1074
990
|
constructor(opts) {
|
|
1075
991
|
this.opts = opts;
|
|
1076
992
|
this.emitter.setMaxListeners(0);
|
|
@@ -1084,7 +1000,7 @@ var BridgeStateHub = class {
|
|
|
1084
1000
|
deviceLabel = null;
|
|
1085
1001
|
deviceError = null;
|
|
1086
1002
|
// CT586: the latest harness snapshot the supervisor probed, or null before the
|
|
1087
|
-
// first probe (see
|
|
1003
|
+
// first probe (see CompanionStatusJson.harnesses).
|
|
1088
1004
|
harnesses = null;
|
|
1089
1005
|
// ---- subscription (SSE) ----
|
|
1090
1006
|
on(listener) {
|
|
@@ -1276,7 +1192,7 @@ var BridgeStateHub = class {
|
|
|
1276
1192
|
})),
|
|
1277
1193
|
last_event_at_overall: lastOverall,
|
|
1278
1194
|
dashboard_url: this.dashboardUrl,
|
|
1279
|
-
|
|
1195
|
+
companion_version: this.opts.companionVersion,
|
|
1280
1196
|
instance_id: this.opts.instanceId ?? null,
|
|
1281
1197
|
harnesses: this.harnesses
|
|
1282
1198
|
};
|
|
@@ -1317,7 +1233,7 @@ function registerRoutes(app, deps) {
|
|
|
1317
1233
|
});
|
|
1318
1234
|
app.get("/api/logs", async (c) => {
|
|
1319
1235
|
const lines = clampLimit(c.req.query("lines"), 200, 1e3);
|
|
1320
|
-
return c.json({ lines: tailFile(
|
|
1236
|
+
return c.json({ lines: tailFile(companionLogPath(), lines) });
|
|
1321
1237
|
});
|
|
1322
1238
|
app.post("/api/settings", async (c) => {
|
|
1323
1239
|
const body = await readJson(c);
|
|
@@ -1458,7 +1374,7 @@ function buildDashboardApp(deps) {
|
|
|
1458
1374
|
const status2 = err.status >= 400 && err.status < 600 ? err.status : 502;
|
|
1459
1375
|
return c.json({ error: err.message }, status2);
|
|
1460
1376
|
}
|
|
1461
|
-
if (err instanceof
|
|
1377
|
+
if (err instanceof CompanionError) {
|
|
1462
1378
|
return c.json({ error: err.message }, 400);
|
|
1463
1379
|
}
|
|
1464
1380
|
return c.json({ error: err instanceof Error ? err.message : "internal error" }, 500);
|
|
@@ -1490,7 +1406,7 @@ async function startDashboard(opts) {
|
|
|
1490
1406
|
throw err;
|
|
1491
1407
|
}
|
|
1492
1408
|
}
|
|
1493
|
-
throw new
|
|
1409
|
+
throw new CompanionError(
|
|
1494
1410
|
`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
1411
|
);
|
|
1496
1412
|
}
|
|
@@ -1694,7 +1610,7 @@ var CabaneApi = class {
|
|
|
1694
1610
|
outbox.persist({ enqueuedAt: Date.now(), turnId, seq, method: "POST", path: path3, body, kind });
|
|
1695
1611
|
this.opts.log?.warn(
|
|
1696
1612
|
{ kind, turnId, seq, err: err instanceof Error ? err.message : String(err) },
|
|
1697
|
-
"
|
|
1613
|
+
"companion: commit queued to outbox after transient failure (will drain when the API returns)"
|
|
1698
1614
|
);
|
|
1699
1615
|
}
|
|
1700
1616
|
}
|
|
@@ -1721,7 +1637,7 @@ var CabaneApi = class {
|
|
|
1721
1637
|
if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
|
|
1722
1638
|
this.opts.log?.warn(
|
|
1723
1639
|
{ kind: entry.kind, turnId: entry.turnId, seq: entry.seq, status: err.status },
|
|
1724
|
-
"
|
|
1640
|
+
"companion outbox: discarding entry on terminal 4xx (will never land)"
|
|
1725
1641
|
);
|
|
1726
1642
|
outbox.remove(entry.turnId, entry.seq);
|
|
1727
1643
|
progressed = true;
|
|
@@ -1748,7 +1664,7 @@ var CabaneApi = class {
|
|
|
1748
1664
|
// the machinery the `sub_agent` turn-control tool is sugar over. Two things make
|
|
1749
1665
|
// it distinct from an ordinary `request` call, so it does its own `fetch`:
|
|
1750
1666
|
// - a PER-CALL bearer — the turn's OBO token when the API minted one, else the
|
|
1751
|
-
//
|
|
1667
|
+
// companion PAT — so the spawn carries the same authority as the agent's other
|
|
1752
1668
|
// cabane calls this turn (mirrors build-options' `turnToken ?? agentPat`);
|
|
1753
1669
|
// - the `x-cabane-active-conversation` header naming the caller's turn, which
|
|
1754
1670
|
// the server verifies against the live run to resolve the caller pair for the
|
|
@@ -1781,15 +1697,15 @@ var CabaneApi = class {
|
|
|
1781
1697
|
}
|
|
1782
1698
|
return { status: res.status, body: parsed };
|
|
1783
1699
|
}
|
|
1784
|
-
// SJ383:
|
|
1700
|
+
// SJ383: companion-only participant ops. All three authenticate with the
|
|
1785
1701
|
// workspace's agent-bound PAT (passed as `token` on this client) — never
|
|
1786
1702
|
// the user PAT.
|
|
1787
|
-
// Recovery-path read: returns the
|
|
1703
|
+
// Recovery-path read: returns the companion's `agentSessionId` for this
|
|
1788
1704
|
// conversation so the dispatcher can pass it as `Options.resume`, plus
|
|
1789
|
-
// the current `activeRunStartedAt` so a freshly-reconnected
|
|
1705
|
+
// the current `activeRunStartedAt` so a freshly-reconnected companion can
|
|
1790
1706
|
// see whether a prior run is still flagged in-flight.
|
|
1791
1707
|
// SJ383: recovery-path read for a participant row — the `agentSessionId` a
|
|
1792
|
-
// freshly-reconnected
|
|
1708
|
+
// freshly-reconnected companion resumes on. CT262: the per-turn piggybacks this
|
|
1793
1709
|
// fetch grew (agentRules / visionBlocks / channel / members / conversationContext)
|
|
1794
1710
|
// are gone — `getTurnContext` composes the whole turn now — so this is back to
|
|
1795
1711
|
// the plain recovery read, with no `messageId` param.
|
|
@@ -1799,17 +1715,37 @@ var CabaneApi = class {
|
|
|
1799
1715
|
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}`
|
|
1800
1716
|
);
|
|
1801
1717
|
}
|
|
1802
|
-
// CT262: the ONE turn-context fetch. Collapses the
|
|
1718
|
+
// CT262: the ONE turn-context fetch. Collapses the companion's old four-fetch
|
|
1803
1719
|
// choreography (getConversation + getMessage + getAgentSelf + getParticipantAgent)
|
|
1804
1720
|
// into a single call: the server composes the whole server portion of the
|
|
1805
1721
|
// `TurnRequest` — systemPrompt, per-turn prompt + vision content, effective
|
|
1806
1722
|
// run-config, `HostPolicy`, prior session, the user MCP definitions to resolve
|
|
1807
1723
|
// locally, plus the anchor/title + trigger-message summary the host needs.
|
|
1808
1724
|
// Agent-PAT authed; the workspace is implied by the PAT.
|
|
1809
|
-
|
|
1810
|
-
|
|
1725
|
+
// CT714: `turnId` is the host-minted id for THIS turn, passed so the server can
|
|
1726
|
+
// bind the minted turn token to it — the turn-control surface then rejects a
|
|
1727
|
+
// token whose turn has ended. The dispatcher mints it before this call and
|
|
1728
|
+
// reuses the same value on its active-run PATCH, so the token's turn id and the
|
|
1729
|
+
// pair's `active_turn_id` agree.
|
|
1730
|
+
getTurnContext(conversationId, messageId2, turnId) {
|
|
1731
|
+
const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
|
|
1811
1732
|
return this.request("GET", `/api/agent/turn-context?${q}`);
|
|
1812
1733
|
}
|
|
1734
|
+
// CT714: read a turn's recorded turn-control intent (ask/wake/summon/skip). An
|
|
1735
|
+
// EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
|
|
1736
|
+
// `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
|
|
1737
|
+
// in-memory closures, so the dispatcher fetches this once at settle — by
|
|
1738
|
+
// `turnId` — and populates those closures, letting the unchanged settle path
|
|
1739
|
+
// materialize the effects identically to claude-code. Agent-PAT authed +
|
|
1740
|
+
// self-scoped (`:agentId` must match the PAT's agent). A turn that recorded no
|
|
1741
|
+
// control verb returns all-empty fields.
|
|
1742
|
+
getTurnIntent(workspaceId, conversationId, agentId, turnId) {
|
|
1743
|
+
const q = `turnId=${encodeURIComponent(turnId)}`;
|
|
1744
|
+
return this.request(
|
|
1745
|
+
"GET",
|
|
1746
|
+
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-intent?${q}`
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1813
1749
|
// Flips `active_run_started_at` and optionally captures the SDK session
|
|
1814
1750
|
// id. The dispatcher hits this twice per turn (now() before the SDK
|
|
1815
1751
|
// loop; null when it settles) plus once with the session id on the
|
|
@@ -1822,7 +1758,7 @@ var CabaneApi = class {
|
|
|
1822
1758
|
// `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
|
|
1823
1759
|
// left best-effort: it's lower-stakes and self-heals on the next turn, so it
|
|
1824
1760
|
// stays a single-shot PATCH and is deliberately out of CT93's scope.
|
|
1825
|
-
|
|
1761
|
+
setActiveRun(workspaceId, conversationId, agentId, body) {
|
|
1826
1762
|
const path3 = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
|
|
1827
1763
|
const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
|
|
1828
1764
|
if (touchesFlag && this.opts.outbox) {
|
|
@@ -1865,16 +1801,16 @@ var CabaneApi = class {
|
|
|
1865
1801
|
});
|
|
1866
1802
|
this.opts.log?.warn(
|
|
1867
1803
|
{ conversationId, agentId, err: err instanceof Error ? err.message : String(err) },
|
|
1868
|
-
"
|
|
1804
|
+
"companion: active-run write queued to outbox after transient failure (will drain when the API returns)"
|
|
1869
1805
|
);
|
|
1870
1806
|
}
|
|
1871
1807
|
}
|
|
1872
1808
|
// CT29: per-device liveness moved off the per-workspace agent PAT and onto the
|
|
1873
1809
|
// device token — see `DeviceApi.heartbeat`. There is no agent-PAT heartbeat
|
|
1874
1810
|
// anymore.
|
|
1875
|
-
// SJ477: commit one row of the
|
|
1811
|
+
// SJ477: commit one row of the companion's turn (a `progress` interim note or
|
|
1876
1812
|
// the `final` reply), derived from its own SDK transcript. Posts to the same
|
|
1877
|
-
// public messages endpoint a user hits — the
|
|
1813
|
+
// public messages endpoint a user hits — the companion holds an agent-bound
|
|
1878
1814
|
// PAT, so the server attributes the row to this agent (role `agent`) and
|
|
1879
1815
|
// won't re-dispatch (the route gates re-dispatch on role `user`). `turnId`
|
|
1880
1816
|
// groups every row of one turn so the chat drawer renders them as a single
|
|
@@ -1889,12 +1825,12 @@ var CabaneApi = class {
|
|
|
1889
1825
|
// CT11: `kind` now includes `'stopped'` for the terminal marker the
|
|
1890
1826
|
// dispatcher writes when a turn is cancelled — same wire shape as
|
|
1891
1827
|
// `progress`/`final`, distinguished only by `kind` so the chat drawer's
|
|
1892
|
-
// turn-group renderer treats it as a closing row. `seq` is the
|
|
1828
|
+
// turn-group renderer treats it as a closing row. `seq` is the companion's
|
|
1893
1829
|
// per-turn monotonic counter, stamped on the row so the merged timeline
|
|
1894
1830
|
// orders the commit deterministically against the persisted tool/thinking
|
|
1895
|
-
// rows. Both fields are optional on the wire — an older
|
|
1831
|
+
// rows. Both fields are optional on the wire — an older companion that didn't
|
|
1896
1832
|
// mint seq still validates (the server defaults to 0); `stopped` is only
|
|
1897
|
-
// emitted by post-CT11
|
|
1833
|
+
// emitted by post-CT11 companions.
|
|
1898
1834
|
postTurnMessage(workspaceId, conversationId, body, signal) {
|
|
1899
1835
|
return this.durableCommit(
|
|
1900
1836
|
"message",
|
|
@@ -1935,11 +1871,11 @@ var CabaneApi = class {
|
|
|
1935
1871
|
);
|
|
1936
1872
|
}
|
|
1937
1873
|
// SJ493: fetch the agent's self-view — identity + operating context. The
|
|
1938
|
-
//
|
|
1874
|
+
// companion calls this on each dispatch to get its `systemPrompt` (composed
|
|
1939
1875
|
// server-side from the bundled default + the agent's charter) rather than
|
|
1940
1876
|
// baking a copy of the prompt into the download. Cabane is the control plane
|
|
1941
1877
|
// for the prompt, so changing it (or the per-agent charter) takes effect
|
|
1942
|
-
// without shipping a new
|
|
1878
|
+
// without shipping a new companion. Agent-PAT authed; the workspace is implied
|
|
1943
1879
|
// by the PAT, so no workspace arg.
|
|
1944
1880
|
// CT245: pass the triggering turn's `conversationId` so the server returns the
|
|
1945
1881
|
// run-config RESOLVED for this conversation (agent default + that
|
|
@@ -1950,10 +1886,10 @@ var CabaneApi = class {
|
|
|
1950
1886
|
const path3 = conversationId ? `/api/agent/me?conversationId=${encodeURIComponent(conversationId)}` : `/api/agent/me`;
|
|
1951
1887
|
return this.request("GET", path3);
|
|
1952
1888
|
}
|
|
1953
|
-
// The
|
|
1889
|
+
// The companion fetches the triggering message body by listing the
|
|
1954
1890
|
// conversation's messages and finding the one with `id === messageId`.
|
|
1955
1891
|
// Cabane has no single-message GET endpoint; for v0 this is fine because
|
|
1956
|
-
// the
|
|
1892
|
+
// the companion only reaches for the specific row immediately after the
|
|
1957
1893
|
// event fires (the thread is small at that point).
|
|
1958
1894
|
async getMessage(workspaceId, conversationId, messageId2) {
|
|
1959
1895
|
const res = await this.request(
|
|
@@ -2032,7 +1968,10 @@ var DeviceApi = class {
|
|
|
2032
1968
|
getAssignments() {
|
|
2033
1969
|
return this.request("GET", "/api/companion/assignments");
|
|
2034
1970
|
}
|
|
2035
|
-
|
|
1971
|
+
beginDrain() {
|
|
1972
|
+
return this.request("POST", "/api/companion/drain", {});
|
|
1973
|
+
}
|
|
1974
|
+
// Per-device liveness ping. Reports the companion build version and the env-var
|
|
2036
1975
|
// names the operator's secret store exposes (never values), so CT30's UI can
|
|
2037
1976
|
// warn pre-emptively about an agent that needs a secret this device lacks.
|
|
2038
1977
|
heartbeat(body) {
|
|
@@ -2049,7 +1988,7 @@ function errorMessage2(status2, body) {
|
|
|
2049
1988
|
}
|
|
2050
1989
|
|
|
2051
1990
|
// src/cursor.ts
|
|
2052
|
-
import { mkdirSync as mkdirSync6, readFileSync as
|
|
1991
|
+
import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
|
|
2053
1992
|
import { join as join7 } from "path";
|
|
2054
1993
|
function pathFor(workspaceId) {
|
|
2055
1994
|
return join7(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
|
|
@@ -2057,7 +1996,7 @@ function pathFor(workspaceId) {
|
|
|
2057
1996
|
function readCursor(workspaceId) {
|
|
2058
1997
|
const path3 = pathFor(workspaceId);
|
|
2059
1998
|
if (!existsSync5(path3)) return null;
|
|
2060
|
-
const raw =
|
|
1999
|
+
const raw = readFileSync4(path3, "utf8").trim();
|
|
2061
2000
|
return raw.length > 0 ? raw : null;
|
|
2062
2001
|
}
|
|
2063
2002
|
function writeCursor(workspaceId, eventId) {
|
|
@@ -2106,7 +2045,7 @@ var CursorTracker = class {
|
|
|
2106
2045
|
};
|
|
2107
2046
|
|
|
2108
2047
|
// src/dispatch-dedupe.ts
|
|
2109
|
-
import { mkdirSync as mkdirSync7, readFileSync as
|
|
2048
|
+
import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "fs";
|
|
2110
2049
|
import { join as join8 } from "path";
|
|
2111
2050
|
var MAX_IDS = 256;
|
|
2112
2051
|
function dir(log) {
|
|
@@ -2119,7 +2058,7 @@ function readIds(log, workspaceId) {
|
|
|
2119
2058
|
const path3 = pathFor2(log, workspaceId);
|
|
2120
2059
|
if (!existsSync6(path3)) return [];
|
|
2121
2060
|
try {
|
|
2122
|
-
return
|
|
2061
|
+
return readFileSync5(path3, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
2123
2062
|
} catch {
|
|
2124
2063
|
return [];
|
|
2125
2064
|
}
|
|
@@ -2159,7 +2098,7 @@ function readResumeCounts(workspaceId) {
|
|
|
2159
2098
|
const path3 = resumePathFor(workspaceId);
|
|
2160
2099
|
if (!existsSync6(path3)) return out;
|
|
2161
2100
|
try {
|
|
2162
|
-
for (const line of
|
|
2101
|
+
for (const line of readFileSync5(path3, "utf8").split("\n")) {
|
|
2163
2102
|
const trimmed = line.trim();
|
|
2164
2103
|
if (!trimmed) continue;
|
|
2165
2104
|
const tab = trimmed.lastIndexOf(" ");
|
|
@@ -2188,54 +2127,55 @@ function bumpResumeAttempt(workspaceId, eventId) {
|
|
|
2188
2127
|
return next;
|
|
2189
2128
|
}
|
|
2190
2129
|
function noResume() {
|
|
2191
|
-
return process.env.
|
|
2130
|
+
return process.env.CABANE_COMPANION_NO_RESUME === "1";
|
|
2192
2131
|
}
|
|
2193
2132
|
|
|
2194
2133
|
// packages/agent-runtime/src/version.ts
|
|
2195
2134
|
var TURN_PROTOCOL_VERSION = 1;
|
|
2196
2135
|
|
|
2197
2136
|
// packages/agent-runtime/src/host-policy.ts
|
|
2198
|
-
import { z as
|
|
2199
|
-
var hostPolicySchema =
|
|
2137
|
+
import { z as z4 } from "zod";
|
|
2138
|
+
var hostPolicySchema = z4.object({
|
|
2200
2139
|
// Host filesystem + shell: `Bash`/`Read`/`Write`/`Edit`/`Glob`/`Grep`, the
|
|
2201
2140
|
// notebook read/write pair, git-worktree tools, and the `REPL` host code-exec
|
|
2202
2141
|
// tool. Off under the locked assistant surface (today's `DISALLOWED_TOOLS`);
|
|
2203
2142
|
// on under `coding` mode.
|
|
2204
|
-
hostFs:
|
|
2143
|
+
hostFs: z4.boolean(),
|
|
2205
2144
|
// Public web (today's `WebSearch` / `WebFetch`, the `DEFAULT_WEB_TOOLS`). Public
|
|
2206
2145
|
// web, not host reach — granted by default today, but expressible as a grant.
|
|
2207
|
-
web:
|
|
2208
|
-
// Browser automation (the Playwright MCP surface). Varies by host: a
|
|
2146
|
+
web: z4.boolean(),
|
|
2147
|
+
// Browser automation (the Playwright MCP surface). Varies by host: a companion has
|
|
2209
2148
|
// it, the house executor does not (CT230).
|
|
2210
|
-
browser:
|
|
2149
|
+
browser: z4.boolean(),
|
|
2211
2150
|
// User-configured MCP servers permitted. False for the house executor
|
|
2212
|
-
// (CT227: Cabane agents run no user MCP servers), true for a personal
|
|
2213
|
-
userMcp:
|
|
2151
|
+
// (CT227: Cabane agents run no user MCP servers), true for a personal companion.
|
|
2152
|
+
userMcp: z4.boolean(),
|
|
2214
2153
|
// Subagent / sub-dispatch (`Task` / `Agent`). A per-host GRANT (see the CT261
|
|
2215
2154
|
// amendment above): `false` on the locked assistant/house surface (banned via
|
|
2216
2155
|
// `DISALLOWED_TOOLS`), `true` under coding / custom where the fan-out is left to
|
|
2217
|
-
//
|
|
2156
|
+
// the coding allow-hook (which reaches sub-agent tool calls, CT680) or the user
|
|
2157
|
+
// allowlist. The subagent completes within the turn, so
|
|
2218
2158
|
// it's not the turn-model invariant `scheduling` is.
|
|
2219
|
-
subagents:
|
|
2159
|
+
subagents: z4.boolean(),
|
|
2220
2160
|
// ── Hard platform invariants — always denied, never granted ────────────────
|
|
2221
2161
|
// Deferred re-invocation / scheduling (`ScheduleWakeup`, the `Cron*` / `Task*`
|
|
2222
2162
|
// families, `Monitor`, `Workflow`, …). A turn is one query that resolves when
|
|
2223
2163
|
// `result` fires; a scheduled callback fires after the reply window has closed
|
|
2224
2164
|
// and strands the agent (the CT155/CT156 rule).
|
|
2225
|
-
scheduling:
|
|
2165
|
+
scheduling: z4.literal("never"),
|
|
2226
2166
|
// Human-facing UI prompts (`AskUserQuestion`). A Cabane conversation has no
|
|
2227
2167
|
// handler to answer a structured prompt, so the call hangs the turn
|
|
2228
2168
|
// (`UNSUPPORTED_TOOLS`). The agent asks in its reply instead.
|
|
2229
|
-
uiPrompts:
|
|
2169
|
+
uiPrompts: z4.literal("never")
|
|
2230
2170
|
});
|
|
2231
2171
|
|
|
2232
2172
|
// packages/agent-runtime/src/turn-event.ts
|
|
2233
|
-
import { z as
|
|
2234
|
-
var turnEventSchema =
|
|
2173
|
+
import { z as z5 } from "zod";
|
|
2174
|
+
var turnEventSchema = z5.discriminatedUnion("type", [
|
|
2235
2175
|
// The runtime's opaque session state, emitted when the adapter learns it (e.g.
|
|
2236
2176
|
// the SDK `system/init` frame). The platform stores `state` verbatim per
|
|
2237
2177
|
// (conversation, agent) and hands it back on the next turn; only the adapter
|
|
2238
|
-
// knows what it means. Today's
|
|
2178
|
+
// knows what it means. Today's companion captures the raw SDK session id here; a
|
|
2239
2179
|
// future adapter may encode more (e.g. `{sdkSessionId, cwd}`) — still one
|
|
2240
2180
|
// opaque string to the platform.
|
|
2241
2181
|
//
|
|
@@ -2248,22 +2188,22 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2248
2188
|
// so the mark now over-reaches an empty session. The host relays `degraded`
|
|
2249
2189
|
// on settle and the server rewinds the mark, so the NEXT turn rebuilds a full
|
|
2250
2190
|
// catch-up (this failing turn is unavoidably lossy — the degrade is only known
|
|
2251
|
-
// on the
|
|
2191
|
+
// on the companion, after the server committed the manifest). Runtime-neutral: a
|
|
2252
2192
|
// plain boolean, not a runtime-specific reason string (that stays in the
|
|
2253
2193
|
// adapter's `onWarn` log). Additive + optional — an old receiver ignores it.
|
|
2254
|
-
|
|
2255
|
-
type:
|
|
2256
|
-
state:
|
|
2257
|
-
degraded:
|
|
2194
|
+
z5.object({
|
|
2195
|
+
type: z5.literal("session"),
|
|
2196
|
+
state: z5.string(),
|
|
2197
|
+
degraded: z5.boolean().optional()
|
|
2258
2198
|
}),
|
|
2259
2199
|
// One readable thinking summary. Maps `onThinking({ text })`. Transient —
|
|
2260
2200
|
// surfaced live, never persisted as durable content.
|
|
2261
|
-
|
|
2201
|
+
z5.object({ type: z5.literal("thinking"), text: z5.string() }),
|
|
2262
2202
|
// Assistant text. Maps `onAssistantText({ text, final })` — `text`→`body`,
|
|
2263
2203
|
// `final`→`terminal`. `terminal: false` is interim narration (commits as a
|
|
2264
2204
|
// `progress` row); `terminal: true` is the turn's closing reply (commits as
|
|
2265
2205
|
// the `final` row).
|
|
2266
|
-
|
|
2206
|
+
z5.object({ type: z5.literal("text"), body: z5.string(), terminal: z5.boolean() }),
|
|
2267
2207
|
// A tool-activity transition. Maps `onToolActivity` — `toolUseId`→`id`,
|
|
2268
2208
|
// `toolName`→`name` (already prefix-stripped: `cabane_read`, not
|
|
2269
2209
|
// `mcp__cabane__cabane_read`), `summary` is the short card label. `phase`
|
|
@@ -2278,15 +2218,15 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2278
2218
|
// dropped the prefix; null for a host / built-in tool. The client tags Cabane
|
|
2279
2219
|
// MCP calls (`=== 'cabane'`) with a subtle glyph. Nullable + optional so a
|
|
2280
2220
|
// pre-CT496 producer that never sets it is unaffected (treated as null).
|
|
2281
|
-
|
|
2282
|
-
type:
|
|
2283
|
-
id:
|
|
2284
|
-
name:
|
|
2285
|
-
phase:
|
|
2286
|
-
summary:
|
|
2287
|
-
input:
|
|
2288
|
-
result:
|
|
2289
|
-
mcpServer:
|
|
2221
|
+
z5.object({
|
|
2222
|
+
type: z5.literal("tool"),
|
|
2223
|
+
id: z5.string(),
|
|
2224
|
+
name: z5.string(),
|
|
2225
|
+
phase: z5.enum(["start", "done", "error"]),
|
|
2226
|
+
summary: z5.string(),
|
|
2227
|
+
input: z5.unknown().optional(),
|
|
2228
|
+
result: z5.unknown().optional(),
|
|
2229
|
+
mcpServer: z5.string().nullable().optional()
|
|
2290
2230
|
}),
|
|
2291
2231
|
// The turn's terminal outcome. Maps the `{ ok, reason }` both loops return
|
|
2292
2232
|
// inline. `ok:false` carries a machine reason (`no_session`, an error code);
|
|
@@ -2296,7 +2236,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2296
2236
|
// dropped on the floor before. `inputTokens` is the full context the model saw
|
|
2297
2237
|
// (uncached + cache-read + cache-creation input), so it doubles as the
|
|
2298
2238
|
// context-window cost; `outputTokens` the generated tokens. Optional ⇒
|
|
2299
|
-
// backward-compatible: an old
|
|
2239
|
+
// backward-compatible: an old companion / adapter that never sets it, and a
|
|
2300
2240
|
// receiver that never reads it, are unaffected (the turn's token columns stay
|
|
2301
2241
|
// null → the UI shows `—`).
|
|
2302
2242
|
//
|
|
@@ -2306,6 +2246,19 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2306
2246
|
// the server just leaves the cache columns null. Captured now because honest
|
|
2307
2247
|
// costing later prices a cache-read token far below a fresh input token.
|
|
2308
2248
|
//
|
|
2249
|
+
// CT699: `inputTokens` (and the cache slice) is a BILLING quantity — for
|
|
2250
|
+
// claude-code/codex it's the runtime's CUMULATIVE total summed across every model
|
|
2251
|
+
// request in the agentic turn, so it grows with the tool-call count and is NOT
|
|
2252
|
+
// "how full is the window." `contextTokens` is the distinct CONTEXT-OCCUPANCY
|
|
2253
|
+
// read: the input the model saw on its FINAL request of the turn (uncached +
|
|
2254
|
+
// cache, since cached tokens still occupy the window) — the number the composer
|
|
2255
|
+
// gauge wants. `contextWindow` is the model's true window in tokens when the
|
|
2256
|
+
// runtime reports it (claude-code's SDK does, per model) — a real denominator so
|
|
2257
|
+
// the gauge can show a fraction. Both optional: a runtime that can't source a
|
|
2258
|
+
// clean final-request figure (codex's cumulative-only usage) omits `contextTokens`
|
|
2259
|
+
// and the gauge falls back to the raw count; `contextWindow` falls back to the
|
|
2260
|
+
// model catalog.
|
|
2261
|
+
//
|
|
2309
2262
|
// CT601: two more optional carry-homes on the terminal result, alongside
|
|
2310
2263
|
// `usage`. `resolvedModel` is the CONCRETE model the runtime actually ran —
|
|
2311
2264
|
// claude-code learns it from the `system/init` frame mid-run (even for a
|
|
@@ -2315,34 +2268,36 @@ var turnEventSchema = z6.discriminatedUnion("type", [
|
|
|
2315
2268
|
// for claude-code today). Both only known after the run streams — so they ride
|
|
2316
2269
|
// the terminal event home, the host relays them on settle, and the server writes
|
|
2317
2270
|
// `message_turn_metadata.resolved_model` / `resolved_config`. Optional ⇒
|
|
2318
|
-
// backward-compatible: an old adapter/
|
|
2271
|
+
// backward-compatible: an old adapter/companion omits them, a cancel has no result
|
|
2319
2272
|
// event at all, and the columns stay null → the UI shows `—`.
|
|
2320
|
-
|
|
2321
|
-
type:
|
|
2322
|
-
ok:
|
|
2323
|
-
reason:
|
|
2324
|
-
usage:
|
|
2325
|
-
inputTokens:
|
|
2326
|
-
outputTokens:
|
|
2327
|
-
cacheReadTokens:
|
|
2328
|
-
cacheCreationTokens:
|
|
2273
|
+
z5.object({
|
|
2274
|
+
type: z5.literal("result"),
|
|
2275
|
+
ok: z5.boolean(),
|
|
2276
|
+
reason: z5.string().optional(),
|
|
2277
|
+
usage: z5.object({
|
|
2278
|
+
inputTokens: z5.number(),
|
|
2279
|
+
outputTokens: z5.number(),
|
|
2280
|
+
cacheReadTokens: z5.number().optional(),
|
|
2281
|
+
cacheCreationTokens: z5.number().optional(),
|
|
2282
|
+
contextTokens: z5.number().optional(),
|
|
2283
|
+
contextWindow: z5.number().optional()
|
|
2329
2284
|
}).optional(),
|
|
2330
|
-
resolvedModel:
|
|
2331
|
-
resolvedConfig:
|
|
2332
|
-
effort:
|
|
2333
|
-
thinking:
|
|
2334
|
-
reasoningEffort:
|
|
2285
|
+
resolvedModel: z5.string().optional(),
|
|
2286
|
+
resolvedConfig: z5.object({
|
|
2287
|
+
effort: z5.string().optional(),
|
|
2288
|
+
thinking: z5.string().optional(),
|
|
2289
|
+
reasoningEffort: z5.string().optional()
|
|
2335
2290
|
}).optional()
|
|
2336
2291
|
})
|
|
2337
2292
|
]);
|
|
2338
2293
|
|
|
2339
2294
|
// packages/agent-runtime/src/failure.ts
|
|
2340
|
-
import { z as
|
|
2341
|
-
var turnFailureSchema =
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2295
|
+
import { z as z6 } from "zod";
|
|
2296
|
+
var turnFailureSchema = z6.discriminatedUnion("kind", [
|
|
2297
|
+
z6.object({ kind: z6.literal("usage_capped"), resetsAt: z6.string().optional() }),
|
|
2298
|
+
z6.object({ kind: z6.literal("rate_limited") }),
|
|
2299
|
+
z6.object({ kind: z6.literal("server_error") }),
|
|
2300
|
+
z6.object({ kind: z6.literal("auth_expired") })
|
|
2346
2301
|
]);
|
|
2347
2302
|
var USAGE_CAPPED = "usage_capped";
|
|
2348
2303
|
var RATE_LIMITED = "rate_limited";
|
|
@@ -2413,7 +2368,8 @@ function classifyErrorText(text) {
|
|
|
2413
2368
|
const t = text.toLowerCase();
|
|
2414
2369
|
if (AUTH_PATTERNS.some((re) => re.test(t))) return { kind: "auth_expired" };
|
|
2415
2370
|
if (SERVER_PATTERNS.some((re) => re.test(t))) return { kind: "server_error" };
|
|
2416
|
-
const
|
|
2371
|
+
const withoutNegatedCap = t.replace(NEGATED_CAP, "");
|
|
2372
|
+
const capNoun = CAP_PATTERNS.some((re) => re.test(withoutNegatedCap));
|
|
2417
2373
|
const rateToken = RATE_PATTERNS.some((re) => re.test(t));
|
|
2418
2374
|
if (capNoun) return { kind: "usage_capped" };
|
|
2419
2375
|
if (rateToken) return { kind: "rate_limited" };
|
|
@@ -2442,105 +2398,120 @@ var SERVER_PATTERNS = [
|
|
|
2442
2398
|
/fetch failed/
|
|
2443
2399
|
];
|
|
2444
2400
|
var CAP_PATTERNS = [/usage limit/, /weekly limit/, /session limit/, /\bquota\b/];
|
|
2401
|
+
var NEGATED_CAP = /not (your|a) usage limit/g;
|
|
2445
2402
|
var RATE_PATTERNS = [/rate[_ ]?limit/, /\b429\b/, /too many requests/];
|
|
2446
2403
|
var BARE_LIMIT = /\blimit (reached|exceeded)\b/;
|
|
2447
2404
|
|
|
2448
2405
|
// packages/agent-runtime/src/turn-request.ts
|
|
2449
|
-
import { z as
|
|
2450
|
-
var contentBlockSchema =
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
type:
|
|
2454
|
-
source:
|
|
2406
|
+
import { z as z7 } from "zod";
|
|
2407
|
+
var contentBlockSchema = z7.discriminatedUnion("type", [
|
|
2408
|
+
z7.object({ type: z7.literal("text"), text: z7.string() }),
|
|
2409
|
+
z7.object({
|
|
2410
|
+
type: z7.literal("image"),
|
|
2411
|
+
source: z7.object({ type: z7.literal("url"), url: z7.string() })
|
|
2455
2412
|
}),
|
|
2456
|
-
|
|
2457
|
-
type:
|
|
2458
|
-
source:
|
|
2413
|
+
z7.object({
|
|
2414
|
+
type: z7.literal("document"),
|
|
2415
|
+
source: z7.object({ type: z7.literal("url"), url: z7.string() })
|
|
2459
2416
|
})
|
|
2460
2417
|
]);
|
|
2461
|
-
var effortLevelSchema =
|
|
2462
|
-
var resolvedRunConfigSchema =
|
|
2463
|
-
model:
|
|
2418
|
+
var effortLevelSchema = z7.enum(["low", "medium", "high", "xhigh", "max"]);
|
|
2419
|
+
var resolvedRunConfigSchema = z7.object({
|
|
2420
|
+
model: z7.string().nullable(),
|
|
2464
2421
|
effort: effortLevelSchema.optional(),
|
|
2465
|
-
runtimeOptions:
|
|
2422
|
+
runtimeOptions: z7.record(z7.string(), z7.unknown()).optional()
|
|
2466
2423
|
});
|
|
2467
|
-
var resolvedMcpServerSchema =
|
|
2468
|
-
|
|
2469
|
-
type:
|
|
2470
|
-
command:
|
|
2471
|
-
args:
|
|
2472
|
-
env:
|
|
2424
|
+
var resolvedMcpServerSchema = z7.union([
|
|
2425
|
+
z7.object({
|
|
2426
|
+
type: z7.literal("stdio").optional(),
|
|
2427
|
+
command: z7.string(),
|
|
2428
|
+
args: z7.array(z7.string()).optional(),
|
|
2429
|
+
env: z7.record(z7.string(), z7.string()).optional()
|
|
2473
2430
|
}),
|
|
2474
|
-
|
|
2475
|
-
type:
|
|
2476
|
-
url:
|
|
2477
|
-
headers:
|
|
2431
|
+
z7.object({
|
|
2432
|
+
type: z7.literal("http"),
|
|
2433
|
+
url: z7.string(),
|
|
2434
|
+
headers: z7.record(z7.string(), z7.string()).optional()
|
|
2478
2435
|
}),
|
|
2479
|
-
|
|
2480
|
-
type:
|
|
2481
|
-
url:
|
|
2482
|
-
headers:
|
|
2436
|
+
z7.object({
|
|
2437
|
+
type: z7.literal("sse"),
|
|
2438
|
+
url: z7.string(),
|
|
2439
|
+
headers: z7.record(z7.string(), z7.string()).optional()
|
|
2483
2440
|
})
|
|
2484
2441
|
]);
|
|
2485
|
-
var resolvedMcpServersSchema =
|
|
2486
|
-
var hostInjectedServersSchema =
|
|
2487
|
-
var turnRequestSchema =
|
|
2442
|
+
var resolvedMcpServersSchema = z7.record(z7.string(), resolvedMcpServerSchema);
|
|
2443
|
+
var hostInjectedServersSchema = z7.record(z7.string(), z7.unknown());
|
|
2444
|
+
var turnRequestSchema = z7.object({
|
|
2488
2445
|
// Server-composed system prompt (core + capability prose + adapter addendum +
|
|
2489
2446
|
// charter). One string to the adapter.
|
|
2490
|
-
systemPrompt:
|
|
2447
|
+
systemPrompt: z7.string(),
|
|
2491
2448
|
// Server-composed per-turn user text (anchor reminder + the triggering message).
|
|
2492
|
-
prompt:
|
|
2449
|
+
prompt: z7.string(),
|
|
2493
2450
|
// The multi-block user-message body (text + vision).
|
|
2494
|
-
content:
|
|
2451
|
+
content: z7.array(contentBlockSchema),
|
|
2495
2452
|
// Portable-or-dialect run-config (above).
|
|
2496
2453
|
config: resolvedRunConfigSchema,
|
|
2497
2454
|
// Abstract capability grants; the adapter maps them to tool names.
|
|
2498
2455
|
policy: hostPolicySchema,
|
|
2499
2456
|
// Prior opaque session state, or null for a fresh session.
|
|
2500
|
-
session:
|
|
2457
|
+
session: z7.string().nullable(),
|
|
2501
2458
|
// The cabane control-plane coordinates for this turn's MCP + post-back.
|
|
2502
|
-
cabane:
|
|
2503
|
-
mcpUrl:
|
|
2504
|
-
bearer:
|
|
2505
|
-
activeConversationId:
|
|
2459
|
+
cabane: z7.object({
|
|
2460
|
+
mcpUrl: z7.string(),
|
|
2461
|
+
bearer: z7.string(),
|
|
2462
|
+
activeConversationId: z7.string(),
|
|
2463
|
+
// CT714: the scoped TURN-CONTROL MCP endpoint (`/api/turn-control`). The
|
|
2464
|
+
// EXTERNAL adapters (Codex / opencode) mount it by URL under the key
|
|
2465
|
+
// `cabane_companion` — using the same `bearer` (the turn token) and the same
|
|
2466
|
+
// active-conversation header they send to the `cabane` server — so their
|
|
2467
|
+
// agents get `ask`/`wake_me`/`summon_agent`/`sub_agent`/`skip_turn`, the
|
|
2468
|
+
// verbs they can't get from the companion's in-process SDK server. Optional:
|
|
2469
|
+
// claude-code ignores it (it mounts the in-process instance instead), and
|
|
2470
|
+
// every existing `cabane`-block fixture keeps parsing unchanged; the
|
|
2471
|
+
// companion always populates it (`build-options.ts`).
|
|
2472
|
+
turnControlUrl: z7.string().optional(),
|
|
2506
2473
|
// CT598: the workspace this turn runs in. The claude-code/opencode/codex
|
|
2507
2474
|
// adapters never need it (they reach Cabane through the `cabane` MCP server,
|
|
2508
2475
|
// which takes `workspaceId` as a per-tool arg the model supplies); the
|
|
2509
2476
|
// native runtime's interim tool surface calls the workspace-scoped REST API
|
|
2510
2477
|
// DIRECTLY, so it needs the id host-side rather than trusting the model to
|
|
2511
2478
|
// pass it. Optional so every existing `cabane`-block constructor (the three
|
|
2512
|
-
// adapters' conformance fixtures, tests) keeps parsing unchanged — the
|
|
2479
|
+
// adapters' conformance fixtures, tests) keeps parsing unchanged — the companion
|
|
2513
2480
|
// always populates it (`build-options.ts`), and the native adapter fails the
|
|
2514
2481
|
// turn loudly when it is somehow absent rather than guessing.
|
|
2515
|
-
workspaceId:
|
|
2482
|
+
workspaceId: z7.string().optional(),
|
|
2483
|
+
// CT752: the server-resolved workspace surface this credential exposes.
|
|
2484
|
+
// Readiness uses this explicit fact to require `sdk` for code mode and the
|
|
2485
|
+
// granular floor for classic mode; inventory contents alone cannot infer it
|
|
2486
|
+
// because `sdk` is intentionally also available on the classic surface.
|
|
2487
|
+
workspaceToolSurface: z7.enum(["code", "classic"]).optional()
|
|
2516
2488
|
}),
|
|
2517
2489
|
// Machine-local resolution (host-filled): the checkout cwd, extra env from a
|
|
2518
2490
|
// prepare hook, and the resolved user MCP servers.
|
|
2519
|
-
local:
|
|
2520
|
-
cwd:
|
|
2521
|
-
env:
|
|
2491
|
+
local: z7.object({
|
|
2492
|
+
cwd: z7.string().optional(),
|
|
2493
|
+
env: z7.record(z7.string(), z7.string()).optional(),
|
|
2522
2494
|
mcpServers: resolvedMcpServersSchema.optional(),
|
|
2523
2495
|
// CT289: machine-local claude-code adapter knobs the operator sets on a
|
|
2524
|
-
//
|
|
2496
|
+
// companion they run themselves — the auto-memory escape hatch. `autoMemory:
|
|
2525
2497
|
// true` opts back into Claude Code's auto-memory (governed by the operator's
|
|
2526
2498
|
// own `.claude/settings.json`); absent/false leaves the adapter's force-off
|
|
2527
|
-
// default in place (see `buildClaudeCodeOptions`).
|
|
2528
|
-
|
|
2529
|
-
claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
|
|
2499
|
+
// default in place (see `buildClaudeCodeOptions`).
|
|
2500
|
+
claudeCode: z7.object({ autoMemory: z7.boolean().optional() }).optional()
|
|
2530
2501
|
}),
|
|
2531
2502
|
// Host-owned injected servers (host-filled) — e.g. the summon server.
|
|
2532
|
-
extra:
|
|
2503
|
+
extra: z7.object({
|
|
2533
2504
|
mcpServers: hostInjectedServersSchema
|
|
2534
2505
|
})
|
|
2535
2506
|
});
|
|
2536
2507
|
|
|
2537
2508
|
// packages/agent-runtime/src/conformance.ts
|
|
2538
|
-
import { z as
|
|
2539
|
-
var conformanceFixtureSchema =
|
|
2540
|
-
name:
|
|
2509
|
+
import { z as z8 } from "zod";
|
|
2510
|
+
var conformanceFixtureSchema = z8.object({
|
|
2511
|
+
name: z8.string(),
|
|
2541
2512
|
request: turnRequestSchema,
|
|
2542
|
-
nativeStream:
|
|
2543
|
-
expected:
|
|
2513
|
+
nativeStream: z8.array(z8.unknown()),
|
|
2514
|
+
expected: z8.array(turnEventSchema)
|
|
2544
2515
|
});
|
|
2545
2516
|
|
|
2546
2517
|
// packages/agent-runtime/src/transcript.ts
|
|
@@ -2845,7 +2816,7 @@ import {
|
|
|
2845
2816
|
var CLAUDE_CODE_ADDENDUM = "";
|
|
2846
2817
|
|
|
2847
2818
|
// packages/agent-runtime/src/claude-code/policy.ts
|
|
2848
|
-
import { z as
|
|
2819
|
+
import { z as z9 } from "zod";
|
|
2849
2820
|
var HOST_FS_TOOLS = [
|
|
2850
2821
|
// shell + local filesystem
|
|
2851
2822
|
"Bash",
|
|
@@ -2895,30 +2866,20 @@ function withThinkingSummaries(thinking) {
|
|
|
2895
2866
|
if (thinking.type === "disabled") return thinking;
|
|
2896
2867
|
return { display: "summarized", ...thinking };
|
|
2897
2868
|
}
|
|
2898
|
-
var claudeCodeDialectSchema =
|
|
2899
|
-
thinking:
|
|
2900
|
-
|
|
2901
|
-
type:
|
|
2902
|
-
display:
|
|
2869
|
+
var claudeCodeDialectSchema = z9.object({
|
|
2870
|
+
thinking: z9.discriminatedUnion("type", [
|
|
2871
|
+
z9.object({
|
|
2872
|
+
type: z9.literal("adaptive"),
|
|
2873
|
+
display: z9.enum(["summarized", "omitted"]).optional()
|
|
2903
2874
|
}),
|
|
2904
|
-
|
|
2905
|
-
type:
|
|
2906
|
-
budgetTokens:
|
|
2907
|
-
display:
|
|
2875
|
+
z9.object({
|
|
2876
|
+
type: z9.literal("enabled"),
|
|
2877
|
+
budgetTokens: z9.number().int().positive().optional(),
|
|
2878
|
+
display: z9.enum(["summarized", "omitted"]).optional()
|
|
2908
2879
|
}),
|
|
2909
|
-
|
|
2880
|
+
z9.object({ type: z9.literal("disabled") })
|
|
2910
2881
|
]).optional(),
|
|
2911
|
-
|
|
2912
|
-
disallowedTools: z10.array(z10.string()).optional(),
|
|
2913
|
-
// Which claude-code harness shape to run. `coding` switches to the
|
|
2914
|
-
// `claude_code` preset + project settings + always-allow `canUseTool`;
|
|
2915
|
-
// `custom`/`assistant` (or absent) use a plain-string prompt + bypass. This
|
|
2916
|
-
// is the claude-code-specific PRESET selector — kept distinct from
|
|
2917
|
-
// `policy.hostFs` (the host-fs BLOCK), because bridge `custom` mode wants host
|
|
2918
|
-
// fs available (via its own allowlist) WITHOUT the coding harness, and in-app
|
|
2919
|
-
// `custom` wants host fs blocked — neither of which a single `hostFs` boolean
|
|
2920
|
-
// can express alongside the preset choice.
|
|
2921
|
-
mode: z10.enum(["assistant", "coding", "custom"]).optional()
|
|
2882
|
+
hostAccess: z9.boolean().optional()
|
|
2922
2883
|
}).loose();
|
|
2923
2884
|
function readThinking(runtimeOptions) {
|
|
2924
2885
|
const dialect = runtimeOptions?.["claude-code"];
|
|
@@ -2962,10 +2923,18 @@ function decideResume(stored, currentCwd) {
|
|
|
2962
2923
|
// packages/agent-runtime/src/claude-code/options.ts
|
|
2963
2924
|
var CABANE_MCP_SERVER = "cabane";
|
|
2964
2925
|
var ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2926
|
+
async function allowEverythingHook(input) {
|
|
2927
|
+
const toolInput = "tool_input" in input && input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
|
|
2928
|
+
return {
|
|
2929
|
+
continue: true,
|
|
2930
|
+
hookSpecificOutput: {
|
|
2931
|
+
hookEventName: "PreToolUse",
|
|
2932
|
+
permissionDecision: "allow",
|
|
2933
|
+
permissionDecisionReason: "coding mode: headless never-prompt (CT680)",
|
|
2934
|
+
updatedInput: toolInput
|
|
2935
|
+
}
|
|
2936
|
+
};
|
|
2937
|
+
}
|
|
2969
2938
|
function buildClaudeCodeOptions(req, augment) {
|
|
2970
2939
|
const { policy, config } = req;
|
|
2971
2940
|
const cwd = req.local.cwd;
|
|
@@ -2989,18 +2958,15 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
2989
2958
|
};
|
|
2990
2959
|
}
|
|
2991
2960
|
const dialect = claudeCodeDialectSchema.safeParse(config.runtimeOptions?.["claude-code"] ?? {});
|
|
2992
|
-
const
|
|
2993
|
-
const customDisallowed = dialect.success ? dialect.data.disallowedTools ?? [] : [];
|
|
2994
|
-
const useCodingPreset = (dialect.success ? dialect.data.mode : void 0) === "coding";
|
|
2961
|
+
const useCodingPreset = dialect.success ? dialect.data.hostAccess ?? false : false;
|
|
2995
2962
|
const cabaneGlob = `mcp__${CABANE_MCP_SERVER}__*`;
|
|
2996
2963
|
const extraServerGlobs = Object.keys(req.extra.mcpServers).map((name) => `mcp__${name}__*`);
|
|
2997
2964
|
const allowedTools = dedupe([
|
|
2998
2965
|
cabaneGlob,
|
|
2999
2966
|
...extraServerGlobs,
|
|
3000
|
-
...policy.web ? DEFAULT_WEB_TOOLS : []
|
|
3001
|
-
...customAllowed
|
|
2967
|
+
...policy.web ? DEFAULT_WEB_TOOLS : []
|
|
3002
2968
|
]);
|
|
3003
|
-
const disallowedTools = dedupe([...disallowedToolsFor(policy)
|
|
2969
|
+
const disallowedTools = dedupe([...disallowedToolsFor(policy)]);
|
|
3004
2970
|
const resumeDecision = decideResume(req.session, cwd);
|
|
3005
2971
|
const resume = "resume" in resumeDecision ? resumeDecision.resume : null;
|
|
3006
2972
|
const freshReason = "fresh" in resumeDecision ? resumeDecision.reason : void 0;
|
|
@@ -3026,7 +2992,7 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
3026
2992
|
...devControlsAutoMemory ? {} : { settings: { autoMemoryEnabled: false } },
|
|
3027
2993
|
mcpServers,
|
|
3028
2994
|
...cwd ? { cwd } : {},
|
|
3029
|
-
// Extra env (a
|
|
2995
|
+
// Extra env (a companion prepare hook's tokens/ports; the in-app's debug flags)
|
|
3030
2996
|
// merged OVER the inherited environment.
|
|
3031
2997
|
...req.local.env ? { env: { ...process.env, ...req.local.env } } : {},
|
|
3032
2998
|
...resume ? { resume } : {}
|
|
@@ -3039,7 +3005,7 @@ function buildClaudeCodeOptions(req, augment) {
|
|
|
3039
3005
|
settingSources: ["project"],
|
|
3040
3006
|
allowedTools,
|
|
3041
3007
|
disallowedTools,
|
|
3042
|
-
|
|
3008
|
+
hooks: { PreToolUse: [{ hooks: [allowEverythingHook] }] }
|
|
3043
3009
|
};
|
|
3044
3010
|
} else {
|
|
3045
3011
|
options = {
|
|
@@ -3070,9 +3036,11 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3070
3036
|
let resultReason;
|
|
3071
3037
|
let sawResult = false;
|
|
3072
3038
|
let usage;
|
|
3039
|
+
let lastRequestContextTokens;
|
|
3073
3040
|
let resolvedModel;
|
|
3074
3041
|
let sawRejectedLimit = false;
|
|
3075
3042
|
let rateLimitResetIso;
|
|
3043
|
+
let rateLimitType;
|
|
3076
3044
|
let authError;
|
|
3077
3045
|
let lastAssistantError;
|
|
3078
3046
|
try {
|
|
@@ -3095,6 +3063,8 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3095
3063
|
if (typeof assistantErr === "string" && assistantErr.length > 0) {
|
|
3096
3064
|
lastAssistantError = assistantErr;
|
|
3097
3065
|
}
|
|
3066
|
+
const reqContext = readRequestContextTokens(msg);
|
|
3067
|
+
if (reqContext !== void 0) lastRequestContextTokens = reqContext;
|
|
3098
3068
|
await processAssistantMessage(msg, emit, pending, buffer);
|
|
3099
3069
|
yield* drain(out);
|
|
3100
3070
|
} else if (msg.type === "user") {
|
|
@@ -3105,6 +3075,7 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3105
3075
|
if (info?.status === "rejected") {
|
|
3106
3076
|
sawRejectedLimit = true;
|
|
3107
3077
|
rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? rateLimitResetIso;
|
|
3078
|
+
if (typeof info.rateLimitType === "string") rateLimitType = info.rateLimitType;
|
|
3108
3079
|
}
|
|
3109
3080
|
} else if (msg.type === "auth_status") {
|
|
3110
3081
|
const err = msg.error;
|
|
@@ -3112,6 +3083,12 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3112
3083
|
} else if (msg.type === "result") {
|
|
3113
3084
|
sawResult = true;
|
|
3114
3085
|
usage = readSdkUsage(msg);
|
|
3086
|
+
if (usage) {
|
|
3087
|
+
if (lastRequestContextTokens !== void 0)
|
|
3088
|
+
usage.contextTokens = lastRequestContextTokens;
|
|
3089
|
+
const window = readContextWindow(msg, resolvedModel);
|
|
3090
|
+
if (window !== void 0) usage.contextWindow = window;
|
|
3091
|
+
}
|
|
3115
3092
|
const isError = msg.is_error === true;
|
|
3116
3093
|
if (msg.subtype === "success" && !isError) {
|
|
3117
3094
|
ok = true;
|
|
@@ -3122,7 +3099,8 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3122
3099
|
const errorText = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(
|
|
3123
3100
|
" "
|
|
3124
3101
|
);
|
|
3125
|
-
const
|
|
3102
|
+
const rejectedIsCap = sawRejectedLimit && (rateLimitResetIso !== void 0 || isSubscriptionWindow(rateLimitType));
|
|
3103
|
+
const failure = rejectedIsCap || terminalReason === "blocking_limit" ? {
|
|
3126
3104
|
kind: "usage_capped",
|
|
3127
3105
|
...rateLimitResetIso ? { resetsAt: rateLimitResetIso } : {}
|
|
3128
3106
|
} : classifyAssistantError(lastAssistantError) ?? classifyErrorText([authError, errorText].filter(Boolean).join(" "));
|
|
@@ -3152,6 +3130,9 @@ async function* decodeSdkStream(iter, ctx) {
|
|
|
3152
3130
|
...resolvedModel ? { resolvedModel } : {}
|
|
3153
3131
|
};
|
|
3154
3132
|
}
|
|
3133
|
+
function isSubscriptionWindow(value) {
|
|
3134
|
+
return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
|
|
3135
|
+
}
|
|
3155
3136
|
function* drain(out) {
|
|
3156
3137
|
while (out.length > 0) yield out.shift();
|
|
3157
3138
|
}
|
|
@@ -3165,6 +3146,27 @@ function readSdkUsage(msg) {
|
|
|
3165
3146
|
const outputTokens = num(usage.output_tokens);
|
|
3166
3147
|
return { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens };
|
|
3167
3148
|
}
|
|
3149
|
+
function readRequestContextTokens(msg) {
|
|
3150
|
+
const usage = msg.message?.usage;
|
|
3151
|
+
if (!usage || typeof usage !== "object") return void 0;
|
|
3152
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
3153
|
+
return num(usage.input_tokens) + num(usage.cache_read_input_tokens) + num(usage.cache_creation_input_tokens);
|
|
3154
|
+
}
|
|
3155
|
+
function readContextWindow(msg, resolvedModel) {
|
|
3156
|
+
const modelUsage = msg.modelUsage;
|
|
3157
|
+
if (!modelUsage || typeof modelUsage !== "object") return void 0;
|
|
3158
|
+
const pos = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
3159
|
+
if (resolvedModel) {
|
|
3160
|
+
const direct = pos(modelUsage[resolvedModel]?.contextWindow);
|
|
3161
|
+
if (direct !== void 0) return direct;
|
|
3162
|
+
}
|
|
3163
|
+
let max;
|
|
3164
|
+
for (const entry of Object.values(modelUsage)) {
|
|
3165
|
+
const w = pos(entry?.contextWindow);
|
|
3166
|
+
if (w !== void 0 && (max === void 0 || w > max)) max = w;
|
|
3167
|
+
}
|
|
3168
|
+
return max;
|
|
3169
|
+
}
|
|
3168
3170
|
|
|
3169
3171
|
// packages/agent-runtime/src/claude-code/prompt-input.ts
|
|
3170
3172
|
function buildQueryPrompt(req) {
|
|
@@ -3282,9 +3284,13 @@ var resultErrorFull = (subtype, extra = {}) => ({
|
|
|
3282
3284
|
session_id: "s",
|
|
3283
3285
|
...extra
|
|
3284
3286
|
});
|
|
3285
|
-
var rateLimitEvent = (status2, resetsAt) => ({
|
|
3287
|
+
var rateLimitEvent = (status2, resetsAt, rateLimitType) => ({
|
|
3286
3288
|
type: "rate_limit_event",
|
|
3287
|
-
rate_limit_info: {
|
|
3289
|
+
rate_limit_info: {
|
|
3290
|
+
status: status2,
|
|
3291
|
+
...resetsAt !== void 0 ? { resetsAt } : {},
|
|
3292
|
+
...rateLimitType !== void 0 ? { rateLimitType } : {}
|
|
3293
|
+
},
|
|
3288
3294
|
session_id: "s"
|
|
3289
3295
|
});
|
|
3290
3296
|
var authStatus = (error) => ({
|
|
@@ -3448,17 +3454,16 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
|
|
|
3448
3454
|
]
|
|
3449
3455
|
},
|
|
3450
3456
|
{
|
|
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.
|
|
3457
|
+
// CT558/CT592/CT731: a subscription cap. The SDK emits a rejected
|
|
3458
|
+
// `rate_limit_event` with a named subscription window; the terminal error result
|
|
3459
|
+
// then classifies as the structured `usage_capped` reason. Partial narration
|
|
3460
|
+
// lands as `progress`. The cap event is distinct from a provider 429 throttle.
|
|
3456
3461
|
name: "subscription cap \u2192 usage_capped",
|
|
3457
3462
|
request: makeRequest(),
|
|
3458
3463
|
nativeStream: [
|
|
3459
3464
|
init("s1"),
|
|
3460
3465
|
assistantText("Let me work on that."),
|
|
3461
|
-
rateLimitEvent("rejected"),
|
|
3466
|
+
rateLimitEvent("rejected", void 0, "five_hour"),
|
|
3462
3467
|
resultError("error_during_execution")
|
|
3463
3468
|
],
|
|
3464
3469
|
expected: [
|
|
@@ -3821,7 +3826,7 @@ function sealHeld(held, terminal) {
|
|
|
3821
3826
|
}
|
|
3822
3827
|
|
|
3823
3828
|
// packages/agent-runtime/src/opencode/policy.ts
|
|
3824
|
-
import { z as
|
|
3829
|
+
import { z as z10 } from "zod";
|
|
3825
3830
|
var OPENCODE_HOST_TOOLS = [
|
|
3826
3831
|
"bash",
|
|
3827
3832
|
"edit",
|
|
@@ -3848,8 +3853,8 @@ function opencodeToolPolicy(policy) {
|
|
|
3848
3853
|
deny(OPENCODE_UI_PROMPT_TOOLS);
|
|
3849
3854
|
return { tools, allowAllHostTools: policy.hostFs };
|
|
3850
3855
|
}
|
|
3851
|
-
var opencodeDialectSchema =
|
|
3852
|
-
agent:
|
|
3856
|
+
var opencodeDialectSchema = z10.object({
|
|
3857
|
+
agent: z10.string().min(1).optional()
|
|
3853
3858
|
}).loose();
|
|
3854
3859
|
function readOpencodeDialect(runtimeOptions) {
|
|
3855
3860
|
const parsed = opencodeDialectSchema.safeParse(runtimeOptions?.["opencode"] ?? {});
|
|
@@ -3865,6 +3870,7 @@ function parseOpencodeModel(model) {
|
|
|
3865
3870
|
|
|
3866
3871
|
// packages/agent-runtime/src/opencode/run-spec.ts
|
|
3867
3872
|
var CABANE_MCP_SERVER2 = "cabane";
|
|
3873
|
+
var TURN_CONTROL_MCP_SERVER = "cabane_companion";
|
|
3868
3874
|
var ACTIVE_CONVERSATION_HEADER3 = "x-cabane-active-conversation";
|
|
3869
3875
|
function buildRunSpec(req, resumeSessionId) {
|
|
3870
3876
|
const { policy, config } = req;
|
|
@@ -3926,6 +3932,17 @@ function buildMcp(req) {
|
|
|
3926
3932
|
},
|
|
3927
3933
|
enabled: true
|
|
3928
3934
|
};
|
|
3935
|
+
if (req.cabane.turnControlUrl) {
|
|
3936
|
+
mcp[TURN_CONTROL_MCP_SERVER] = {
|
|
3937
|
+
type: "remote",
|
|
3938
|
+
url: req.cabane.turnControlUrl,
|
|
3939
|
+
headers: {
|
|
3940
|
+
Authorization: `Bearer ${req.cabane.bearer}`,
|
|
3941
|
+
[ACTIVE_CONVERSATION_HEADER3]: req.cabane.activeConversationId
|
|
3942
|
+
},
|
|
3943
|
+
enabled: true
|
|
3944
|
+
};
|
|
3945
|
+
}
|
|
3929
3946
|
for (const [name, raw] of Object.entries(req.extra.mcpServers)) {
|
|
3930
3947
|
const server = raw;
|
|
3931
3948
|
if (typeof server.url === "string") {
|
|
@@ -4191,7 +4208,7 @@ var opencodeAdapter = createOpencodeAdapter();
|
|
|
4191
4208
|
// packages/agent-runtime/src/opencode/conformance.ts
|
|
4192
4209
|
var ABORT_SENTINEL2 = { __abortHere: true };
|
|
4193
4210
|
var NEW_SESSION_ID = "sess_new";
|
|
4194
|
-
var
|
|
4211
|
+
var COMPANION_POLICY = {
|
|
4195
4212
|
hostFs: false,
|
|
4196
4213
|
web: true,
|
|
4197
4214
|
browser: true,
|
|
@@ -4207,7 +4224,7 @@ function makeRequest2(overrides = {}) {
|
|
|
4207
4224
|
prompt: "hi there",
|
|
4208
4225
|
content: [{ type: "text", text: "hi there" }],
|
|
4209
4226
|
config: { model: "deepseek/deepseek-chat" },
|
|
4210
|
-
policy:
|
|
4227
|
+
policy: COMPANION_POLICY,
|
|
4211
4228
|
session: null,
|
|
4212
4229
|
cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
|
|
4213
4230
|
local: { cwd: DIR },
|
|
@@ -4555,13 +4572,19 @@ var CODEX_ADDENDUM = [
|
|
|
4555
4572
|
"as the turn\u2019s reply."
|
|
4556
4573
|
].join(" ");
|
|
4557
4574
|
var CODEX_ADDENDUM_CODE_MODE = [
|
|
4558
|
-
"Your
|
|
4559
|
-
"
|
|
4575
|
+
"Your Cabane workspace tool is exposed to Codex as the qualified MCP tool",
|
|
4576
|
+
"`mcp__cabane__sdk`. On Codex versions that defer MCP tools, locate it in the",
|
|
4577
|
+
"`functions.exec` deferred-tool inventory and invoke that exact qualified tool",
|
|
4578
|
+
"from the exec program; do not look for or call a bare top-level `sdk` tool.",
|
|
4579
|
+
"If discovery or an invocation fails, report the recorded tool error; never",
|
|
4580
|
+
"declare the SDK absent without attempting discovery and invocation. The SDK",
|
|
4581
|
+
"call runs a TypeScript program against the ambient `cabane` object. The",
|
|
4560
4582
|
"turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
|
|
4561
|
-
"
|
|
4583
|
+
"qualified `mcp__cabane_companion__\u2026` tools (and may be deferred too). There is",
|
|
4584
|
+
"no Cabane `read`/`write`/`search`/`edit` tool here \u2014 those",
|
|
4562
4585
|
"are `cabane` SDK calls inside your program, not tools. If a tool appears in this",
|
|
4563
|
-
"prompt with an `mcp__\u2026__` prefix, that
|
|
4564
|
-
"
|
|
4586
|
+
"prompt with an `mcp__\u2026__` prefix, preserve that qualified name. Write your",
|
|
4587
|
+
"closing reply as the last thing you say in the",
|
|
4565
4588
|
"turn: you can interleave narration with tool calls, but only your final message",
|
|
4566
4589
|
"is recorded as the turn\u2019s reply."
|
|
4567
4590
|
].join(" ");
|
|
@@ -4588,7 +4611,7 @@ function readItemMessage(item) {
|
|
|
4588
4611
|
function isModelMetadataError(message) {
|
|
4589
4612
|
return message.includes("Defaulting to fallback metadata");
|
|
4590
4613
|
}
|
|
4591
|
-
function readToolItem(item) {
|
|
4614
|
+
function readToolItem(item, eventType) {
|
|
4592
4615
|
const type = str(item.type);
|
|
4593
4616
|
const id = str(item.id);
|
|
4594
4617
|
if (!type || !id) return null;
|
|
@@ -4632,7 +4655,12 @@ function readToolItem(item) {
|
|
|
4632
4655
|
}
|
|
4633
4656
|
if (type === "web_search") {
|
|
4634
4657
|
const query = str(item.query) ?? "";
|
|
4635
|
-
return {
|
|
4658
|
+
return {
|
|
4659
|
+
id,
|
|
4660
|
+
name: "web_search",
|
|
4661
|
+
input: { query },
|
|
4662
|
+
status: eventType === "item.completed" ? "completed" : "in_progress"
|
|
4663
|
+
};
|
|
4636
4664
|
}
|
|
4637
4665
|
return null;
|
|
4638
4666
|
}
|
|
@@ -4750,7 +4778,7 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
4750
4778
|
if (text) yield { type: "thinking", text };
|
|
4751
4779
|
continue;
|
|
4752
4780
|
}
|
|
4753
|
-
const tool2 = readToolItem(item);
|
|
4781
|
+
const tool2 = readToolItem(item, ev.type);
|
|
4754
4782
|
if (!tool2) continue;
|
|
4755
4783
|
yield* flushInterim();
|
|
4756
4784
|
const name = prettyToolName(tool2.name);
|
|
@@ -4828,20 +4856,23 @@ function sealHeld2(held, terminal) {
|
|
|
4828
4856
|
}
|
|
4829
4857
|
|
|
4830
4858
|
// packages/agent-runtime/src/codex/policy.ts
|
|
4831
|
-
import { z as
|
|
4859
|
+
import { z as z11 } from "zod";
|
|
4832
4860
|
function codexToolPolicy(policy) {
|
|
4833
|
-
return {
|
|
4834
|
-
|
|
4861
|
+
return policy.hostFs ? {
|
|
4862
|
+
permissionProfile: "cabane-coding",
|
|
4863
|
+
approvalPolicy: "never",
|
|
4864
|
+
networkAccessEnabled: policy.web
|
|
4865
|
+
} : {
|
|
4866
|
+
sandboxMode: "read-only",
|
|
4835
4867
|
// Headless: the sandbox is the boundary; never pause for a human.
|
|
4836
4868
|
approvalPolicy: "never",
|
|
4837
|
-
//
|
|
4838
|
-
// Codex's shell commands may reach the network.
|
|
4869
|
+
// Retained in the policy value for symmetry; read-only ignores it.
|
|
4839
4870
|
networkAccessEnabled: policy.web
|
|
4840
4871
|
};
|
|
4841
4872
|
}
|
|
4842
4873
|
var CODEX_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"];
|
|
4843
|
-
var codexDialectSchema =
|
|
4844
|
-
modelReasoningEffort:
|
|
4874
|
+
var codexDialectSchema = z11.object({
|
|
4875
|
+
modelReasoningEffort: z11.enum(CODEX_REASONING_EFFORTS).optional()
|
|
4845
4876
|
}).loose();
|
|
4846
4877
|
function readCodexDialect(runtimeOptions) {
|
|
4847
4878
|
const parsed = codexDialectSchema.safeParse(runtimeOptions?.["codex"] ?? {});
|
|
@@ -4849,7 +4880,7 @@ function readCodexDialect(runtimeOptions) {
|
|
|
4849
4880
|
}
|
|
4850
4881
|
|
|
4851
4882
|
// packages/agent-runtime/src/codex/model.ts
|
|
4852
|
-
var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default"
|
|
4883
|
+
var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default"]);
|
|
4853
4884
|
function parseCodexModel(model) {
|
|
4854
4885
|
const sep = model.indexOf("/");
|
|
4855
4886
|
const bare = sep === -1 || model.slice(0, sep) !== "openai" ? model : model.slice(sep + 1);
|
|
@@ -4858,6 +4889,7 @@ function parseCodexModel(model) {
|
|
|
4858
4889
|
|
|
4859
4890
|
// packages/agent-runtime/src/codex/run-spec.ts
|
|
4860
4891
|
var CABANE_MCP_SERVER3 = "cabane";
|
|
4892
|
+
var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
|
|
4861
4893
|
var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
|
|
4862
4894
|
function buildRunSpec2(req, resumeThreadId) {
|
|
4863
4895
|
const { policy, config } = req;
|
|
@@ -4888,13 +4920,15 @@ function buildConfig(req) {
|
|
|
4888
4920
|
if ("url" in server) {
|
|
4889
4921
|
mcp_servers[name] = {
|
|
4890
4922
|
url: server.url,
|
|
4891
|
-
...server.headers ? { http_headers: server.headers } : {}
|
|
4923
|
+
...server.headers ? { http_headers: server.headers } : {},
|
|
4924
|
+
default_tools_approval_mode: "approve"
|
|
4892
4925
|
};
|
|
4893
4926
|
} else if ("command" in server) {
|
|
4894
4927
|
mcp_servers[name] = {
|
|
4895
4928
|
command: server.command,
|
|
4896
4929
|
...server.args ? { args: server.args } : {},
|
|
4897
|
-
...server.env ? { env: server.env } : {}
|
|
4930
|
+
...server.env ? { env: server.env } : {},
|
|
4931
|
+
default_tools_approval_mode: "approve"
|
|
4898
4932
|
};
|
|
4899
4933
|
}
|
|
4900
4934
|
}
|
|
@@ -4903,12 +4937,14 @@ function buildConfig(req) {
|
|
|
4903
4937
|
if (typeof server.url === "string") {
|
|
4904
4938
|
mcp_servers[name] = {
|
|
4905
4939
|
url: server.url,
|
|
4906
|
-
...isStringRecord2(server.headers) ? { http_headers: server.headers } : {}
|
|
4940
|
+
...isStringRecord2(server.headers) ? { http_headers: server.headers } : {},
|
|
4941
|
+
default_tools_approval_mode: "approve"
|
|
4907
4942
|
};
|
|
4908
4943
|
} else if (typeof server.command === "string") {
|
|
4909
4944
|
mcp_servers[name] = {
|
|
4910
4945
|
command: server.command,
|
|
4911
|
-
...Array.isArray(server.args) ? { args: server.args } : {}
|
|
4946
|
+
...Array.isArray(server.args) ? { args: server.args } : {},
|
|
4947
|
+
default_tools_approval_mode: "approve"
|
|
4912
4948
|
};
|
|
4913
4949
|
}
|
|
4914
4950
|
}
|
|
@@ -4920,13 +4956,48 @@ function buildConfig(req) {
|
|
|
4920
4956
|
},
|
|
4921
4957
|
default_tools_approval_mode: "approve"
|
|
4922
4958
|
};
|
|
4923
|
-
|
|
4959
|
+
if (req.cabane.turnControlUrl) {
|
|
4960
|
+
mcp_servers[TURN_CONTROL_MCP_SERVER2] = {
|
|
4961
|
+
url: req.cabane.turnControlUrl,
|
|
4962
|
+
http_headers: {
|
|
4963
|
+
Authorization: `Bearer ${req.cabane.bearer}`,
|
|
4964
|
+
[ACTIVE_CONVERSATION_HEADER4]: req.cabane.activeConversationId
|
|
4965
|
+
},
|
|
4966
|
+
default_tools_approval_mode: "approve"
|
|
4967
|
+
};
|
|
4968
|
+
}
|
|
4969
|
+
const policy = codexToolPolicy(req.policy);
|
|
4970
|
+
const tmpDir = req.local.env?.TMPDIR;
|
|
4971
|
+
return {
|
|
4972
|
+
mcp_servers,
|
|
4973
|
+
experimental_use_rmcp_client: true,
|
|
4974
|
+
...tmpDir ? { shell_environment_policy: { set: { TMPDIR: tmpDir } } } : {},
|
|
4975
|
+
...policy.permissionProfile ? {
|
|
4976
|
+
// CT733: named permission profiles are Codex's split-filesystem path.
|
|
4977
|
+
// `:root = read` preserves coding-mode host reads; the one explicit
|
|
4978
|
+
// workspace-root write grants the checkout, and the more-specific
|
|
4979
|
+
// `.git` write reopens the metadata Codex protects by default. Neither
|
|
4980
|
+
// rule grants an adjacent directory. Do not combine this with legacy `sandbox_mode` /
|
|
4981
|
+
// `sandbox_workspace_write`, which would restore the `.git` carve-out.
|
|
4982
|
+
approval_policy: policy.approvalPolicy,
|
|
4983
|
+
default_permissions: policy.permissionProfile,
|
|
4984
|
+
permissions: {
|
|
4985
|
+
[policy.permissionProfile]: {
|
|
4986
|
+
filesystem: {
|
|
4987
|
+
":root": "read",
|
|
4988
|
+
":workspace_roots": { ".": "write", ".git": "write" }
|
|
4989
|
+
},
|
|
4990
|
+
network: { enabled: policy.networkAccessEnabled, mode: "full" }
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
} : {}
|
|
4994
|
+
};
|
|
4924
4995
|
}
|
|
4925
4996
|
function isStringRecord2(v) {
|
|
4926
4997
|
return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
|
|
4927
4998
|
}
|
|
4928
4999
|
|
|
4929
|
-
// node_modules/.pnpm/@openai+codex-sdk@0.
|
|
5000
|
+
// node_modules/.pnpm/@openai+codex-sdk@0.146.0/node_modules/@openai/codex-sdk/dist/index.js
|
|
4930
5001
|
import { promises as fs } from "fs";
|
|
4931
5002
|
import os from "os";
|
|
4932
5003
|
import path from "path";
|
|
@@ -5015,6 +5086,8 @@ var Thread = class {
|
|
|
5015
5086
|
}
|
|
5016
5087
|
if (parsed.type === "thread.started") {
|
|
5017
5088
|
this._id = parsed.thread_id;
|
|
5089
|
+
} else if (parsed.type === "turn.completed") {
|
|
5090
|
+
parsed.usage.cache_write_input_tokens ??= 0;
|
|
5018
5091
|
}
|
|
5019
5092
|
yield parsed;
|
|
5020
5093
|
}
|
|
@@ -5454,6 +5527,17 @@ var Codex = class {
|
|
|
5454
5527
|
};
|
|
5455
5528
|
|
|
5456
5529
|
// packages/agent-runtime/src/codex/transport.ts
|
|
5530
|
+
function buildSdkThreadOptions(spec) {
|
|
5531
|
+
return {
|
|
5532
|
+
...spec.model ? { model: spec.model } : {},
|
|
5533
|
+
...spec.policy.sandboxMode ? { sandboxMode: spec.policy.sandboxMode } : {},
|
|
5534
|
+
workingDirectory: spec.directory,
|
|
5535
|
+
skipGitRepoCheck: spec.skipGitRepoCheck,
|
|
5536
|
+
...spec.policy.sandboxMode ? { approvalPolicy: spec.policy.approvalPolicy } : {},
|
|
5537
|
+
...spec.policy.sandboxMode === "workspace-write" ? { networkAccessEnabled: spec.policy.networkAccessEnabled } : {},
|
|
5538
|
+
...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
|
|
5539
|
+
};
|
|
5540
|
+
}
|
|
5457
5541
|
function createSdkCodexTransport(opts = {}) {
|
|
5458
5542
|
return {
|
|
5459
5543
|
async run(spec, signal) {
|
|
@@ -5467,17 +5551,7 @@ function createSdkCodexTransport(opts = {}) {
|
|
|
5467
5551
|
config: spec.config
|
|
5468
5552
|
};
|
|
5469
5553
|
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
|
-
};
|
|
5554
|
+
const threadOptions = buildSdkThreadOptions(spec);
|
|
5481
5555
|
const thread = spec.resumeThreadId ? codex.resumeThread(spec.resumeThreadId, threadOptions) : codex.startThread(threadOptions);
|
|
5482
5556
|
const streamed = await thread.runStreamed(spec.input, { signal });
|
|
5483
5557
|
return { events: streamed.events };
|
|
@@ -5534,7 +5608,7 @@ var codexAdapter = createCodexAdapter();
|
|
|
5534
5608
|
// packages/agent-runtime/src/codex/conformance.ts
|
|
5535
5609
|
var ABORT_SENTINEL3 = { __abortHere: true };
|
|
5536
5610
|
var NEW_THREAD_ID = "th_new";
|
|
5537
|
-
var
|
|
5611
|
+
var COMPANION_POLICY2 = {
|
|
5538
5612
|
hostFs: false,
|
|
5539
5613
|
web: true,
|
|
5540
5614
|
browser: true,
|
|
@@ -5555,7 +5629,7 @@ function makeRequest3(overrides = {}) {
|
|
|
5555
5629
|
// emitted → their expected results stay unchanged; a dedicated capture fixture
|
|
5556
5630
|
// sets a real model + effort.
|
|
5557
5631
|
config: { model: null },
|
|
5558
|
-
policy:
|
|
5632
|
+
policy: COMPANION_POLICY2,
|
|
5559
5633
|
session: null,
|
|
5560
5634
|
cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
|
|
5561
5635
|
local: { cwd: DIR2 },
|
|
@@ -5719,6 +5793,75 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
5719
5793
|
{ type: "result", ok: true }
|
|
5720
5794
|
]
|
|
5721
5795
|
},
|
|
5796
|
+
{
|
|
5797
|
+
// CT715: web_search LIFECYCLE. Unlike the other three tool kinds, a `web_search`
|
|
5798
|
+
// item carries NO `status` field — completion is signaled by the frame TYPE
|
|
5799
|
+
// (`item.started` → `item.completed`). The `start` fires off the first frame
|
|
5800
|
+
// (empty query, no output card); the `done` must resolve off `item.completed`
|
|
5801
|
+
// and carry the populated query the completed frame filled in. Before CT715 the
|
|
5802
|
+
// card was pinned at "running" forever (status derived from a missing field).
|
|
5803
|
+
name: "web_search lifecycle \u2014 completes off frame type, populated query on done",
|
|
5804
|
+
request: makeRequest3(),
|
|
5805
|
+
nativeStream: [
|
|
5806
|
+
threadStarted(NEW_THREAD_ID),
|
|
5807
|
+
toolFrame("item.started", { id: "ws1", type: "web_search", query: "" }),
|
|
5808
|
+
toolFrame("item.completed", { id: "ws1", type: "web_search", query: "best pizza in nyc" }),
|
|
5809
|
+
turnCompleted()
|
|
5810
|
+
],
|
|
5811
|
+
expected: [
|
|
5812
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
5813
|
+
{
|
|
5814
|
+
type: "tool",
|
|
5815
|
+
id: "ws1",
|
|
5816
|
+
name: "web_search",
|
|
5817
|
+
phase: "start",
|
|
5818
|
+
summary: "",
|
|
5819
|
+
input: { query: "" }
|
|
5820
|
+
},
|
|
5821
|
+
{
|
|
5822
|
+
type: "tool",
|
|
5823
|
+
id: "ws1",
|
|
5824
|
+
name: "web_search",
|
|
5825
|
+
phase: "done",
|
|
5826
|
+
summary: "best pizza in nyc",
|
|
5827
|
+
input: { query: "best pizza in nyc" }
|
|
5828
|
+
},
|
|
5829
|
+
{ type: "result", ok: true }
|
|
5830
|
+
]
|
|
5831
|
+
},
|
|
5832
|
+
{
|
|
5833
|
+
// CT715: a non-text web action (`action.type: "other"`) legitimately completes
|
|
5834
|
+
// with an EMPTY query — that's Codex's own data, not our bug. It must still
|
|
5835
|
+
// resolve to `done` (empty query acceptable; stuck-running is not).
|
|
5836
|
+
name: "web_search lifecycle \u2014 empty-query completion still resolves to done",
|
|
5837
|
+
request: makeRequest3(),
|
|
5838
|
+
nativeStream: [
|
|
5839
|
+
threadStarted(NEW_THREAD_ID),
|
|
5840
|
+
toolFrame("item.started", { id: "ws2", type: "web_search", query: "" }),
|
|
5841
|
+
toolFrame("item.completed", { id: "ws2", type: "web_search", query: "" }),
|
|
5842
|
+
turnCompleted()
|
|
5843
|
+
],
|
|
5844
|
+
expected: [
|
|
5845
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
5846
|
+
{
|
|
5847
|
+
type: "tool",
|
|
5848
|
+
id: "ws2",
|
|
5849
|
+
name: "web_search",
|
|
5850
|
+
phase: "start",
|
|
5851
|
+
summary: "",
|
|
5852
|
+
input: { query: "" }
|
|
5853
|
+
},
|
|
5854
|
+
{
|
|
5855
|
+
type: "tool",
|
|
5856
|
+
id: "ws2",
|
|
5857
|
+
name: "web_search",
|
|
5858
|
+
phase: "done",
|
|
5859
|
+
summary: "",
|
|
5860
|
+
input: { query: "" }
|
|
5861
|
+
},
|
|
5862
|
+
{ type: "result", ok: true }
|
|
5863
|
+
]
|
|
5864
|
+
},
|
|
5722
5865
|
{
|
|
5723
5866
|
// HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
|
|
5724
5867
|
// adapter stops before the closing reply — no final text, no `result` event.
|
|
@@ -6010,560 +6153,6 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
6010
6153
|
}
|
|
6011
6154
|
];
|
|
6012
6155
|
|
|
6013
|
-
// packages/agent-runtime/src/cabane-native/addendum.ts
|
|
6014
|
-
var CABANE_NATIVE_ADDENDUM = `## Your tools (native runtime)
|
|
6015
|
-
|
|
6016
|
-
You are running on Cabane's own agent runtime. You have a small, fixed set of workspace tools, all prefixed \`cabane_\`:
|
|
6017
|
-
|
|
6018
|
-
- \`cabane_list\` \u2014 list a folder's files and subfolders.
|
|
6019
|
-
- \`cabane_read\` \u2014 read one file's contents by path.
|
|
6020
|
-
- \`cabane_search\` \u2014 substring search across file names and contents.
|
|
6021
|
-
- \`cabane_write\` \u2014 create or overwrite a file (pass \`overwrite: true\` to replace).
|
|
6022
|
-
- \`cabane_edit\` \u2014 find/replace inside an existing file.
|
|
6023
|
-
|
|
6024
|
-
Paths are workspace-relative with a leading slash (\`/notes/todo.md\`). This is a deliberately small interim tool set \u2014 if a job needs an operation that isn't here, say so plainly in your reply rather than inventing a tool. Your reply text is streamed straight into the conversation; there is no separate send step.`;
|
|
6025
|
-
|
|
6026
|
-
// packages/agent-runtime/src/cabane-native/context.ts
|
|
6027
|
-
var DEFAULT_HISTORY_LIMIT = 20;
|
|
6028
|
-
var DEFAULT_MAX_HISTORY_CHARS = 24e3;
|
|
6029
|
-
async function assembleMessages(systemPrompt, content, fallbackPrompt, opts) {
|
|
6030
|
-
const messages = [{ role: "system", content: systemPrompt }];
|
|
6031
|
-
const history = await fetchRecentHistory(opts);
|
|
6032
|
-
for (const m of history) messages.push(m);
|
|
6033
|
-
messages.push({ role: "user", content: currentUserText(content, fallbackPrompt) });
|
|
6034
|
-
return messages;
|
|
6035
|
-
}
|
|
6036
|
-
function currentUserText(content, fallbackPrompt) {
|
|
6037
|
-
const text = content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
|
|
6038
|
-
return text.length > 0 ? text : fallbackPrompt;
|
|
6039
|
-
}
|
|
6040
|
-
async function fetchRecentHistory(opts) {
|
|
6041
|
-
const doFetch = opts.fetchImpl ?? fetch;
|
|
6042
|
-
const limit = opts.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
6043
|
-
const url = new URL(
|
|
6044
|
-
`${opts.apiRoot}/workspaces/${opts.workspaceId}/conversations/${opts.conversationId}/messages`
|
|
6045
|
-
);
|
|
6046
|
-
url.searchParams.set("limit", String(limit));
|
|
6047
|
-
url.searchParams.set("order", "desc");
|
|
6048
|
-
let rows;
|
|
6049
|
-
try {
|
|
6050
|
-
const res = await doFetch(url.toString(), {
|
|
6051
|
-
headers: { Authorization: `Bearer ${opts.bearer}` }
|
|
6052
|
-
});
|
|
6053
|
-
if (!res.ok) return [];
|
|
6054
|
-
const body = await res.json();
|
|
6055
|
-
rows = body.messages ?? [];
|
|
6056
|
-
} catch {
|
|
6057
|
-
return [];
|
|
6058
|
-
}
|
|
6059
|
-
const chronological = [...rows].reverse();
|
|
6060
|
-
while (chronological.length > 0 && chronological[chronological.length - 1].role === "user") {
|
|
6061
|
-
chronological.pop();
|
|
6062
|
-
}
|
|
6063
|
-
const mapped = [];
|
|
6064
|
-
for (const r of chronological) {
|
|
6065
|
-
const role = r.role === "agent" ? "assistant" : r.role === "user" ? "user" : null;
|
|
6066
|
-
if (!role) continue;
|
|
6067
|
-
const body = (r.body ?? "").trim();
|
|
6068
|
-
if (body.length === 0) continue;
|
|
6069
|
-
mapped.push({ role, content: body });
|
|
6070
|
-
}
|
|
6071
|
-
return capHistory(mapped, opts.maxHistoryChars ?? DEFAULT_MAX_HISTORY_CHARS);
|
|
6072
|
-
}
|
|
6073
|
-
function capHistory(messages, maxChars) {
|
|
6074
|
-
let total = messages.reduce((n, m) => n + m.content.length, 0);
|
|
6075
|
-
let start2 = 0;
|
|
6076
|
-
while (total > maxChars && start2 < messages.length) {
|
|
6077
|
-
total -= messages[start2].content.length;
|
|
6078
|
-
start2 += 1;
|
|
6079
|
-
}
|
|
6080
|
-
return messages.slice(start2);
|
|
6081
|
-
}
|
|
6082
|
-
|
|
6083
|
-
// packages/agent-runtime/src/cabane-native/model.ts
|
|
6084
|
-
var CABANE_NATIVE_MODEL_PREFIX = "cabane-native/";
|
|
6085
|
-
function parseCabaneNativeModel(model) {
|
|
6086
|
-
return model.startsWith(CABANE_NATIVE_MODEL_PREFIX) ? model.slice(CABANE_NATIVE_MODEL_PREFIX.length) : model;
|
|
6087
|
-
}
|
|
6088
|
-
|
|
6089
|
-
// packages/agent-runtime/src/cabane-native/tools.ts
|
|
6090
|
-
var TOOL_RESULT_MAX_CHARS = 8e3;
|
|
6091
|
-
var CABANE_NATIVE_TOOLS = [
|
|
6092
|
-
{
|
|
6093
|
-
type: "function",
|
|
6094
|
-
function: {
|
|
6095
|
-
name: "cabane_list",
|
|
6096
|
-
description: "List the files and subfolders at a workspace folder path. Omit `path` for the root.",
|
|
6097
|
-
parameters: {
|
|
6098
|
-
type: "object",
|
|
6099
|
-
properties: {
|
|
6100
|
-
path: {
|
|
6101
|
-
type: "string",
|
|
6102
|
-
description: "Workspace folder path, e.g. /notes. Defaults to /."
|
|
6103
|
-
}
|
|
6104
|
-
}
|
|
6105
|
-
}
|
|
6106
|
-
}
|
|
6107
|
-
},
|
|
6108
|
-
{
|
|
6109
|
-
type: "function",
|
|
6110
|
-
function: {
|
|
6111
|
-
name: "cabane_read",
|
|
6112
|
-
description: "Read the contents of one file at a workspace path.",
|
|
6113
|
-
parameters: {
|
|
6114
|
-
type: "object",
|
|
6115
|
-
properties: {
|
|
6116
|
-
path: { type: "string", description: "Workspace file path, e.g. /notes/todo.md." }
|
|
6117
|
-
},
|
|
6118
|
-
required: ["path"]
|
|
6119
|
-
}
|
|
6120
|
-
}
|
|
6121
|
-
},
|
|
6122
|
-
{
|
|
6123
|
-
type: "function",
|
|
6124
|
-
function: {
|
|
6125
|
-
name: "cabane_search",
|
|
6126
|
-
description: "Case-insensitive substring search across file names and file contents. Optionally scope to a subtree with `path`.",
|
|
6127
|
-
parameters: {
|
|
6128
|
-
type: "object",
|
|
6129
|
-
properties: {
|
|
6130
|
-
q: { type: "string", description: "The search string." },
|
|
6131
|
-
path: {
|
|
6132
|
-
type: "string",
|
|
6133
|
-
description: "Optional workspace subtree to scope the search to."
|
|
6134
|
-
}
|
|
6135
|
-
},
|
|
6136
|
-
required: ["q"]
|
|
6137
|
-
}
|
|
6138
|
-
}
|
|
6139
|
-
},
|
|
6140
|
-
{
|
|
6141
|
-
type: "function",
|
|
6142
|
-
function: {
|
|
6143
|
-
name: "cabane_write",
|
|
6144
|
-
description: "Create a file at a workspace path (missing parent folders are created). Pass `overwrite: true` to replace an existing file instead of failing on a name conflict.",
|
|
6145
|
-
parameters: {
|
|
6146
|
-
type: "object",
|
|
6147
|
-
properties: {
|
|
6148
|
-
path: { type: "string", description: "Workspace file path, e.g. /notes/new.md." },
|
|
6149
|
-
content: { type: "string", description: "The file contents." },
|
|
6150
|
-
overwrite: { type: "boolean", description: "Replace an existing file (default false)." }
|
|
6151
|
-
},
|
|
6152
|
-
required: ["path", "content"]
|
|
6153
|
-
}
|
|
6154
|
-
}
|
|
6155
|
-
},
|
|
6156
|
-
{
|
|
6157
|
-
type: "function",
|
|
6158
|
-
function: {
|
|
6159
|
-
name: "cabane_edit",
|
|
6160
|
-
description: "Modify an existing file with a single find/replace. By default `find` must occur exactly once; set `replaceAll: true` to replace every occurrence.",
|
|
6161
|
-
parameters: {
|
|
6162
|
-
type: "object",
|
|
6163
|
-
properties: {
|
|
6164
|
-
path: { type: "string", description: "Workspace file path to edit." },
|
|
6165
|
-
find: { type: "string", description: "The substring to find." },
|
|
6166
|
-
replace: { type: "string", description: "The replacement." },
|
|
6167
|
-
replaceAll: { type: "boolean", description: "Replace every occurrence (default false)." }
|
|
6168
|
-
},
|
|
6169
|
-
required: ["path", "find", "replace"]
|
|
6170
|
-
}
|
|
6171
|
-
}
|
|
6172
|
-
}
|
|
6173
|
-
];
|
|
6174
|
-
function summarizeCabaneToolArgs(name, args) {
|
|
6175
|
-
if (name === "cabane_search") return typeof args.q === "string" ? args.q : "";
|
|
6176
|
-
return typeof args.path === "string" ? args.path : "";
|
|
6177
|
-
}
|
|
6178
|
-
async function executeCabaneNativeTool(name, args, ctx, signal) {
|
|
6179
|
-
const doFetch = ctx.fetchImpl ?? fetch;
|
|
6180
|
-
const wsBase = `${ctx.apiRoot}/workspaces/${ctx.workspaceId}`;
|
|
6181
|
-
const headers = { Authorization: `Bearer ${ctx.bearer}`, "Content-Type": "application/json" };
|
|
6182
|
-
const call = async (method, path3, init2) => {
|
|
6183
|
-
const url = new URL(`${wsBase}${path3}`);
|
|
6184
|
-
for (const [k, v] of Object.entries(init2?.query ?? {})) {
|
|
6185
|
-
if (v !== void 0) url.searchParams.set(k, v);
|
|
6186
|
-
}
|
|
6187
|
-
let res;
|
|
6188
|
-
try {
|
|
6189
|
-
res = await doFetch(url.toString(), {
|
|
6190
|
-
method,
|
|
6191
|
-
headers,
|
|
6192
|
-
...init2?.body !== void 0 ? { body: JSON.stringify(init2.body) } : {},
|
|
6193
|
-
signal
|
|
6194
|
-
});
|
|
6195
|
-
} catch (err) {
|
|
6196
|
-
return {
|
|
6197
|
-
ok: false,
|
|
6198
|
-
result: `error: request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
6199
|
-
};
|
|
6200
|
-
}
|
|
6201
|
-
const contentType = res.headers.get("content-type") ?? "";
|
|
6202
|
-
const text = contentType.includes("application/json") ? JSON.stringify(await res.json().catch(() => ({}))) : await res.text().catch(() => "");
|
|
6203
|
-
if (!res.ok) return { ok: false, result: truncate2(`error ${res.status}: ${text}`) };
|
|
6204
|
-
return { ok: true, result: truncate2(text) };
|
|
6205
|
-
};
|
|
6206
|
-
switch (name) {
|
|
6207
|
-
case "cabane_list":
|
|
6208
|
-
return call("GET", "/files/tree", { query: { path: str2(args.path) ?? "/" } });
|
|
6209
|
-
case "cabane_read":
|
|
6210
|
-
return call("GET", "/files/content", { query: { path: str2(args.path) } });
|
|
6211
|
-
case "cabane_search":
|
|
6212
|
-
return call("GET", "/search", { query: { q: str2(args.q), path: str2(args.path) } });
|
|
6213
|
-
case "cabane_write": {
|
|
6214
|
-
const overwrite = args.overwrite === true;
|
|
6215
|
-
return overwrite ? call("PUT", "/files/content", {
|
|
6216
|
-
body: { path: str2(args.path), content: str2(args.content) }
|
|
6217
|
-
}) : call("POST", "/files", {
|
|
6218
|
-
body: { path: str2(args.path), content: str2(args.content), mkdirs: true }
|
|
6219
|
-
});
|
|
6220
|
-
}
|
|
6221
|
-
case "cabane_edit":
|
|
6222
|
-
return call("PATCH", "/files", {
|
|
6223
|
-
body: {
|
|
6224
|
-
path: str2(args.path),
|
|
6225
|
-
find: str2(args.find),
|
|
6226
|
-
replace: str2(args.replace) ?? "",
|
|
6227
|
-
...args.replaceAll === true ? { replaceAll: true } : {}
|
|
6228
|
-
}
|
|
6229
|
-
});
|
|
6230
|
-
default:
|
|
6231
|
-
return { ok: false, result: `error: unknown tool "${name}"` };
|
|
6232
|
-
}
|
|
6233
|
-
}
|
|
6234
|
-
function str2(v) {
|
|
6235
|
-
return typeof v === "string" ? v : void 0;
|
|
6236
|
-
}
|
|
6237
|
-
function truncate2(s) {
|
|
6238
|
-
return s.length > TOOL_RESULT_MAX_CHARS ? `${s.slice(0, TOOL_RESULT_MAX_CHARS)}
|
|
6239
|
-
\u2026 [truncated]` : s;
|
|
6240
|
-
}
|
|
6241
|
-
|
|
6242
|
-
// packages/agent-runtime/src/cabane-native/loop.ts
|
|
6243
|
-
var DEFAULT_MAX_ITERATIONS = 12;
|
|
6244
|
-
async function* runCabaneNativeTurn(req, signal, deps) {
|
|
6245
|
-
if (!req.config.model) {
|
|
6246
|
-
yield { type: "result", ok: false, reason: "no_model" };
|
|
6247
|
-
return;
|
|
6248
|
-
}
|
|
6249
|
-
const model = parseCabaneNativeModel(req.config.model);
|
|
6250
|
-
const toolCtx = {
|
|
6251
|
-
apiRoot: deps.apiRoot,
|
|
6252
|
-
workspaceId: deps.workspaceId,
|
|
6253
|
-
bearer: deps.bearer,
|
|
6254
|
-
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
|
|
6255
|
-
};
|
|
6256
|
-
const messages = await assembleMessages(req.systemPrompt, req.content, req.prompt, {
|
|
6257
|
-
apiRoot: deps.apiRoot,
|
|
6258
|
-
workspaceId: deps.workspaceId,
|
|
6259
|
-
bearer: deps.bearer,
|
|
6260
|
-
conversationId: deps.conversationId,
|
|
6261
|
-
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {},
|
|
6262
|
-
...deps.historyLimit !== void 0 ? { historyLimit: deps.historyLimit } : {}
|
|
6263
|
-
});
|
|
6264
|
-
if (signal.aborted) return;
|
|
6265
|
-
const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
|
6266
|
-
let usage;
|
|
6267
|
-
let resolvedModel = model;
|
|
6268
|
-
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
6269
|
-
let text = "";
|
|
6270
|
-
const toolAcc = /* @__PURE__ */ new Map();
|
|
6271
|
-
let finishReason;
|
|
6272
|
-
let errored2;
|
|
6273
|
-
for await (const ev of deps.provider.stream(
|
|
6274
|
-
{ model, messages, tools: CABANE_NATIVE_TOOLS },
|
|
6275
|
-
signal
|
|
6276
|
-
)) {
|
|
6277
|
-
if (signal.aborted) return;
|
|
6278
|
-
switch (ev.type) {
|
|
6279
|
-
case "text":
|
|
6280
|
-
text += ev.delta;
|
|
6281
|
-
break;
|
|
6282
|
-
case "tool_call": {
|
|
6283
|
-
const cur = toolAcc.get(ev.index) ?? { id: `call_${ev.index}`, name: "", args: "" };
|
|
6284
|
-
if (ev.id) cur.id = ev.id;
|
|
6285
|
-
if (ev.name) cur.name = ev.name;
|
|
6286
|
-
if (ev.argumentsDelta) cur.args += ev.argumentsDelta;
|
|
6287
|
-
toolAcc.set(ev.index, cur);
|
|
6288
|
-
break;
|
|
6289
|
-
}
|
|
6290
|
-
case "usage":
|
|
6291
|
-
usage = { inputTokens: ev.inputTokens, outputTokens: ev.outputTokens };
|
|
6292
|
-
break;
|
|
6293
|
-
case "model":
|
|
6294
|
-
resolvedModel = ev.model;
|
|
6295
|
-
break;
|
|
6296
|
-
case "error":
|
|
6297
|
-
errored2 = ev.message;
|
|
6298
|
-
break;
|
|
6299
|
-
case "done":
|
|
6300
|
-
finishReason = ev.finishReason;
|
|
6301
|
-
break;
|
|
6302
|
-
}
|
|
6303
|
-
}
|
|
6304
|
-
if (signal.aborted) return;
|
|
6305
|
-
if (errored2 !== void 0) {
|
|
6306
|
-
const sealed = sealText(text, false);
|
|
6307
|
-
if (sealed) yield sealed;
|
|
6308
|
-
const failure = classifyErrorText(errored2);
|
|
6309
|
-
yield {
|
|
6310
|
-
type: "result",
|
|
6311
|
-
ok: false,
|
|
6312
|
-
reason: failure ? encodeFailureReason(failure) : `error:${errored2.slice(0, 200)}`,
|
|
6313
|
-
...usage ? { usage } : {},
|
|
6314
|
-
...resolvedModel ? { resolvedModel } : {}
|
|
6315
|
-
};
|
|
6316
|
-
return;
|
|
6317
|
-
}
|
|
6318
|
-
const toolCalls = [...toolAcc.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
|
|
6319
|
-
if (toolCalls.length === 0) {
|
|
6320
|
-
const sealed = sealText(text, true);
|
|
6321
|
-
if (sealed) yield sealed;
|
|
6322
|
-
yield {
|
|
6323
|
-
type: "result",
|
|
6324
|
-
ok: true,
|
|
6325
|
-
...usage ? { usage } : {},
|
|
6326
|
-
...resolvedModel ? { resolvedModel } : {}
|
|
6327
|
-
};
|
|
6328
|
-
return;
|
|
6329
|
-
}
|
|
6330
|
-
const sealedInterim = sealText(text, false);
|
|
6331
|
-
if (sealedInterim) yield sealedInterim;
|
|
6332
|
-
const assistantToolCalls = toolCalls.map((t) => ({
|
|
6333
|
-
id: t.id,
|
|
6334
|
-
type: "function",
|
|
6335
|
-
function: { name: t.name, arguments: t.args || "{}" }
|
|
6336
|
-
}));
|
|
6337
|
-
messages.push({ role: "assistant", content: text, tool_calls: assistantToolCalls });
|
|
6338
|
-
for (const t of toolCalls) {
|
|
6339
|
-
if (signal.aborted) return;
|
|
6340
|
-
const args = parseArgs(t.args);
|
|
6341
|
-
const displayName = prettyToolName(t.name);
|
|
6342
|
-
const summary = summarizeCabaneToolArgs(t.name, args);
|
|
6343
|
-
yield { type: "tool", id: t.id, name: displayName, phase: "start", summary, input: args };
|
|
6344
|
-
const result = await executeCabaneNativeTool(t.name, args, toolCtx, signal);
|
|
6345
|
-
if (signal.aborted) return;
|
|
6346
|
-
yield {
|
|
6347
|
-
type: "tool",
|
|
6348
|
-
id: t.id,
|
|
6349
|
-
name: displayName,
|
|
6350
|
-
phase: result.ok ? "done" : "error",
|
|
6351
|
-
summary,
|
|
6352
|
-
input: args,
|
|
6353
|
-
result: result.result
|
|
6354
|
-
};
|
|
6355
|
-
messages.push({ role: "tool", tool_call_id: t.id, content: result.result });
|
|
6356
|
-
}
|
|
6357
|
-
}
|
|
6358
|
-
deps.onWarn?.("cabane-native: turn hit the tool-iteration cap; force-settling", {
|
|
6359
|
-
maxIterations
|
|
6360
|
-
});
|
|
6361
|
-
yield {
|
|
6362
|
-
type: "text",
|
|
6363
|
-
body: `(Stopped after ${maxIterations} tool steps without a final answer.)`,
|
|
6364
|
-
terminal: true
|
|
6365
|
-
};
|
|
6366
|
-
yield {
|
|
6367
|
-
type: "result",
|
|
6368
|
-
ok: true,
|
|
6369
|
-
...usage ? { usage } : {},
|
|
6370
|
-
...resolvedModel ? { resolvedModel } : {}
|
|
6371
|
-
};
|
|
6372
|
-
}
|
|
6373
|
-
function sealText(text, terminal) {
|
|
6374
|
-
const body = text.trim();
|
|
6375
|
-
if (body.length === 0) return null;
|
|
6376
|
-
return { type: "text", body, terminal };
|
|
6377
|
-
}
|
|
6378
|
-
function parseArgs(raw) {
|
|
6379
|
-
if (!raw.trim()) return {};
|
|
6380
|
-
try {
|
|
6381
|
-
const parsed = JSON.parse(raw);
|
|
6382
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
6383
|
-
} catch {
|
|
6384
|
-
return {};
|
|
6385
|
-
}
|
|
6386
|
-
}
|
|
6387
|
-
|
|
6388
|
-
// packages/agent-runtime/src/cabane-native/policy.ts
|
|
6389
|
-
import { z as z13 } from "zod";
|
|
6390
|
-
var cabaneNativeDialectSchema = z13.object({}).loose();
|
|
6391
|
-
|
|
6392
|
-
// packages/agent-runtime/src/cabane-native/provider.ts
|
|
6393
|
-
var DEFAULT_OPENROUTER_BASE = "https://openrouter.ai/api/v1";
|
|
6394
|
-
function createOpenRouterProvider(opts) {
|
|
6395
|
-
const base = (opts.baseUrl ?? DEFAULT_OPENROUTER_BASE).replace(/\/$/, "");
|
|
6396
|
-
const doFetch = opts.fetchImpl ?? fetch;
|
|
6397
|
-
return {
|
|
6398
|
-
async *stream(req, signal) {
|
|
6399
|
-
let res;
|
|
6400
|
-
try {
|
|
6401
|
-
res = await doFetch(`${base}/chat/completions`, {
|
|
6402
|
-
method: "POST",
|
|
6403
|
-
headers: {
|
|
6404
|
-
Authorization: `Bearer ${opts.apiKey}`,
|
|
6405
|
-
"Content-Type": "application/json",
|
|
6406
|
-
// OpenRouter attribution headers (optional, but polite + used for
|
|
6407
|
-
// routing/analytics on their side).
|
|
6408
|
-
"HTTP-Referer": "https://cabane.ai",
|
|
6409
|
-
"X-Title": "Cabane"
|
|
6410
|
-
},
|
|
6411
|
-
body: JSON.stringify({
|
|
6412
|
-
model: req.model,
|
|
6413
|
-
messages: req.messages,
|
|
6414
|
-
...req.tools.length > 0 ? { tools: req.tools } : {},
|
|
6415
|
-
stream: true,
|
|
6416
|
-
// Ask OpenRouter to append a trailing usage chunk to the stream.
|
|
6417
|
-
stream_options: { include_usage: true }
|
|
6418
|
-
}),
|
|
6419
|
-
signal
|
|
6420
|
-
});
|
|
6421
|
-
} catch (err) {
|
|
6422
|
-
if (signal.aborted) return;
|
|
6423
|
-
yield { type: "error", message: `request failed: ${errText(err)}` };
|
|
6424
|
-
return;
|
|
6425
|
-
}
|
|
6426
|
-
if (!res.ok || !res.body) {
|
|
6427
|
-
const bodyText = await res.text().catch(() => "");
|
|
6428
|
-
yield { type: "error", message: providerErrorMessage(res.status, bodyText) };
|
|
6429
|
-
return;
|
|
6430
|
-
}
|
|
6431
|
-
const decoder = new TextDecoder();
|
|
6432
|
-
const reader = res.body.getReader();
|
|
6433
|
-
let buffer = "";
|
|
6434
|
-
let finishReason;
|
|
6435
|
-
let modelEmitted = false;
|
|
6436
|
-
try {
|
|
6437
|
-
for (; ; ) {
|
|
6438
|
-
if (signal.aborted) return;
|
|
6439
|
-
const { value, done } = await reader.read();
|
|
6440
|
-
if (done) break;
|
|
6441
|
-
buffer += decoder.decode(value, { stream: true });
|
|
6442
|
-
let nl;
|
|
6443
|
-
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
6444
|
-
const line = buffer.slice(0, nl).trim();
|
|
6445
|
-
buffer = buffer.slice(nl + 1);
|
|
6446
|
-
if (!line || line.startsWith(":")) continue;
|
|
6447
|
-
if (!line.startsWith("data:")) continue;
|
|
6448
|
-
const data = line.slice("data:".length).trim();
|
|
6449
|
-
if (data === "[DONE]") {
|
|
6450
|
-
yield { type: "done", ...finishReason ? { finishReason } : {} };
|
|
6451
|
-
return;
|
|
6452
|
-
}
|
|
6453
|
-
let chunk;
|
|
6454
|
-
try {
|
|
6455
|
-
chunk = JSON.parse(data);
|
|
6456
|
-
} catch {
|
|
6457
|
-
continue;
|
|
6458
|
-
}
|
|
6459
|
-
if (chunk.error) {
|
|
6460
|
-
yield { type: "error", message: chunk.error.message ?? "provider error" };
|
|
6461
|
-
return;
|
|
6462
|
-
}
|
|
6463
|
-
if (!modelEmitted && chunk.model) {
|
|
6464
|
-
modelEmitted = true;
|
|
6465
|
-
yield { type: "model", model: chunk.model };
|
|
6466
|
-
}
|
|
6467
|
-
const choice = chunk.choices?.[0];
|
|
6468
|
-
if (choice) {
|
|
6469
|
-
const delta = choice.delta;
|
|
6470
|
-
if (delta?.content) yield { type: "text", delta: delta.content };
|
|
6471
|
-
if (delta?.tool_calls) {
|
|
6472
|
-
for (const tc of delta.tool_calls) {
|
|
6473
|
-
yield {
|
|
6474
|
-
type: "tool_call",
|
|
6475
|
-
index: tc.index,
|
|
6476
|
-
...tc.id ? { id: tc.id } : {},
|
|
6477
|
-
...tc.function?.name ? { name: tc.function.name } : {},
|
|
6478
|
-
...tc.function?.arguments !== void 0 ? { argumentsDelta: tc.function.arguments } : {}
|
|
6479
|
-
};
|
|
6480
|
-
}
|
|
6481
|
-
}
|
|
6482
|
-
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
6483
|
-
}
|
|
6484
|
-
if (chunk.usage) {
|
|
6485
|
-
yield {
|
|
6486
|
-
type: "usage",
|
|
6487
|
-
inputTokens: chunk.usage.prompt_tokens ?? 0,
|
|
6488
|
-
outputTokens: chunk.usage.completion_tokens ?? 0
|
|
6489
|
-
};
|
|
6490
|
-
}
|
|
6491
|
-
}
|
|
6492
|
-
}
|
|
6493
|
-
} catch (err) {
|
|
6494
|
-
if (signal.aborted) return;
|
|
6495
|
-
yield { type: "error", message: `stream read failed: ${errText(err)}` };
|
|
6496
|
-
return;
|
|
6497
|
-
}
|
|
6498
|
-
yield { type: "done", ...finishReason ? { finishReason } : {} };
|
|
6499
|
-
}
|
|
6500
|
-
};
|
|
6501
|
-
}
|
|
6502
|
-
function providerErrorMessage(status2, body) {
|
|
6503
|
-
let detail = body.slice(0, 300);
|
|
6504
|
-
try {
|
|
6505
|
-
const parsed = JSON.parse(body);
|
|
6506
|
-
if (parsed.error?.message) detail = parsed.error.message;
|
|
6507
|
-
} catch {
|
|
6508
|
-
}
|
|
6509
|
-
return `HTTP ${status2}: ${detail}`;
|
|
6510
|
-
}
|
|
6511
|
-
function errText(err) {
|
|
6512
|
-
return err instanceof Error ? err.message : String(err);
|
|
6513
|
-
}
|
|
6514
|
-
|
|
6515
|
-
// packages/agent-runtime/src/cabane-native/index.ts
|
|
6516
|
-
var CABANE_NATIVE_RUNTIME_NAME = "cabane-native";
|
|
6517
|
-
function createCabaneNativeAdapter(deps = {}) {
|
|
6518
|
-
const provider = deps.provider ?? (deps.apiKey ? createOpenRouterProvider({
|
|
6519
|
-
apiKey: deps.apiKey,
|
|
6520
|
-
...deps.baseUrl ? { baseUrl: deps.baseUrl } : {},
|
|
6521
|
-
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
|
|
6522
|
-
}) : null);
|
|
6523
|
-
return {
|
|
6524
|
-
name: CABANE_NATIVE_RUNTIME_NAME,
|
|
6525
|
-
// CT614: the native runtime carries its OWN `cabane_*` tool surface (not the
|
|
6526
|
-
// `cabane` MCP server), so the CT609 code-mode lever — which filters the MCP
|
|
6527
|
-
// server down to `code` — never touches what's mounted here. The addendum is
|
|
6528
|
-
// therefore flag-INDEPENDENT: it always teaches the `cabane_*` names that are
|
|
6529
|
-
// actually mounted (the `codeMode` arg is accepted-and-ignored). Native's
|
|
6530
|
-
// adoption of `code` as its primary surface is a separate, later track.
|
|
6531
|
-
promptAddendum: () => CABANE_NATIVE_ADDENDUM,
|
|
6532
|
-
dialectSchema: cabaneNativeDialectSchema,
|
|
6533
|
-
async *runTurn(req, signal) {
|
|
6534
|
-
if (!provider) {
|
|
6535
|
-
yield {
|
|
6536
|
-
type: "result",
|
|
6537
|
-
ok: false,
|
|
6538
|
-
reason: "cabane_native_unavailable:no OPENROUTER_API_KEY configured on this device"
|
|
6539
|
-
};
|
|
6540
|
-
return;
|
|
6541
|
-
}
|
|
6542
|
-
const workspaceId = req.cabane.workspaceId;
|
|
6543
|
-
if (!workspaceId) {
|
|
6544
|
-
yield {
|
|
6545
|
-
type: "result",
|
|
6546
|
-
ok: false,
|
|
6547
|
-
reason: "cabane_native_unavailable:turn carried no workspaceId"
|
|
6548
|
-
};
|
|
6549
|
-
return;
|
|
6550
|
-
}
|
|
6551
|
-
const apiRoot = req.cabane.mcpUrl.replace(/\/mcp\/?$/, "");
|
|
6552
|
-
yield* runCabaneNativeTurn(req, signal, {
|
|
6553
|
-
provider,
|
|
6554
|
-
apiRoot,
|
|
6555
|
-
workspaceId,
|
|
6556
|
-
bearer: req.cabane.bearer,
|
|
6557
|
-
conversationId: req.cabane.activeConversationId,
|
|
6558
|
-
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {},
|
|
6559
|
-
...deps.onWarn ? { onWarn: deps.onWarn } : {},
|
|
6560
|
-
...deps.maxIterations !== void 0 ? { maxIterations: deps.maxIterations } : {}
|
|
6561
|
-
});
|
|
6562
|
-
}
|
|
6563
|
-
};
|
|
6564
|
-
}
|
|
6565
|
-
var cabaneNativeAdapter = createCabaneNativeAdapter();
|
|
6566
|
-
|
|
6567
6156
|
// packages/agent-runtime/src/claude-code/sdk.ts
|
|
6568
6157
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
6569
6158
|
|
|
@@ -6602,8 +6191,8 @@ var ConnectorHealthStore = class {
|
|
|
6602
6191
|
return this.byRuntime.get(runtime);
|
|
6603
6192
|
}
|
|
6604
6193
|
// The per-connector reports to attach to a heartbeat — one entry per runtime the
|
|
6605
|
-
//
|
|
6606
|
-
// so a
|
|
6194
|
+
// companion has an observation for. Empty until the first classified failure/heal,
|
|
6195
|
+
// so a companion that has seen nothing sends no `connectors[]` and the server's
|
|
6607
6196
|
// manifest synthesis (status-less rows) is unaffected.
|
|
6608
6197
|
reports() {
|
|
6609
6198
|
return [...this.byRuntime.entries()].map(([runtime, h]) => ({
|
|
@@ -6617,22 +6206,23 @@ var ConnectorHealthStore = class {
|
|
|
6617
6206
|
|
|
6618
6207
|
// src/dispatcher.ts
|
|
6619
6208
|
import { randomUUID } from "crypto";
|
|
6620
|
-
import { existsSync as existsSync9 } from "fs";
|
|
6209
|
+
import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync10 } from "fs";
|
|
6210
|
+
import { join as join12 } from "path";
|
|
6621
6211
|
|
|
6622
6212
|
// src/summon.ts
|
|
6623
|
-
import { z as
|
|
6624
|
-
var
|
|
6213
|
+
import { z as z12 } from "zod";
|
|
6214
|
+
var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
|
|
6625
6215
|
var SUMMON_AGENT_TOOL = "summon_agent";
|
|
6626
|
-
var SUMMON_AGENT_TOOL_NAME = `mcp__${
|
|
6627
|
-
var
|
|
6216
|
+
var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
|
|
6217
|
+
var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
|
|
6628
6218
|
var SKIP_TURN_TOOL = "skip_turn";
|
|
6629
|
-
var SKIP_TURN_TOOL_NAME = `mcp__${
|
|
6219
|
+
var SKIP_TURN_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SKIP_TURN_TOOL}`;
|
|
6630
6220
|
var ASK_TOOL = "ask";
|
|
6631
|
-
var ASK_TOOL_NAME = `mcp__${
|
|
6221
|
+
var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
|
|
6632
6222
|
var SUB_AGENT_TOOL = "sub_agent";
|
|
6633
|
-
var SUB_AGENT_TOOL_NAME = `mcp__${
|
|
6223
|
+
var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
|
|
6634
6224
|
var WAKE_ME_TOOL = "wake_me";
|
|
6635
|
-
var WAKE_ME_TOOL_NAME = `mcp__${
|
|
6225
|
+
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
6636
6226
|
function createSummonState() {
|
|
6637
6227
|
return { agentId: null };
|
|
6638
6228
|
}
|
|
@@ -6647,14 +6237,14 @@ function createWakeState() {
|
|
|
6647
6237
|
}
|
|
6648
6238
|
function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
|
|
6649
6239
|
return createSdkMcpServer({
|
|
6650
|
-
name:
|
|
6240
|
+
name: COMPANION_LOCAL_MCP_SERVER,
|
|
6651
6241
|
version: "0.0.0",
|
|
6652
6242
|
tools: [
|
|
6653
6243
|
tool(
|
|
6654
6244
|
SUMMON_AGENT_TOOL,
|
|
6655
6245
|
"Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Use it to hand part of the work to a teammate, or pull in an expert, without leaving the conversation. Pass the peer's `agentId` (discover handles + ids via `list_agents`). The peer is dispatched on your turn's final reply, so write the context/ask into that reply first \u2014 it receives your message + this conversation to work from. Writing `@handle` in your prose does NOT summon anyone (agent prose never dispatches); this tool is the only in-thread lever. Single target \u2014 the last call wins. Summoning yourself is a no-op. Reach for it when the human wants the peer's answer right HERE, in front of them \u2014 the reply lands in this thread, so there's no return to wire (a return is for work YOU consume, never a courtesy notification). A handoff to a DIFFERENT conversation is `create_conversation` / `post_message` with their `dispatch` field instead.",
|
|
6656
6246
|
{
|
|
6657
|
-
agentId:
|
|
6247
|
+
agentId: z12.string().uuid().describe("The peer agent to summon \u2014 a workspace agent id from `list_agents`.")
|
|
6658
6248
|
},
|
|
6659
6249
|
async (args) => {
|
|
6660
6250
|
summonState.agentId = args.agentId;
|
|
@@ -6669,7 +6259,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6669
6259
|
SKIP_TURN_TOOL,
|
|
6670
6260
|
`End your current turn WITHOUT posting a reply. Call this when you've been dispatched but the message genuinely doesn't need a response from you \u2014 a thanks/aside, a question already answered, chatter outside your lane, or a pile-on where someone else has it. Your turn ends silently: no message bubble is posted. The \`reason\` is a short free-text note for telemetry (e.g. "already answered by cabane", "thanks, nothing to add"). Prefer this over posting a low-value "ok!"/"got it" reply. Don't also write a reply when you skip \u2014 skipping IS the whole turn.`,
|
|
6671
6261
|
{
|
|
6672
|
-
reason:
|
|
6262
|
+
reason: z12.string().min(1).max(500).describe("Short reason you are declining \u2014 used for telemetry/debugging.")
|
|
6673
6263
|
},
|
|
6674
6264
|
async (args) => {
|
|
6675
6265
|
skipState.skipped = true;
|
|
@@ -6686,21 +6276,21 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6686
6276
|
ASK_TOOL,
|
|
6687
6277
|
"Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact, a go/no-go). Pass `targetUserId` (a workspace member's user id \u2014 get it from `mcp__cabane__list_members`). Two forms: a SINGLE question \u2014 a `headline` (the actual question as one clear, capitalized sentence ending in `?`, \"Do we go to prod?\") plus a short `question` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once (\"three calls before I build: A? B? C?\"), a `questions` array of 1\u20135 items, each `{ headline, body?, options? }`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence `headline`, a short `body` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted lists or headings), and 2\u20134 `options` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. An option can be a short button label OR a sentence that carries its own context; short/binary sets render as inline buttons, long ones stack full-width. Provide EITHER `question` (single) or `questions` (array), never both. The ask is recorded as a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use summon/dispatch instead.",
|
|
6688
6278
|
{
|
|
6689
|
-
targetUserId:
|
|
6690
|
-
question:
|
|
6279
|
+
targetUserId: z12.string().uuid().describe("The workspace member (human) to ask \u2014 a user id from `list_members`."),
|
|
6280
|
+
question: z12.string().min(1).max(400).optional().describe(
|
|
6691
6281
|
"SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (capped, inline markdown only). Provide EITHER this or `questions`, not both. Put the crisp one-sentence question in `headline`."
|
|
6692
6282
|
),
|
|
6693
|
-
headline:
|
|
6283
|
+
headline: z12.string().min(1).max(120).optional().describe(
|
|
6694
6284
|
'SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in `?` ("Do we go to prod?"). What the human reads first in the inbox and the chip \u2014 one scannable question, no elaboration (that goes in `question`). Strongly encouraged.'
|
|
6695
6285
|
),
|
|
6696
|
-
options:
|
|
6697
|
-
questions:
|
|
6698
|
-
|
|
6699
|
-
headline:
|
|
6286
|
+
options: z12.array(z12.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
|
|
6287
|
+
questions: z12.array(
|
|
6288
|
+
z12.object({
|
|
6289
|
+
headline: z12.string().min(1).max(120).describe(
|
|
6700
6290
|
'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
|
|
6701
6291
|
),
|
|
6702
|
-
body:
|
|
6703
|
-
options:
|
|
6292
|
+
body: z12.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
|
|
6293
|
+
options: z12.array(z12.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
|
|
6704
6294
|
})
|
|
6705
6295
|
).min(1).max(5).optional().describe(
|
|
6706
6296
|
"MULTI-question form: 1\u20135 questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or `question`/`headline`/`options`, not both."
|
|
@@ -6757,13 +6347,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6757
6347
|
SUB_AGENT_TOOL,
|
|
6758
6348
|
"Spawn a sub-agent \u2014 hand a piece of work to a private worker with a fresh context window, whose result comes back to you automatically. Modeled on the Task tool, with ONE deliberate difference: it does NOT return the result inline. A callee's turn can run for minutes and no turn may hold an unbounded wait, so the shape is spawn-now, results-on-wake \u2014 this returns immediately with the child's `conversationId`, and the outcome lands LATER as a message in THIS conversation; you're woken once every sub-agent you have out in this conversation has returned. So DON'T wait for it: after spawning, finish whatever else this turn can do and end your turn (never poll the child with reads in a loop \u2014 the wake is automatic). Parallel fan-out = call this N times in one turn (they run concurrently; ONE wake when all are in); series = one call per turn. `prompt` is the child's opening instruction \u2014 make it self-contained (the sub-agent starts fresh, with only this prompt + the thread it lands in). `agentId` (optional) dispatches a PEER instead of yourself \u2014 same mechanics, a different mind (use for capability/context you lack); default (self) is the pure sub-worker with a clean context window. `title` (optional) names the child thread (results link it, so a legible title helps). A single sub-agent has no wall-clock advantage (you idle either way) \u2014 it pays when the callee has capability/context you lack, or to isolate a big read from your own session; the real win is fan-out. Don't spawn one for a lookup you can do in-turn with your own tools. The result returns to YOU to act on \u2014 reach for it when you're the consumer of the output, not as a way to notify a human: if a person just wants to read the result, dispatch a plain (no-return) conversation and link it instead of spawning a sub-agent.",
|
|
6759
6349
|
{
|
|
6760
|
-
prompt:
|
|
6350
|
+
prompt: z12.string().min(1).max(65536).describe(
|
|
6761
6351
|
"The sub-agent's opening instruction \u2014 self-contained (it starts with a fresh context window; only this prompt + the thread it lands in)."
|
|
6762
6352
|
),
|
|
6763
|
-
agentId:
|
|
6353
|
+
agentId: z12.string().uuid().optional().describe(
|
|
6764
6354
|
"Optional peer to run the sub-agent as (a workspace agent id from `list_agents`); omit to spawn yourself with a fresh context window."
|
|
6765
6355
|
),
|
|
6766
|
-
title:
|
|
6356
|
+
title: z12.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
|
|
6767
6357
|
},
|
|
6768
6358
|
async (args) => {
|
|
6769
6359
|
const result = await subAgentCreate(args);
|
|
@@ -6793,13 +6383,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6793
6383
|
WAKE_ME_TOOL,
|
|
6794
6384
|
'Wake yourself later \u2014 end this turn now and be re-dispatched at a time you pick, with a note you write to yourself. Use it for "wait until X": when the thing you need hasn\'t happened yet (a conversation isn\'t done, a PR isn\'t merged, a human hasn\'t answered), arm a wake, end your turn, and you\'re woken later to CHECK \u2014 read the workspace, and either act or re-arm. This is the loop behind "check back in five minutes", "keep checking until {condition}", and a scheduled re-try ("re-send once that agent\'s limit resets"). Ground the delay before you arm it. Almost every wake is short \u2014 seconds to a couple of hours \u2014 waiting on a condition you can name: a session limit resetting, a PR merging, a deploy landing, a throttled dispatch to re-send. Reach past a few hours only when (a) a human asked for that timing, or (b) the wait is pinned to a real external event you can name \u2014 a report that only runs Mondays, a scheduled appointment, a known reset time. A speculative far-future check-in you invented yourself \u2014 "in two weeks I\'ll see whether this feature is used" \u2014 is the one thing not to arm: if no one asked and you can\'t name both what clears the wait and why it takes that long, don\'t arm it \u2014 finish now, or raise an `ask`. Pass EXACTLY ONE of `afterSeconds` (a relative delay \u2014 `300` for five minutes) or `at` (an absolute ISO-8601 timestamp WITH a zone, e.g. `2026-07-16T09:00:00-07:00` \u2014 YOU compute it from a phrase like "tomorrow morning"; the system never parses natural-language time). `note` is a message to your future self \u2014 it becomes the body of the wake message that re-dispatches you, so write the condition to re-check ("check whether CT441 merged yet"). The wake is armed when your turn SETTLES, not now, so the delay counts from the turn ending; one wake per turn (last call wins). This is the sanctioned way to schedule your own continuation \u2014 the ONLY one; never reach for a host cron/scheduler. Guardrails: at least 60s out, at most 14 days; widen the interval as a loop ages (5m \u2192 15m \u2192 1h\u2026) rather than hammering; after many consecutive re-arms with no other activity you\'ll be steered to raise an `ask` to the human instead. If a wake can\'t be armed you\'re re-dispatched with a note explaining why \u2014 never a silent drop.',
|
|
6795
6385
|
{
|
|
6796
|
-
afterSeconds:
|
|
6386
|
+
afterSeconds: z12.number().int().positive().optional().describe(
|
|
6797
6387
|
"Relative delay in seconds from when this turn ends (e.g. 300 = five minutes). Provide EITHER this or `at`, not both. Floor 60s, horizon 14 days \u2014 enforced server-side."
|
|
6798
6388
|
),
|
|
6799
|
-
at:
|
|
6389
|
+
at: z12.string().datetime({ offset: true }).optional().describe(
|
|
6800
6390
|
"Absolute ISO-8601 timestamp WITH a zone (`Z` or `\xB1HH:MM`), e.g. `2026-07-16T09:00:00-07:00`. YOU compute it from a natural-language phrase using the current datetime in your turn context. Provide EITHER this or `afterSeconds`, not both."
|
|
6801
6391
|
),
|
|
6802
|
-
note:
|
|
6392
|
+
note: z12.string().min(1).max(2e3).describe(
|
|
6803
6393
|
'A note to your future self \u2014 becomes the body of the wake message that re-dispatches you. Write the condition to re-check ("check whether the PR merged").'
|
|
6804
6394
|
)
|
|
6805
6395
|
},
|
|
@@ -6849,7 +6439,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
|
|
|
6849
6439
|
function cabaneMcpUrl(baseUrl) {
|
|
6850
6440
|
return `${trimSlash3(baseUrl)}/api/mcp`;
|
|
6851
6441
|
}
|
|
6852
|
-
function
|
|
6442
|
+
function turnControlMcpUrl(baseUrl) {
|
|
6443
|
+
return `${trimSlash3(baseUrl)}/api/turn-control`;
|
|
6444
|
+
}
|
|
6445
|
+
function buildCompanionTurnRequest(params) {
|
|
6853
6446
|
const { turnContext: t } = params;
|
|
6854
6447
|
return {
|
|
6855
6448
|
systemPrompt: t.systemPrompt,
|
|
@@ -6860,11 +6453,18 @@ function buildBridgeTurnRequest(params) {
|
|
|
6860
6453
|
session: t.session,
|
|
6861
6454
|
cabane: {
|
|
6862
6455
|
mcpUrl: cabaneMcpUrl(params.baseUrl),
|
|
6863
|
-
// CT306: prefer the per-turn OBO credential; fall back to the
|
|
6456
|
+
// CT306: prefer the per-turn OBO credential; fall back to the companion PAT
|
|
6864
6457
|
// when the API didn't mint one (older API / unresolvable delegation).
|
|
6865
6458
|
bearer: params.turnToken ?? params.agentPat,
|
|
6866
6459
|
activeConversationId: params.activeConversationId,
|
|
6867
|
-
workspaceId: params.workspaceId
|
|
6460
|
+
workspaceId: params.workspaceId,
|
|
6461
|
+
...t.workspaceToolSurface ? { workspaceToolSurface: t.workspaceToolSurface } : {},
|
|
6462
|
+
// CT714: mount the turn-control surface ONLY when a real turn token backs
|
|
6463
|
+
// this turn — the surface admits `turn_token` auth exclusively, so a
|
|
6464
|
+
// PAT-fallback bearer (older API / unresolved delegation) would be rejected
|
|
6465
|
+
// there. Absent it, external adapters simply don't mount it that turn (the
|
|
6466
|
+
// same graceful degrade as the rest of the OBO path).
|
|
6467
|
+
...params.turnToken ? { turnControlUrl: turnControlMcpUrl(params.baseUrl) } : {}
|
|
6868
6468
|
},
|
|
6869
6469
|
local: {
|
|
6870
6470
|
...params.cwd ? { cwd: params.cwd } : {},
|
|
@@ -6875,8 +6475,11 @@ function buildBridgeTurnRequest(params) {
|
|
|
6875
6475
|
// CT289: the auto-memory escape hatch, when the operator set it.
|
|
6876
6476
|
...params.claudeCode ? { claudeCode: params.claudeCode } : {}
|
|
6877
6477
|
},
|
|
6878
|
-
// Host-injected: the
|
|
6879
|
-
|
|
6478
|
+
// Host-injected: the companion-local summon server (for the subprocess adapters,
|
|
6479
|
+
// under its own namespace).
|
|
6480
|
+
extra: {
|
|
6481
|
+
mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.summonServer }
|
|
6482
|
+
}
|
|
6880
6483
|
};
|
|
6881
6484
|
}
|
|
6882
6485
|
function trimSlash3(s) {
|
|
@@ -6884,19 +6487,22 @@ function trimSlash3(s) {
|
|
|
6884
6487
|
}
|
|
6885
6488
|
|
|
6886
6489
|
// src/prepared.ts
|
|
6887
|
-
import { mkdirSync as mkdirSync8, readFileSync as
|
|
6490
|
+
import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync7 } from "fs";
|
|
6888
6491
|
import { join as join9 } from "path";
|
|
6889
6492
|
function dirFor(workspaceId) {
|
|
6890
6493
|
return join9(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
|
|
6891
6494
|
}
|
|
6892
|
-
function
|
|
6893
|
-
return join9(dirFor(workspaceId),
|
|
6495
|
+
function conversationDir(workspaceId, conversationId) {
|
|
6496
|
+
return join9(dirFor(workspaceId), encodeURIComponent(conversationId));
|
|
6894
6497
|
}
|
|
6895
|
-
function
|
|
6896
|
-
|
|
6498
|
+
function pathFor3(workspaceId, conversationId, agentId) {
|
|
6499
|
+
return join9(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
|
|
6500
|
+
}
|
|
6501
|
+
function readPrepared(workspaceId, conversationId, agentId) {
|
|
6502
|
+
const path3 = pathFor3(workspaceId, conversationId, agentId);
|
|
6897
6503
|
if (!existsSync7(path3)) return null;
|
|
6898
6504
|
try {
|
|
6899
|
-
const parsed = JSON.parse(
|
|
6505
|
+
const parsed = JSON.parse(readFileSync6(path3, "utf8"));
|
|
6900
6506
|
if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
|
|
6901
6507
|
return {
|
|
6902
6508
|
cwd: parsed.cwd,
|
|
@@ -6908,26 +6514,30 @@ function readPrepared(workspaceId, conversationId) {
|
|
|
6908
6514
|
return null;
|
|
6909
6515
|
}
|
|
6910
6516
|
}
|
|
6911
|
-
function writePrepared(workspaceId, conversationId, result) {
|
|
6912
|
-
mkdirSync8(
|
|
6913
|
-
writeFileSync6(
|
|
6517
|
+
function writePrepared(workspaceId, conversationId, agentId, result) {
|
|
6518
|
+
mkdirSync8(conversationDir(workspaceId, conversationId), { recursive: true });
|
|
6519
|
+
writeFileSync6(
|
|
6520
|
+
pathFor3(workspaceId, conversationId, agentId),
|
|
6521
|
+
JSON.stringify(result) + "\n",
|
|
6522
|
+
"utf8"
|
|
6523
|
+
);
|
|
6914
6524
|
}
|
|
6915
6525
|
|
|
6916
6526
|
// src/secrets.ts
|
|
6917
|
-
import { existsSync as existsSync8, readFileSync as
|
|
6527
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
|
|
6918
6528
|
import { join as join10 } from "path";
|
|
6919
|
-
import { z as
|
|
6529
|
+
import { z as z13 } from "zod";
|
|
6920
6530
|
var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
6921
6531
|
function secretsPath() {
|
|
6922
6532
|
return join10(cabaneDir(), "secrets.json");
|
|
6923
6533
|
}
|
|
6924
|
-
var secretStoreSchema =
|
|
6534
|
+
var secretStoreSchema = z13.record(z13.string(), z13.string());
|
|
6925
6535
|
function loadSecretStore() {
|
|
6926
6536
|
const path3 = secretsPath();
|
|
6927
6537
|
if (!existsSync8(path3)) return makeStore({});
|
|
6928
6538
|
let raw;
|
|
6929
6539
|
try {
|
|
6930
|
-
raw =
|
|
6540
|
+
raw = readFileSync7(path3, "utf8");
|
|
6931
6541
|
} catch (err) {
|
|
6932
6542
|
throw new ConfigError(
|
|
6933
6543
|
`couldn't read ${path3}: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -7251,17 +6861,132 @@ var TurnCommitter = class {
|
|
|
7251
6861
|
}
|
|
7252
6862
|
};
|
|
7253
6863
|
|
|
6864
|
+
// src/workspace-readiness.ts
|
|
6865
|
+
var CLASSIC_REQUIRED = ["read", "list", "search", "write", "edit"];
|
|
6866
|
+
async function proveWorkspaceTools(req, runtime, opts = {}) {
|
|
6867
|
+
const base = {
|
|
6868
|
+
ok: false,
|
|
6869
|
+
proofType: "authenticated_mcp_tools_list",
|
|
6870
|
+
runtime,
|
|
6871
|
+
harnessFingerprint: opts.harnessFingerprint ?? runtime,
|
|
6872
|
+
endpoint: safeEndpoint(req.cabane.mcpUrl),
|
|
6873
|
+
initialized: false,
|
|
6874
|
+
authenticated: false,
|
|
6875
|
+
discoveredTools: [],
|
|
6876
|
+
requiredTools: [],
|
|
6877
|
+
acceptedNames: ["sdk", "mcp__cabane__sdk"],
|
|
6878
|
+
failedCapability: null,
|
|
6879
|
+
detail: null
|
|
6880
|
+
};
|
|
6881
|
+
if (!req.cabane.mcpUrl) return fail(base, "server_not_configured", "Cabane MCP URL absent");
|
|
6882
|
+
if (!req.cabane.bearer) return fail(base, "authentication_failed", "Cabane bearer absent");
|
|
6883
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
6884
|
+
const headers = {
|
|
6885
|
+
authorization: `Bearer ${req.cabane.bearer}`,
|
|
6886
|
+
accept: "application/json, text/event-stream",
|
|
6887
|
+
"content-type": "application/json",
|
|
6888
|
+
"x-cabane-active-conversation": req.cabane.activeConversationId
|
|
6889
|
+
};
|
|
6890
|
+
try {
|
|
6891
|
+
const initialized = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
|
|
6892
|
+
jsonrpc: "2.0",
|
|
6893
|
+
id: 1,
|
|
6894
|
+
method: "initialize",
|
|
6895
|
+
params: {
|
|
6896
|
+
protocolVersion: "2025-03-26",
|
|
6897
|
+
capabilities: {},
|
|
6898
|
+
clientInfo: { name: "cabane-companion-readiness", version: "1" }
|
|
6899
|
+
}
|
|
6900
|
+
});
|
|
6901
|
+
if (initialized.status === 401 || initialized.status === 403)
|
|
6902
|
+
return fail(base, "authentication_failed", `initialize returned HTTP ${initialized.status}`);
|
|
6903
|
+
if (!initialized.ok) return fail(base, "initialization_failed", initialized.detail);
|
|
6904
|
+
base.initialized = true;
|
|
6905
|
+
base.authenticated = true;
|
|
6906
|
+
if (initialized.sessionId) headers["mcp-session-id"] = initialized.sessionId;
|
|
6907
|
+
const listed = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
|
|
6908
|
+
jsonrpc: "2.0",
|
|
6909
|
+
id: 2,
|
|
6910
|
+
method: "tools/list",
|
|
6911
|
+
params: {}
|
|
6912
|
+
});
|
|
6913
|
+
if (listed.status === 401 || listed.status === 403)
|
|
6914
|
+
return fail(base, "authentication_failed", `tools/list returned HTTP ${listed.status}`);
|
|
6915
|
+
if (!listed.ok) return fail(base, "tool_discovery_failed", listed.detail);
|
|
6916
|
+
const result = asRecord3(asRecord3(listed.value)?.result);
|
|
6917
|
+
const tools = Array.isArray(result?.tools) ? result.tools : null;
|
|
6918
|
+
if (!tools) return fail(base, "tool_discovery_failed", "tools/list returned no tool inventory");
|
|
6919
|
+
base.discoveredTools = tools.map(
|
|
6920
|
+
(tool2) => tool2 && typeof tool2 === "object" && typeof tool2.name === "string" ? tool2.name : null
|
|
6921
|
+
).filter((name) => name !== null).sort();
|
|
6922
|
+
if (!req.cabane.workspaceToolSurface)
|
|
6923
|
+
return fail(base, "required_tool_missing", "resolved workspace tool surface absent");
|
|
6924
|
+
base.requiredTools = req.cabane.workspaceToolSurface === "code" ? ["sdk"] : CLASSIC_REQUIRED;
|
|
6925
|
+
const missing = base.requiredTools.filter((name) => !base.discoveredTools.includes(name));
|
|
6926
|
+
if (missing.length > 0)
|
|
6927
|
+
return fail(
|
|
6928
|
+
base,
|
|
6929
|
+
"required_tool_missing",
|
|
6930
|
+
`missing initialized tools: ${missing.join(", ")}`
|
|
6931
|
+
);
|
|
6932
|
+
base.ok = true;
|
|
6933
|
+
return base;
|
|
6934
|
+
} catch (error) {
|
|
6935
|
+
return fail(
|
|
6936
|
+
base,
|
|
6937
|
+
"initialization_failed",
|
|
6938
|
+
error instanceof Error ? error.message : String(error)
|
|
6939
|
+
);
|
|
6940
|
+
}
|
|
6941
|
+
}
|
|
6942
|
+
function fail(proof, capability, detail) {
|
|
6943
|
+
proof.failedCapability = capability;
|
|
6944
|
+
proof.detail = detail.slice(0, 300);
|
|
6945
|
+
return proof;
|
|
6946
|
+
}
|
|
6947
|
+
function safeEndpoint(value) {
|
|
6948
|
+
try {
|
|
6949
|
+
const url = new URL(value);
|
|
6950
|
+
return `${url.origin}${url.pathname}`;
|
|
6951
|
+
} catch {
|
|
6952
|
+
return null;
|
|
6953
|
+
}
|
|
6954
|
+
}
|
|
6955
|
+
async function rpc(fetchImpl, url, headers, body) {
|
|
6956
|
+
const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
6957
|
+
const text = await response.text();
|
|
6958
|
+
const value = parseRpcBody(text);
|
|
6959
|
+
return {
|
|
6960
|
+
ok: response.ok && !!value && !value.error,
|
|
6961
|
+
status: response.status,
|
|
6962
|
+
sessionId: response.headers.get("mcp-session-id"),
|
|
6963
|
+
value,
|
|
6964
|
+
detail: typeof asRecord3(value?.error)?.message === "string" ? String(asRecord3(value?.error)?.message) : `HTTP ${response.status}`
|
|
6965
|
+
};
|
|
6966
|
+
}
|
|
6967
|
+
function parseRpcBody(text) {
|
|
6968
|
+
const trimmed = text.trim();
|
|
6969
|
+
if (trimmed.startsWith("{")) return JSON.parse(trimmed);
|
|
6970
|
+
for (const line of trimmed.split("\n")) {
|
|
6971
|
+
if (line.startsWith("data:")) return JSON.parse(line.slice(5).trim());
|
|
6972
|
+
}
|
|
6973
|
+
return null;
|
|
6974
|
+
}
|
|
6975
|
+
function asRecord3(value) {
|
|
6976
|
+
return value !== null && typeof value === "object" ? value : null;
|
|
6977
|
+
}
|
|
6978
|
+
|
|
7254
6979
|
// src/dispatcher.ts
|
|
7255
6980
|
var PREPARING_TOOL_NAME = "preparing";
|
|
7256
6981
|
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
|
|
6982
|
+
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
6983
|
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
|
|
6984
|
+
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:";
|
|
6985
|
+
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
6986
|
var SKIPPED_MARKER_BODY = "(skipped)";
|
|
7262
6987
|
var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
|
|
7263
6988
|
var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
|
|
7264
|
-
var DEFAULT_AGENT_TOTAL_TIMEOUT_MS =
|
|
6989
|
+
var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
|
|
7265
6990
|
function runKey(conversationId, agentId) {
|
|
7266
6991
|
return `${conversationId}|${agentId}`;
|
|
7267
6992
|
}
|
|
@@ -7302,13 +7027,13 @@ var Dispatcher = class {
|
|
|
7302
7027
|
}
|
|
7303
7028
|
}
|
|
7304
7029
|
// CT138: shared pre-run teardown for every early-return that happens BEFORE
|
|
7305
|
-
// the active-run flag is flipped (the `
|
|
7030
|
+
// the active-run flag is flipped (the `setActiveRun` working-flip below).
|
|
7306
7031
|
// The server lights the "X is replying…" indicator eagerly at dispatch
|
|
7307
|
-
// (chat-dispatch.ts `scheduleRun`), and from that point only the
|
|
7032
|
+
// (chat-dispatch.ts `scheduleRun`), and from that point only the companion can
|
|
7308
7033
|
// clear it — the SJ383 `finally` after the SDK loop is the one clear, and
|
|
7309
7034
|
// every pre-run exit returns before reaching it. So each pre-run failure has
|
|
7310
7035
|
// to clear `active_run_started_at` itself, mirroring that `finally`, or the
|
|
7311
|
-
// indicator strands until the
|
|
7036
|
+
// indicator strands until the 12h age sweep.
|
|
7312
7037
|
//
|
|
7313
7038
|
// `errorReason` controls the server's duplicate-notice rule (the active-run
|
|
7314
7039
|
// PATCH handler in conversations.ts): a clear carrying `errorReason` makes the
|
|
@@ -7322,7 +7047,7 @@ var Dispatcher = class {
|
|
|
7322
7047
|
const body = { activeRunStartedAt: null };
|
|
7323
7048
|
if (errorReason) body.errorReason = errorReason.slice(0, 200);
|
|
7324
7049
|
try {
|
|
7325
|
-
await this.opts.api.
|
|
7050
|
+
await this.opts.api.setActiveRun(
|
|
7326
7051
|
this.opts.workspaceId,
|
|
7327
7052
|
payload.conversationId,
|
|
7328
7053
|
payload.agentId,
|
|
@@ -7348,9 +7073,14 @@ var Dispatcher = class {
|
|
|
7348
7073
|
agentId: payload.agentId,
|
|
7349
7074
|
messageId: payload.messageId
|
|
7350
7075
|
});
|
|
7076
|
+
const turnId = randomUUID();
|
|
7351
7077
|
let turnContext;
|
|
7352
7078
|
try {
|
|
7353
|
-
turnContext = await this.opts.api.getTurnContext(
|
|
7079
|
+
turnContext = await this.opts.api.getTurnContext(
|
|
7080
|
+
payload.conversationId,
|
|
7081
|
+
payload.messageId,
|
|
7082
|
+
turnId
|
|
7083
|
+
);
|
|
7354
7084
|
} catch (err) {
|
|
7355
7085
|
const status2 = err instanceof ApiError ? err.status : 0;
|
|
7356
7086
|
if (status2 === 404) {
|
|
@@ -7391,7 +7121,7 @@ var Dispatcher = class {
|
|
|
7391
7121
|
);
|
|
7392
7122
|
if (missing.length > 0) {
|
|
7393
7123
|
const list = missing.map((n) => `\`${n}\``).join(", ");
|
|
7394
|
-
turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this
|
|
7124
|
+
turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this companion");
|
|
7395
7125
|
try {
|
|
7396
7126
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7397
7127
|
body: `${MISSING_SECRET_PREFIX} ${list}`,
|
|
@@ -7412,7 +7142,6 @@ var Dispatcher = class {
|
|
|
7412
7142
|
const localCwd = this.opts.local.cwd;
|
|
7413
7143
|
const prepareHook = this.opts.local.prepareHook;
|
|
7414
7144
|
const cabaneCwd = turnContext.cwd;
|
|
7415
|
-
const turnId = randomUUID();
|
|
7416
7145
|
let seqCounter = 0;
|
|
7417
7146
|
const nextSeq = () => ++seqCounter;
|
|
7418
7147
|
let effectiveCwd = localCwd ?? cabaneCwd;
|
|
@@ -7425,7 +7154,7 @@ var Dispatcher = class {
|
|
|
7425
7154
|
}
|
|
7426
7155
|
let hookEnv;
|
|
7427
7156
|
if (prepareHook) {
|
|
7428
|
-
const cached2 = readPrepared(workspaceId, payload.conversationId);
|
|
7157
|
+
const cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
|
|
7429
7158
|
if (cached2) {
|
|
7430
7159
|
effectiveCwd = cached2.cwd;
|
|
7431
7160
|
hookEnv = cached2.env;
|
|
@@ -7462,6 +7191,7 @@ var Dispatcher = class {
|
|
|
7462
7191
|
conversationId: payload.conversationId,
|
|
7463
7192
|
agentId: payload.agentId,
|
|
7464
7193
|
agentUsername: this.opts.agentUsername,
|
|
7194
|
+
runtime: turnContext.runtime,
|
|
7465
7195
|
// CT317/CT319: the trigger message's referenced-entry paths — what the
|
|
7466
7196
|
// tasker prepare hook keys its per-task env off. Defaults to `[]` for
|
|
7467
7197
|
// an older API. The conversation anchor is gone (CT319).
|
|
@@ -7470,7 +7200,7 @@ var Dispatcher = class {
|
|
|
7470
7200
|
});
|
|
7471
7201
|
clearTimeout(preparingTimer);
|
|
7472
7202
|
if (preparingStarted) reportPreparing("done");
|
|
7473
|
-
writePrepared(workspaceId, payload.conversationId, result);
|
|
7203
|
+
writePrepared(workspaceId, payload.conversationId, payload.agentId, result);
|
|
7474
7204
|
effectiveCwd = result.cwd;
|
|
7475
7205
|
hookEnv = result.env;
|
|
7476
7206
|
} catch (err) {
|
|
@@ -7499,12 +7229,25 @@ ${reason}`,
|
|
|
7499
7229
|
}
|
|
7500
7230
|
}
|
|
7501
7231
|
}
|
|
7232
|
+
let turnEnv = hookEnv;
|
|
7233
|
+
if (effectiveCwd && turnContext.runtime === "codex") {
|
|
7234
|
+
const tmpDir = join12(effectiveCwd, "node_modules", ".cache", "cabane-tmp", turnId);
|
|
7235
|
+
try {
|
|
7236
|
+
mkdirSync10(tmpDir, { recursive: true });
|
|
7237
|
+
turnEnv = { ...hookEnv, TMPDIR: tmpDir };
|
|
7238
|
+
} catch (err) {
|
|
7239
|
+
turnLog.warn(
|
|
7240
|
+
{ err: err instanceof Error ? err.message : String(err), tmpDir },
|
|
7241
|
+
"dispatcher: failed to create per-turn TMPDIR \u2014 proceeding with the inherited temp dir"
|
|
7242
|
+
);
|
|
7243
|
+
}
|
|
7244
|
+
}
|
|
7502
7245
|
const key = runKey(payload.conversationId, payload.agentId);
|
|
7503
7246
|
const abortController = new AbortController();
|
|
7504
7247
|
this.aborts.set(key, abortController);
|
|
7505
7248
|
let timeoutReason = null;
|
|
7506
7249
|
try {
|
|
7507
|
-
await this.opts.api.
|
|
7250
|
+
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
7508
7251
|
activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7509
7252
|
// CT33: hand the server this turn's id so the new-run chokepoint's
|
|
7510
7253
|
// `closeAbandonedTurns` sweep excludes it. The prepare hook may have
|
|
@@ -7552,16 +7295,18 @@ ${reason}`,
|
|
|
7552
7295
|
subAgentCreate,
|
|
7553
7296
|
wakeState
|
|
7554
7297
|
);
|
|
7555
|
-
const request =
|
|
7298
|
+
const request = buildCompanionTurnRequest({
|
|
7556
7299
|
turnContext,
|
|
7557
7300
|
baseUrl: this.opts.baseUrl,
|
|
7558
7301
|
agentPat: this.opts.credential,
|
|
7559
7302
|
// CT306: the per-turn OBO credential when the API minted one; falls back to
|
|
7560
|
-
// the
|
|
7303
|
+
// the companion PAT (`agentPat`) inside `buildCompanionTurnRequest` otherwise.
|
|
7561
7304
|
...turnContext.turnToken ? { turnToken: turnContext.turnToken } : {},
|
|
7562
7305
|
// SJ524: the hook-resolved cwd overrides the static local cwd.
|
|
7563
7306
|
...effectiveCwd ? { cwd: effectiveCwd } : {},
|
|
7564
|
-
|
|
7307
|
+
// CT804: `turnEnv` = the prepare-hook env plus the per-turn checkout-local
|
|
7308
|
+
// TMPDIR (falls back to `hookEnv` when no cwd was resolved).
|
|
7309
|
+
...turnEnv ? { env: turnEnv } : {},
|
|
7565
7310
|
mcpServers: resolvedMcpServers,
|
|
7566
7311
|
summonServer,
|
|
7567
7312
|
// CT238: this turn's conversation, forwarded as the active-conversation
|
|
@@ -7581,9 +7326,6 @@ ${reason}`,
|
|
|
7581
7326
|
if (this.opts.codexEnabled) {
|
|
7582
7327
|
adapters.push(createCodexAdapter({ enabled: true, onWarn }));
|
|
7583
7328
|
}
|
|
7584
|
-
if (this.opts.cabaneNativeApiKey) {
|
|
7585
|
-
adapters.push(createCabaneNativeAdapter({ apiKey: this.opts.cabaneNativeApiKey, onWarn }));
|
|
7586
|
-
}
|
|
7587
7329
|
const registry = createAdapterRegistry(adapters);
|
|
7588
7330
|
let adapter;
|
|
7589
7331
|
try {
|
|
@@ -7614,6 +7356,58 @@ ${reason}`,
|
|
|
7614
7356
|
`runtime_unavailable:${err.runtime}`
|
|
7615
7357
|
);
|
|
7616
7358
|
}
|
|
7359
|
+
if (prepareHook && hookEnv?.CABANE_TASK_ID) {
|
|
7360
|
+
const proof = await proveWorkspaceTools(request, adapter.name, {
|
|
7361
|
+
...this.opts.workspaceProofFetch ? { fetchImpl: this.opts.workspaceProofFetch } : {},
|
|
7362
|
+
harnessFingerprint: turnContext.runtime
|
|
7363
|
+
});
|
|
7364
|
+
turnLog[proof.ok ? "info" : "error"](
|
|
7365
|
+
{ workspaceProof: proof, checkout: effectiveCwd ?? null },
|
|
7366
|
+
`dispatcher: workspace tool proof ${proof.ok ? "passed" : "failed"}`
|
|
7367
|
+
);
|
|
7368
|
+
if (effectiveCwd) {
|
|
7369
|
+
try {
|
|
7370
|
+
const diagnosticDir = join12(effectiveCwd, ".git", "cabane");
|
|
7371
|
+
mkdirSync10(diagnosticDir, { recursive: true });
|
|
7372
|
+
appendFileSync2(
|
|
7373
|
+
join12(diagnosticDir, "readiness.jsonl"),
|
|
7374
|
+
`${JSON.stringify({
|
|
7375
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7376
|
+
taskId: hookEnv.CABANE_TASK_ID,
|
|
7377
|
+
binding: hookEnv.CABANE_TASK_BINDING ?? null,
|
|
7378
|
+
checkout: effectiveCwd,
|
|
7379
|
+
classification: proof.ok ? "ready" : "workspace_tools_missing",
|
|
7380
|
+
failedCapability: proof.failedCapability,
|
|
7381
|
+
workspaceTools: proof
|
|
7382
|
+
})}
|
|
7383
|
+
`,
|
|
7384
|
+
{ mode: 384 }
|
|
7385
|
+
);
|
|
7386
|
+
} catch (error) {
|
|
7387
|
+
turnLog.warn(
|
|
7388
|
+
{ err: error instanceof Error ? error.message : String(error) },
|
|
7389
|
+
"dispatcher: workspace-proof diagnostic write failed"
|
|
7390
|
+
);
|
|
7391
|
+
}
|
|
7392
|
+
}
|
|
7393
|
+
if (!proof.ok) {
|
|
7394
|
+
const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd ?? "unknown"}; runtime=${adapter.name}; recovery=restart the connector after restoring the Cabane workspace tool mount`;
|
|
7395
|
+
try {
|
|
7396
|
+
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7397
|
+
body: `**Couldn't prepare your environment.** ${reason}`,
|
|
7398
|
+
kind: "final",
|
|
7399
|
+
turnId,
|
|
7400
|
+
parentMessageId: payload.messageId
|
|
7401
|
+
});
|
|
7402
|
+
} catch (postErr) {
|
|
7403
|
+
turnLog.warn(
|
|
7404
|
+
{ err: postErr instanceof Error ? postErr.message : String(postErr) },
|
|
7405
|
+
"dispatcher: workspace-proof failure notice post failed"
|
|
7406
|
+
);
|
|
7407
|
+
}
|
|
7408
|
+
return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
|
|
7409
|
+
}
|
|
7410
|
+
}
|
|
7617
7411
|
const transcript2 = this.opts.transcriptDir ? new TranscriptWriter(
|
|
7618
7412
|
this.opts.transcriptDir,
|
|
7619
7413
|
{
|
|
@@ -7657,6 +7451,49 @@ ${reason}`,
|
|
|
7657
7451
|
// server arms the wake schedule atomically with the reply it rode on.
|
|
7658
7452
|
wakeState
|
|
7659
7453
|
});
|
|
7454
|
+
const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
|
|
7455
|
+
let turnControlIntentFetched = false;
|
|
7456
|
+
const applyRecordedTurnControlIntent = async () => {
|
|
7457
|
+
if (turnControlIntentFetched || !usesHttpTurnControl || !turnContext.turnToken) return;
|
|
7458
|
+
turnControlIntentFetched = true;
|
|
7459
|
+
try {
|
|
7460
|
+
const intent = await this.opts.api.getTurnIntent(
|
|
7461
|
+
workspaceId,
|
|
7462
|
+
payload.conversationId,
|
|
7463
|
+
payload.agentId,
|
|
7464
|
+
turnId
|
|
7465
|
+
);
|
|
7466
|
+
if (intent.ask) {
|
|
7467
|
+
askState.targetUserId = intent.ask.targetUserId;
|
|
7468
|
+
if (intent.ask.questions && intent.ask.questions.length > 0) {
|
|
7469
|
+
askState.questions = intent.ask.questions;
|
|
7470
|
+
askState.question = null;
|
|
7471
|
+
askState.headline = null;
|
|
7472
|
+
askState.options = null;
|
|
7473
|
+
} else {
|
|
7474
|
+
askState.question = intent.ask.question ?? null;
|
|
7475
|
+
askState.headline = intent.ask.headline ?? null;
|
|
7476
|
+
askState.options = intent.ask.options ?? null;
|
|
7477
|
+
askState.questions = null;
|
|
7478
|
+
}
|
|
7479
|
+
}
|
|
7480
|
+
if (intent.wake) {
|
|
7481
|
+
wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
|
|
7482
|
+
wakeState.at = intent.wake.at ?? null;
|
|
7483
|
+
wakeState.note = intent.wake.note;
|
|
7484
|
+
}
|
|
7485
|
+
if (intent.summonAgentId) summonState.agentId = intent.summonAgentId;
|
|
7486
|
+
if (intent.skipped) {
|
|
7487
|
+
skipState.skipped = true;
|
|
7488
|
+
skipState.reason = intent.skipReason;
|
|
7489
|
+
}
|
|
7490
|
+
} catch (err) {
|
|
7491
|
+
turnLog.warn(
|
|
7492
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
7493
|
+
"dispatcher: turn-control intent fetch failed; turn-control effects for this turn are dropped"
|
|
7494
|
+
);
|
|
7495
|
+
}
|
|
7496
|
+
};
|
|
7660
7497
|
const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
|
|
7661
7498
|
const totalTimeoutMs = this.opts.totalTimeoutMs ?? DEFAULT_AGENT_TOTAL_TIMEOUT_MS;
|
|
7662
7499
|
const fireTimeout = (reason) => {
|
|
@@ -7692,7 +7529,7 @@ ${reason}`,
|
|
|
7692
7529
|
if (!sessionWritten) {
|
|
7693
7530
|
sessionWritten = true;
|
|
7694
7531
|
try {
|
|
7695
|
-
await this.opts.api.
|
|
7532
|
+
await this.opts.api.setActiveRun(
|
|
7696
7533
|
workspaceId,
|
|
7697
7534
|
payload.conversationId,
|
|
7698
7535
|
payload.agentId,
|
|
@@ -7713,6 +7550,9 @@ ${reason}`,
|
|
|
7713
7550
|
turnResolvedConfig = event.resolvedConfig;
|
|
7714
7551
|
} else if (event.type === "text" && skipState.skipped) {
|
|
7715
7552
|
} else {
|
|
7553
|
+
if (event.type === "text" && event.terminal) {
|
|
7554
|
+
await applyRecordedTurnControlIntent();
|
|
7555
|
+
}
|
|
7716
7556
|
await committer.ingestEvent(event);
|
|
7717
7557
|
}
|
|
7718
7558
|
}
|
|
@@ -7723,18 +7563,28 @@ ${reason}`,
|
|
|
7723
7563
|
if (!okResult && !resultReason) {
|
|
7724
7564
|
resultReason = "no_result";
|
|
7725
7565
|
}
|
|
7566
|
+
if (!abortController.signal.aborted) {
|
|
7567
|
+
await applyRecordedTurnControlIntent();
|
|
7568
|
+
}
|
|
7726
7569
|
if (!abortController.signal.aborted && skipState.skipped) {
|
|
7727
7570
|
turnLog.info(
|
|
7728
7571
|
{ reason: skipState.reason, turnId, ok: okResult },
|
|
7729
7572
|
"agent skipped turn (skip_turn)"
|
|
7730
7573
|
);
|
|
7574
|
+
const { afterSeconds: wakeAfter, at: wakeAt, note: wakeNote } = wakeState;
|
|
7575
|
+
const skipWake = wakeNote && (wakeAfter !== null || wakeAt !== null) ? {
|
|
7576
|
+
...wakeAfter !== null ? { afterSeconds: wakeAfter } : {},
|
|
7577
|
+
...wakeAt !== null ? { at: wakeAt } : {},
|
|
7578
|
+
note: wakeNote
|
|
7579
|
+
} : void 0;
|
|
7731
7580
|
try {
|
|
7732
7581
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
7733
7582
|
body: SKIPPED_MARKER_BODY,
|
|
7734
7583
|
kind: "skipped",
|
|
7735
7584
|
turnId,
|
|
7736
7585
|
seq: nextSeq(),
|
|
7737
|
-
parentMessageId: payload.messageId
|
|
7586
|
+
parentMessageId: payload.messageId,
|
|
7587
|
+
...skipWake ? { wake: skipWake } : {}
|
|
7738
7588
|
});
|
|
7739
7589
|
} catch (err) {
|
|
7740
7590
|
turnLog.warn(
|
|
@@ -7820,7 +7670,7 @@ ${reason}`,
|
|
|
7820
7670
|
errorReason: body.errorReason ?? null
|
|
7821
7671
|
});
|
|
7822
7672
|
try {
|
|
7823
|
-
await this.opts.api.
|
|
7673
|
+
await this.opts.api.setActiveRun(
|
|
7824
7674
|
workspaceId,
|
|
7825
7675
|
payload.conversationId,
|
|
7826
7676
|
payload.agentId,
|
|
@@ -7872,7 +7722,7 @@ ${reason}`,
|
|
|
7872
7722
|
};
|
|
7873
7723
|
}
|
|
7874
7724
|
// SJ383: cancel a specific (conversation, agent) run if one is in flight in
|
|
7875
|
-
// THIS
|
|
7725
|
+
// THIS companion process. Returns true if an in-flight run was aborted.
|
|
7876
7726
|
cancel(conversationId, agentId) {
|
|
7877
7727
|
const key = runKey(conversationId, agentId);
|
|
7878
7728
|
const ac = this.aborts.get(key);
|
|
@@ -7886,27 +7736,17 @@ ${reason}`,
|
|
|
7886
7736
|
};
|
|
7887
7737
|
|
|
7888
7738
|
// src/manifest.ts
|
|
7889
|
-
var
|
|
7739
|
+
var DEVICE_MANIFEST = {
|
|
7890
7740
|
runtimes: [{ name: "claude-code", version: null }],
|
|
7891
7741
|
capabilities: { hostFs: true, browser: true, userMcp: true }
|
|
7892
7742
|
};
|
|
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
|
-
}
|
|
7743
|
+
function buildCompanionManifest(opts) {
|
|
7903
7744
|
const v = opts.versions ?? {};
|
|
7904
7745
|
const runtimes = [];
|
|
7905
7746
|
if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
|
|
7906
7747
|
if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
|
|
7907
7748
|
if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
|
|
7908
|
-
|
|
7909
|
-
return { runtimes, capabilities: { ...BRIDGE_MANIFEST.capabilities } };
|
|
7749
|
+
return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
|
|
7910
7750
|
}
|
|
7911
7751
|
|
|
7912
7752
|
// src/harness-status.ts
|
|
@@ -7917,7 +7757,7 @@ var LABELS = {
|
|
|
7917
7757
|
};
|
|
7918
7758
|
function deriveHarnessSnapshot(signals) {
|
|
7919
7759
|
const advertised = new Set(
|
|
7920
|
-
|
|
7760
|
+
buildCompanionManifest({
|
|
7921
7761
|
claudeCode: signals.claudeOnPath,
|
|
7922
7762
|
opencode: signals.opencodeConfigured,
|
|
7923
7763
|
codex: signals.codexEnabled
|
|
@@ -8107,14 +7947,14 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
|
|
|
8107
7947
|
// src/outbox.ts
|
|
8108
7948
|
import {
|
|
8109
7949
|
existsSync as existsSync10,
|
|
8110
|
-
mkdirSync as
|
|
7950
|
+
mkdirSync as mkdirSync11,
|
|
8111
7951
|
readdirSync as readdirSync2,
|
|
8112
|
-
readFileSync as
|
|
7952
|
+
readFileSync as readFileSync8,
|
|
8113
7953
|
renameSync as renameSync3,
|
|
8114
7954
|
rmSync as rmSync5,
|
|
8115
7955
|
writeFileSync as writeFileSync7
|
|
8116
7956
|
} from "fs";
|
|
8117
|
-
import { join as
|
|
7957
|
+
import { join as join13 } from "path";
|
|
8118
7958
|
var MAX_ENTRIES = 2e3;
|
|
8119
7959
|
var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
8120
7960
|
var Outbox = class {
|
|
@@ -8127,17 +7967,17 @@ var Outbox = class {
|
|
|
8127
7967
|
// Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
|
|
8128
7968
|
// cases route writes at the right tmpdir.
|
|
8129
7969
|
dir() {
|
|
8130
|
-
return
|
|
7970
|
+
return join13(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
|
|
8131
7971
|
}
|
|
8132
7972
|
fileFor(turnId, seq) {
|
|
8133
|
-
return
|
|
7973
|
+
return join13(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
|
|
8134
7974
|
}
|
|
8135
7975
|
// Persist a commit for later draining. Atomic (temp file + rename) so a
|
|
8136
7976
|
// concurrent `list()` never reads a half-written entry, then enforces the
|
|
8137
7977
|
// per-workspace bounds.
|
|
8138
7978
|
persist(entry) {
|
|
8139
7979
|
const dir2 = this.dir();
|
|
8140
|
-
|
|
7980
|
+
mkdirSync11(dir2, { recursive: true });
|
|
8141
7981
|
const target = this.fileFor(entry.turnId, entry.seq);
|
|
8142
7982
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
8143
7983
|
try {
|
|
@@ -8150,7 +7990,7 @@ var Outbox = class {
|
|
|
8150
7990
|
}
|
|
8151
7991
|
this.log?.warn(
|
|
8152
7992
|
{ workspaceId: this.workspaceId, err: err instanceof Error ? err.message : String(err) },
|
|
8153
|
-
"
|
|
7993
|
+
"companion outbox: failed to persist entry"
|
|
8154
7994
|
);
|
|
8155
7995
|
return;
|
|
8156
7996
|
}
|
|
@@ -8172,9 +8012,9 @@ var Outbox = class {
|
|
|
8172
8012
|
const entries = [];
|
|
8173
8013
|
for (const name of names) {
|
|
8174
8014
|
if (!name.endsWith(".json")) continue;
|
|
8175
|
-
const full =
|
|
8015
|
+
const full = join13(dir2, name);
|
|
8176
8016
|
try {
|
|
8177
|
-
const parsed = JSON.parse(
|
|
8017
|
+
const parsed = JSON.parse(readFileSync8(full, "utf8"));
|
|
8178
8018
|
if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
|
|
8179
8019
|
entries.push(parsed);
|
|
8180
8020
|
} else {
|
|
@@ -8208,7 +8048,7 @@ var Outbox = class {
|
|
|
8208
8048
|
dropCorrupt(full) {
|
|
8209
8049
|
this.log?.warn(
|
|
8210
8050
|
{ workspaceId: this.workspaceId, file: full },
|
|
8211
|
-
"
|
|
8051
|
+
"companion outbox: dropping unreadable entry"
|
|
8212
8052
|
);
|
|
8213
8053
|
try {
|
|
8214
8054
|
rmSync5(full, { force: true });
|
|
@@ -8225,7 +8065,7 @@ var Outbox = class {
|
|
|
8225
8065
|
if (now - e.enqueuedAt > MAX_AGE_MS) {
|
|
8226
8066
|
this.log?.warn(
|
|
8227
8067
|
{ workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
|
|
8228
|
-
"
|
|
8068
|
+
"companion outbox: evicting entry past max age (undeliverable)"
|
|
8229
8069
|
);
|
|
8230
8070
|
this.remove(e.turnId, e.seq);
|
|
8231
8071
|
} else {
|
|
@@ -8237,7 +8077,7 @@ var Outbox = class {
|
|
|
8237
8077
|
for (const e of survivors.slice(0, overflow)) {
|
|
8238
8078
|
this.log?.warn(
|
|
8239
8079
|
{ workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
|
|
8240
|
-
"
|
|
8080
|
+
"companion outbox: evicting oldest entry past max size"
|
|
8241
8081
|
);
|
|
8242
8082
|
this.remove(e.turnId, e.seq);
|
|
8243
8083
|
}
|
|
@@ -8246,40 +8086,43 @@ var Outbox = class {
|
|
|
8246
8086
|
};
|
|
8247
8087
|
|
|
8248
8088
|
// src/run-config.ts
|
|
8249
|
-
import { z as
|
|
8250
|
-
var mcpStdioServerSchema =
|
|
8251
|
-
type:
|
|
8252
|
-
command:
|
|
8253
|
-
args:
|
|
8254
|
-
env:
|
|
8089
|
+
import { z as z14 } from "zod";
|
|
8090
|
+
var mcpStdioServerSchema = z14.object({
|
|
8091
|
+
type: z14.literal("stdio").optional(),
|
|
8092
|
+
command: z14.string().min(1),
|
|
8093
|
+
args: z14.array(z14.string()).optional(),
|
|
8094
|
+
env: z14.record(z14.string(), z14.string()).optional()
|
|
8255
8095
|
});
|
|
8256
|
-
var mcpHttpServerSchema =
|
|
8257
|
-
type:
|
|
8258
|
-
url:
|
|
8259
|
-
headers:
|
|
8096
|
+
var mcpHttpServerSchema = z14.object({
|
|
8097
|
+
type: z14.literal("http"),
|
|
8098
|
+
url: z14.string().url(),
|
|
8099
|
+
headers: z14.record(z14.string(), z14.string()).optional()
|
|
8260
8100
|
});
|
|
8261
|
-
var mcpSseServerSchema =
|
|
8262
|
-
type:
|
|
8263
|
-
url:
|
|
8264
|
-
headers:
|
|
8101
|
+
var mcpSseServerSchema = z14.object({
|
|
8102
|
+
type: z14.literal("sse"),
|
|
8103
|
+
url: z14.string().url(),
|
|
8104
|
+
headers: z14.record(z14.string(), z14.string()).optional()
|
|
8265
8105
|
});
|
|
8266
|
-
var mcpServerDefSchema =
|
|
8106
|
+
var mcpServerDefSchema = z14.union([
|
|
8267
8107
|
mcpHttpServerSchema,
|
|
8268
8108
|
mcpSseServerSchema,
|
|
8269
8109
|
mcpStdioServerSchema
|
|
8270
8110
|
]);
|
|
8271
|
-
var thinkingConfigSchema =
|
|
8272
|
-
|
|
8273
|
-
|
|
8274
|
-
|
|
8111
|
+
var thinkingConfigSchema = z14.discriminatedUnion("type", [
|
|
8112
|
+
z14.object({ type: z14.literal("adaptive") }),
|
|
8113
|
+
z14.object({ type: z14.literal("enabled"), budgetTokens: z14.number().int().positive().optional() }),
|
|
8114
|
+
z14.object({ type: z14.literal("disabled") })
|
|
8275
8115
|
]);
|
|
8276
|
-
var effortSchema =
|
|
8277
|
-
var runConfigSchema =
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8116
|
+
var effortSchema = z14.enum(["low", "medium", "high", "xhigh", "max"]);
|
|
8117
|
+
var runConfigSchema = z14.object({
|
|
8118
|
+
// CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
|
|
8119
|
+
// trio + its custom tool lists — `true` grants the host filesystem/shell, absent
|
|
8120
|
+
// is the locked surface. Kept in lockstep with `@cabane/shared`'s
|
|
8121
|
+
// `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
|
|
8122
|
+
// stripped, so an older companion riding a newer server never rejects the config).
|
|
8123
|
+
hostAccess: z14.boolean().optional(),
|
|
8124
|
+
mcpServers: z14.record(z14.string(), mcpServerDefSchema).optional(),
|
|
8125
|
+
model: z14.string().min(1).optional(),
|
|
8283
8126
|
thinking: thinkingConfigSchema.optional(),
|
|
8284
8127
|
effort: effortSchema.optional()
|
|
8285
8128
|
});
|
|
@@ -8400,7 +8243,7 @@ function sleep3(ms) {
|
|
|
8400
8243
|
// src/version.ts
|
|
8401
8244
|
import { createRequire as createRequire2 } from "module";
|
|
8402
8245
|
var pkg = createRequire2(import.meta.url)("../package.json");
|
|
8403
|
-
var
|
|
8246
|
+
var COMPANION_VERSION = pkg.version;
|
|
8404
8247
|
|
|
8405
8248
|
// src/supervisor.ts
|
|
8406
8249
|
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
@@ -8414,7 +8257,7 @@ var ASSIGNMENTS_POLL_MS = 6e4;
|
|
|
8414
8257
|
var DRAIN_BASE_MS = 1e3;
|
|
8415
8258
|
var DRAIN_MAX_MS = 3e4;
|
|
8416
8259
|
var DRAIN_IDLE_MS = 15e3;
|
|
8417
|
-
var
|
|
8260
|
+
var CompanionSupervisor = class {
|
|
8418
8261
|
workspaces = /* @__PURE__ */ new Map();
|
|
8419
8262
|
config;
|
|
8420
8263
|
log;
|
|
@@ -8438,10 +8281,12 @@ var BridgeSupervisor = class {
|
|
|
8438
8281
|
dispatcherFactory;
|
|
8439
8282
|
deviceApi = null;
|
|
8440
8283
|
heartbeatTimer = null;
|
|
8284
|
+
inFlightHeartbeat = null;
|
|
8441
8285
|
pollTimer = null;
|
|
8442
8286
|
refreshing = false;
|
|
8443
8287
|
stopped = false;
|
|
8444
|
-
|
|
8288
|
+
draining = false;
|
|
8289
|
+
// CT484: latch so the companion/server version-skew warning is logged once, not
|
|
8445
8290
|
// on every 30s heartbeat.
|
|
8446
8291
|
versionSkewWarned = false;
|
|
8447
8292
|
// This device's id, captured from the heartbeat / assignments response. The SSE
|
|
@@ -8467,18 +8312,18 @@ var BridgeSupervisor = class {
|
|
|
8467
8312
|
this.dispatcherFactory = opts.dispatcherFactory;
|
|
8468
8313
|
}
|
|
8469
8314
|
// Stand up the data plane: pair check, initial assignments pull, then the
|
|
8470
|
-
// heartbeat + poll loops. A
|
|
8315
|
+
// heartbeat + poll loops. A companion with no device token (logged out) does
|
|
8471
8316
|
// nothing but say so.
|
|
8472
8317
|
async start() {
|
|
8473
8318
|
this.log.info(
|
|
8474
|
-
{ protocolVersion: TURN_PROTOCOL_VERSION, version:
|
|
8475
|
-
"
|
|
8319
|
+
{ protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
|
|
8320
|
+
"companion: starting"
|
|
8476
8321
|
);
|
|
8477
8322
|
void this.refreshHarnessStatuses();
|
|
8478
8323
|
if (!this.config.deviceToken) {
|
|
8479
|
-
this.log.warn("
|
|
8324
|
+
this.log.warn("companion: not paired (no device token) \u2014 run `cabane-companion pair`");
|
|
8480
8325
|
process.stdout.write(
|
|
8481
|
-
"
|
|
8326
|
+
"companion: this device is not paired \u2014 run `cabane-companion pair`, then confirm the short code in Settings \u2192 Connectors.\n"
|
|
8482
8327
|
);
|
|
8483
8328
|
return;
|
|
8484
8329
|
}
|
|
@@ -8487,8 +8332,8 @@ var BridgeSupervisor = class {
|
|
|
8487
8332
|
deviceToken: this.config.deviceToken
|
|
8488
8333
|
});
|
|
8489
8334
|
await this.refreshAssignments();
|
|
8490
|
-
|
|
8491
|
-
this.heartbeatTimer = setInterval(() =>
|
|
8335
|
+
this.kickHeartbeat();
|
|
8336
|
+
this.heartbeatTimer = setInterval(() => this.kickHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
|
8492
8337
|
this.heartbeatTimer.unref?.();
|
|
8493
8338
|
this.pollTimer = setInterval(() => void this.refreshAssignments(), ASSIGNMENTS_POLL_MS);
|
|
8494
8339
|
this.pollTimer.unref?.();
|
|
@@ -8501,20 +8346,28 @@ var BridgeSupervisor = class {
|
|
|
8501
8346
|
return [...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : []);
|
|
8502
8347
|
}
|
|
8503
8348
|
// ---- device-level loops ----
|
|
8349
|
+
kickHeartbeat() {
|
|
8350
|
+
if (this.draining || this.inFlightHeartbeat) return;
|
|
8351
|
+
const pending = this.sendHeartbeat();
|
|
8352
|
+
this.inFlightHeartbeat = pending;
|
|
8353
|
+
void pending.finally(() => {
|
|
8354
|
+
if (this.inFlightHeartbeat === pending) this.inFlightHeartbeat = null;
|
|
8355
|
+
});
|
|
8356
|
+
}
|
|
8504
8357
|
async sendHeartbeat() {
|
|
8505
8358
|
if (!this.deviceApi) return;
|
|
8506
8359
|
await this.refreshHarnessStatuses();
|
|
8507
8360
|
try {
|
|
8508
|
-
const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "
|
|
8361
|
+
const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "companion: secrets"));
|
|
8509
8362
|
const connectorReports = this.connectorHealth.reports();
|
|
8510
8363
|
const opencodeModels = this.config.opencode?.serverUrl ? await enumerateOpencodeModels(this.config.opencode.serverUrl) : null;
|
|
8511
8364
|
const res = await this.deviceApi.heartbeat({
|
|
8512
|
-
version:
|
|
8365
|
+
version: COMPANION_VERSION,
|
|
8513
8366
|
exposedSecretNames: store.names(),
|
|
8514
8367
|
// Report each runtime only when this device can actually run it: CT309
|
|
8515
8368
|
// claude-code when `claude` is on PATH, CT270 opencode when the operator
|
|
8516
8369
|
// configured an `opencode serve`.
|
|
8517
|
-
manifest:
|
|
8370
|
+
manifest: buildCompanionManifest({
|
|
8518
8371
|
// CT586: prefer the live re-probe's presence; fall back to the boot probe
|
|
8519
8372
|
// until the first re-probe lands. Same exit-0 `claude --version` signal
|
|
8520
8373
|
// either way, so the manifest's claude-code advertising is unchanged in
|
|
@@ -8525,17 +8378,13 @@ var BridgeSupervisor = class {
|
|
|
8525
8378
|
// like opencode — the CLI's presence is the operator's responsibility;
|
|
8526
8379
|
// a misconfigured device fails the turn loudly, never silently).
|
|
8527
8380
|
codex: isCodexEnabled(this.config),
|
|
8528
|
-
// CT598: advertise the native runtime when an OpenRouter key is set — the
|
|
8529
|
-
// env flag that mounts "In Cabane" native turns on this device (house or
|
|
8530
|
-
// user). Key absent → not advertised, so a native turn never routes here.
|
|
8531
|
-
cabaneNative: isCabaneNativeEnabled(),
|
|
8532
8381
|
// CT571/CT586: each runtime's `version` from the latest harness probe
|
|
8533
8382
|
// (fail-soft to null). Informational only — the server matches on name.
|
|
8534
8383
|
versions: this.harnessVersions
|
|
8535
8384
|
}),
|
|
8536
8385
|
// CT566: echo the last classified credential state per runtime, when the
|
|
8537
|
-
//
|
|
8538
|
-
// failure/heal, so a fresh
|
|
8386
|
+
// companion has seen any. Omitted (undefined) until the first observed
|
|
8387
|
+
// failure/heal, so a fresh companion's beat is unchanged and the server's
|
|
8539
8388
|
// manifest synthesis (status-less rows) still runs.
|
|
8540
8389
|
...connectorReports.length > 0 ? { connectors: connectorReports } : {},
|
|
8541
8390
|
// CT584: include enumerated models only when the probe SUCCEEDED (non-null).
|
|
@@ -8549,22 +8398,22 @@ var BridgeSupervisor = class {
|
|
|
8549
8398
|
} catch (err) {
|
|
8550
8399
|
this.log.warn(
|
|
8551
8400
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
8552
|
-
"
|
|
8401
|
+
"companion: device heartbeat failed (will retry on next tick)"
|
|
8553
8402
|
);
|
|
8554
8403
|
}
|
|
8555
8404
|
}
|
|
8556
|
-
// CT484: belt-and-suspenders beside shipping the
|
|
8405
|
+
// CT484: belt-and-suspenders beside shipping the companion in lockstep inside the
|
|
8557
8406
|
// artifact — if the server reports a build version that differs from this
|
|
8558
|
-
//
|
|
8559
|
-
// an npm-pinned
|
|
8560
|
-
//
|
|
8407
|
+
// companion's, warn loudly (once). This is exactly the skew that bit the M1 walk:
|
|
8408
|
+
// an npm-pinned companion lagging a from-develop server. The elimination (bundled
|
|
8409
|
+
// companion) makes it match by construction; this catches a companion run out of band.
|
|
8561
8410
|
checkVersionSkew(serverVersion) {
|
|
8562
8411
|
if (this.versionSkewWarned) return;
|
|
8563
|
-
if (!serverVersion || serverVersion ===
|
|
8412
|
+
if (!serverVersion || serverVersion === COMPANION_VERSION) return;
|
|
8564
8413
|
this.versionSkewWarned = true;
|
|
8565
8414
|
this.log.warn(
|
|
8566
|
-
{
|
|
8567
|
-
"
|
|
8415
|
+
{ companionVersion: COMPANION_VERSION, serverVersion },
|
|
8416
|
+
"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
8417
|
);
|
|
8569
8418
|
}
|
|
8570
8419
|
// Pull assignments and reconcile the live runner set against them. Re-entrancy
|
|
@@ -8587,7 +8436,7 @@ var BridgeSupervisor = class {
|
|
|
8587
8436
|
} catch (err) {
|
|
8588
8437
|
this.log.error(
|
|
8589
8438
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
8590
|
-
"
|
|
8439
|
+
"companion: assignments pull failed \u2014 check the device is still active in the cabane app"
|
|
8591
8440
|
);
|
|
8592
8441
|
this.hub.setDeviceError(err instanceof Error ? err.message : String(err));
|
|
8593
8442
|
return;
|
|
@@ -8652,21 +8501,21 @@ var BridgeSupervisor = class {
|
|
|
8652
8501
|
const credential = it.credential ?? getCredential(it.agentId);
|
|
8653
8502
|
const runConfig = parseRunConfig(
|
|
8654
8503
|
it.runConfig,
|
|
8655
|
-
(m) => this.log.warn({ agentId: it.agentId, msg: m }, "
|
|
8504
|
+
(m) => this.log.warn({ agentId: it.agentId, msg: m }, "companion: run-config")
|
|
8656
8505
|
);
|
|
8657
8506
|
const required = requiredSecretNames(runConfig.mcpServers);
|
|
8658
8507
|
const missing = required.filter((n) => !exposed.has(n));
|
|
8659
8508
|
if (!credential) {
|
|
8660
8509
|
this.log.error(
|
|
8661
8510
|
{ workspaceId, agentId: it.agentId, username: it.agentUsername },
|
|
8662
|
-
"
|
|
8511
|
+
"companion: agent assigned but no credential on this device \u2014 re-assign it in the cabane app"
|
|
8663
8512
|
);
|
|
8664
8513
|
this.removeAgent(wr, it.agentId);
|
|
8665
8514
|
this.hub.setAgent(workspaceId, {
|
|
8666
8515
|
agentId: it.agentId,
|
|
8667
8516
|
username: it.agentUsername,
|
|
8668
8517
|
displayName: it.agentDisplayName,
|
|
8669
|
-
mode: runConfig.
|
|
8518
|
+
mode: runConfig.hostAccess ? "full" : "none",
|
|
8670
8519
|
hasCredential: false,
|
|
8671
8520
|
missingSecrets: missing
|
|
8672
8521
|
});
|
|
@@ -8686,14 +8535,14 @@ var BridgeSupervisor = class {
|
|
|
8686
8535
|
agentId: it.agentId,
|
|
8687
8536
|
username: it.agentUsername,
|
|
8688
8537
|
displayName: it.agentDisplayName,
|
|
8689
|
-
mode: runConfig.
|
|
8538
|
+
mode: runConfig.hostAccess ? "full" : "none",
|
|
8690
8539
|
hasCredential: true,
|
|
8691
8540
|
missingSecrets: missing
|
|
8692
8541
|
});
|
|
8693
8542
|
if (missing.length > 0) {
|
|
8694
8543
|
this.log.warn(
|
|
8695
8544
|
{ workspaceId, agentId: it.agentId, missing },
|
|
8696
|
-
"
|
|
8545
|
+
"companion: agent needs secrets this device does not expose (turns using them will fail)"
|
|
8697
8546
|
);
|
|
8698
8547
|
}
|
|
8699
8548
|
}
|
|
@@ -8732,7 +8581,7 @@ var BridgeSupervisor = class {
|
|
|
8732
8581
|
drain2.kick();
|
|
8733
8582
|
this.log.info(
|
|
8734
8583
|
{ workspaceId: it.workspaceId, agentId: it.agentId, username: it.agentUsername },
|
|
8735
|
-
"
|
|
8584
|
+
"companion: running agent"
|
|
8736
8585
|
);
|
|
8737
8586
|
}
|
|
8738
8587
|
removeAgent(wr, agentId) {
|
|
@@ -8741,7 +8590,10 @@ var BridgeSupervisor = class {
|
|
|
8741
8590
|
runner.cancelDrain();
|
|
8742
8591
|
wr.agents.delete(agentId);
|
|
8743
8592
|
this.hub.removeAgent(wr.workspaceId, agentId);
|
|
8744
|
-
this.log.info(
|
|
8593
|
+
this.log.info(
|
|
8594
|
+
{ workspaceId: wr.workspaceId, agentId },
|
|
8595
|
+
"companion: stopped agent (unassigned)"
|
|
8596
|
+
);
|
|
8745
8597
|
}
|
|
8746
8598
|
buildDispatcher(ctx) {
|
|
8747
8599
|
if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
|
|
@@ -8767,12 +8619,9 @@ var BridgeSupervisor = class {
|
|
|
8767
8619
|
// CT481: register the codex adapter when this device offers codex; unset
|
|
8768
8620
|
// leaves an `openai/…` turn to fail loudly (no silent claude-code fallback).
|
|
8769
8621
|
...isCodexEnabled(this.config) ? { codexEnabled: true } : {},
|
|
8770
|
-
//
|
|
8771
|
-
// unset leaves a `cabane-native/…` turn to fail loudly (no silent fallback).
|
|
8772
|
-
...cabaneNativeApiKey() ? { cabaneNativeApiKey: cabaneNativeApiKey() } : {},
|
|
8773
|
-
// CT556: per-turn timeout watchdog windows, from the bridge's own env
|
|
8622
|
+
// CT556: per-turn timeout watchdog windows, from the companion's own env
|
|
8774
8623
|
// (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
|
|
8775
|
-
// dispatcher's baked-in defaults (10 min idle /
|
|
8624
|
+
// dispatcher's baked-in defaults (10 min idle / 6h total).
|
|
8776
8625
|
...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
|
|
8777
8626
|
...positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") !== void 0 ? { totalTimeoutMs: positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") } : {},
|
|
8778
8627
|
observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
|
|
@@ -8809,14 +8658,14 @@ var BridgeSupervisor = class {
|
|
|
8809
8658
|
this.hub.setAuthFailed(wr.workspaceId);
|
|
8810
8659
|
this.log.error(
|
|
8811
8660
|
{ workspaceId: wr.workspaceId, status: status2, sseAgentId: wr.sseAgentId },
|
|
8812
|
-
"
|
|
8661
|
+
"companion: workspace stream auth failed \u2014 re-pulling assignments"
|
|
8813
8662
|
);
|
|
8814
8663
|
wr.sseAgentId = null;
|
|
8815
8664
|
void this.refreshAssignments();
|
|
8816
8665
|
}
|
|
8817
8666
|
});
|
|
8818
8667
|
wr.sub.start();
|
|
8819
|
-
this.log.info({ workspaceId: wr.workspaceId }, "
|
|
8668
|
+
this.log.info({ workspaceId: wr.workspaceId }, "companion: subscribed");
|
|
8820
8669
|
}
|
|
8821
8670
|
async removeWorkspace(workspaceId) {
|
|
8822
8671
|
const wr = this.workspaces.get(workspaceId);
|
|
@@ -8847,9 +8696,9 @@ var BridgeSupervisor = class {
|
|
|
8847
8696
|
return;
|
|
8848
8697
|
}
|
|
8849
8698
|
if (ev.id) wr.cursor.observe(ev.id);
|
|
8850
|
-
if (wire.type === "
|
|
8699
|
+
if (wire.type === "device:cancel_requested") {
|
|
8851
8700
|
const payload2 = {
|
|
8852
|
-
type: "
|
|
8701
|
+
type: "device:cancel_requested",
|
|
8853
8702
|
...wire.payload
|
|
8854
8703
|
};
|
|
8855
8704
|
const agent2 = wr.agents.get(payload2.agentId);
|
|
@@ -8869,12 +8718,13 @@ var BridgeSupervisor = class {
|
|
|
8869
8718
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
8870
8719
|
return;
|
|
8871
8720
|
}
|
|
8872
|
-
if (wire.type !== "
|
|
8721
|
+
if (wire.type !== "device:dispatch_requested") {
|
|
8873
8722
|
if (ev.id) wr.cursor.settle(ev.id);
|
|
8874
8723
|
return;
|
|
8875
8724
|
}
|
|
8725
|
+
if (this.draining) return;
|
|
8876
8726
|
const payload = {
|
|
8877
|
-
type: "
|
|
8727
|
+
type: "device:dispatch_requested",
|
|
8878
8728
|
...wire.payload
|
|
8879
8729
|
};
|
|
8880
8730
|
let agent = wr.agents.get(payload.agentId);
|
|
@@ -8895,7 +8745,7 @@ var BridgeSupervisor = class {
|
|
|
8895
8745
|
agentId: payload.agentId,
|
|
8896
8746
|
err: err instanceof Error ? err.message : String(err)
|
|
8897
8747
|
},
|
|
8898
|
-
"
|
|
8748
|
+
"companion: conversation turn handler threw"
|
|
8899
8749
|
);
|
|
8900
8750
|
});
|
|
8901
8751
|
wr.chains.set(chainKey, tail);
|
|
@@ -8923,7 +8773,7 @@ var BridgeSupervisor = class {
|
|
|
8923
8773
|
if (hasCompleted(workspaceId, ev.id)) {
|
|
8924
8774
|
this.log.info(
|
|
8925
8775
|
{ workspaceId, eventId: ev.id },
|
|
8926
|
-
"
|
|
8776
|
+
"companion: skipping already-completed event (resume after restart)"
|
|
8927
8777
|
);
|
|
8928
8778
|
wr.cursor.settle(ev.id);
|
|
8929
8779
|
return;
|
|
@@ -8931,7 +8781,7 @@ var BridgeSupervisor = class {
|
|
|
8931
8781
|
if (noResume()) {
|
|
8932
8782
|
this.log.warn(
|
|
8933
8783
|
{ workspaceId, eventId: ev.id },
|
|
8934
|
-
"
|
|
8784
|
+
"companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
|
|
8935
8785
|
);
|
|
8936
8786
|
markCompleted(workspaceId, ev.id);
|
|
8937
8787
|
wr.cursor.settle(ev.id);
|
|
@@ -8941,7 +8791,7 @@ var BridgeSupervisor = class {
|
|
|
8941
8791
|
if (attempt > MAX_RESUME_ATTEMPTS) {
|
|
8942
8792
|
this.log.error(
|
|
8943
8793
|
{ workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
8944
|
-
"
|
|
8794
|
+
"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
8795
|
);
|
|
8946
8796
|
markCompleted(workspaceId, ev.id);
|
|
8947
8797
|
wr.cursor.settle(ev.id);
|
|
@@ -8949,7 +8799,7 @@ var BridgeSupervisor = class {
|
|
|
8949
8799
|
}
|
|
8950
8800
|
this.log.info(
|
|
8951
8801
|
{ workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
|
|
8952
|
-
"
|
|
8802
|
+
"companion: re-dispatching interrupted turn (resume after restart)"
|
|
8953
8803
|
);
|
|
8954
8804
|
}
|
|
8955
8805
|
if (ev.id) markDispatched(workspaceId, ev.id);
|
|
@@ -8989,7 +8839,7 @@ var BridgeSupervisor = class {
|
|
|
8989
8839
|
drainDelay = Math.min(drainDelay * 2, DRAIN_MAX_MS);
|
|
8990
8840
|
this.log.warn(
|
|
8991
8841
|
{ agentId, err: err instanceof Error ? err.message : String(err) },
|
|
8992
|
-
"
|
|
8842
|
+
"companion: outbox drain pass threw (will retry with backoff)"
|
|
8993
8843
|
);
|
|
8994
8844
|
} finally {
|
|
8995
8845
|
if (!drainStopped) {
|
|
@@ -9058,7 +8908,7 @@ var BridgeSupervisor = class {
|
|
|
9058
8908
|
} catch (err) {
|
|
9059
8909
|
this.log.warn(
|
|
9060
8910
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
9061
|
-
"
|
|
8911
|
+
"companion: harness probe failed (will retry on next beat)"
|
|
9062
8912
|
);
|
|
9063
8913
|
}
|
|
9064
8914
|
}
|
|
@@ -9083,7 +8933,7 @@ var BridgeSupervisor = class {
|
|
|
9083
8933
|
next = { ...this.config, codex: { enabled: true } };
|
|
9084
8934
|
} else {
|
|
9085
8935
|
const serverUrl = input.serverUrl.trim();
|
|
9086
|
-
const parsed =
|
|
8936
|
+
const parsed = companionConfigSchema.shape.opencode.safeParse({ serverUrl });
|
|
9087
8937
|
if (!parsed.success) {
|
|
9088
8938
|
return {
|
|
9089
8939
|
ok: false,
|
|
@@ -9104,7 +8954,7 @@ var BridgeSupervisor = class {
|
|
|
9104
8954
|
saveConfig(next);
|
|
9105
8955
|
this.rebuildDispatchers();
|
|
9106
8956
|
await this.refreshHarnessStatuses();
|
|
9107
|
-
|
|
8957
|
+
this.kickHeartbeat();
|
|
9108
8958
|
return { ok: true };
|
|
9109
8959
|
}
|
|
9110
8960
|
// Re-create every running agent's Dispatcher from the CURRENT config, keeping
|
|
@@ -9140,6 +8990,41 @@ var BridgeSupervisor = class {
|
|
|
9140
8990
|
[...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
|
|
9141
8991
|
);
|
|
9142
8992
|
}
|
|
8993
|
+
async drainForRestart(graceMs) {
|
|
8994
|
+
this.draining = true;
|
|
8995
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
8996
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
8997
|
+
if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
|
|
8998
|
+
if (!this.deviceApi) throw new Error("cannot establish deploy drain before pairing");
|
|
8999
|
+
await this.deviceApi.beginDrain();
|
|
9000
|
+
for (const wr of this.workspaces.values()) wr.sub?.stop();
|
|
9001
|
+
const turns = [...this.workspaces.values()].flatMap((wr) => [...wr.chains.values()]);
|
|
9002
|
+
let timedOut = false;
|
|
9003
|
+
if (turns.length > 0) {
|
|
9004
|
+
let timer;
|
|
9005
|
+
await Promise.race([
|
|
9006
|
+
Promise.allSettled(turns),
|
|
9007
|
+
new Promise((resolve) => {
|
|
9008
|
+
timer = setTimeout(
|
|
9009
|
+
() => {
|
|
9010
|
+
timedOut = true;
|
|
9011
|
+
resolve();
|
|
9012
|
+
},
|
|
9013
|
+
Math.max(0, graceMs)
|
|
9014
|
+
);
|
|
9015
|
+
timer.unref?.();
|
|
9016
|
+
})
|
|
9017
|
+
]);
|
|
9018
|
+
if (timer) clearTimeout(timer);
|
|
9019
|
+
}
|
|
9020
|
+
await Promise.allSettled(
|
|
9021
|
+
[...this.workspaces.values()].flatMap(
|
|
9022
|
+
(wr) => [...wr.agents.values()].map((agent) => agent.api.drainOutbox())
|
|
9023
|
+
)
|
|
9024
|
+
);
|
|
9025
|
+
await this.shutdown();
|
|
9026
|
+
return { drained: !timedOut };
|
|
9027
|
+
}
|
|
9143
9028
|
async requestStop() {
|
|
9144
9029
|
await this.shutdown();
|
|
9145
9030
|
this.exitFn(0);
|
|
@@ -9211,25 +9096,25 @@ function handleUncaught(log, err, origin) {
|
|
|
9211
9096
|
if (isRecoverableSocketError(err)) {
|
|
9212
9097
|
log.warn(
|
|
9213
9098
|
{ origin, code, err: message },
|
|
9214
|
-
"
|
|
9099
|
+
"companion: recovered from a socket error (kept running \u2014 a broken pipe never kills the companion)"
|
|
9215
9100
|
);
|
|
9216
9101
|
return;
|
|
9217
9102
|
}
|
|
9218
9103
|
log.error(
|
|
9219
9104
|
{ origin, code, err: message, stack: err instanceof Error ? err.stack : void 0 },
|
|
9220
|
-
"
|
|
9105
|
+
"companion: uncaught error (kept running \u2014 see the stack above)"
|
|
9221
9106
|
);
|
|
9222
9107
|
}
|
|
9223
9108
|
|
|
9224
9109
|
// src/crash-marker.ts
|
|
9225
|
-
import { existsSync as existsSync11, mkdirSync as
|
|
9226
|
-
import { join as
|
|
9110
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync12, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
|
|
9111
|
+
import { join as join14 } from "path";
|
|
9227
9112
|
function crashMarkerPath() {
|
|
9228
|
-
return
|
|
9113
|
+
return join14(cabaneDir(), "last-error.json");
|
|
9229
9114
|
}
|
|
9230
9115
|
function recordCrash(rec2) {
|
|
9231
9116
|
try {
|
|
9232
|
-
|
|
9117
|
+
mkdirSync12(cabaneDir(), { recursive: true });
|
|
9233
9118
|
writeFileSync8(crashMarkerPath(), JSON.stringify(rec2, null, 2) + "\n");
|
|
9234
9119
|
} catch {
|
|
9235
9120
|
}
|
|
@@ -9243,7 +9128,7 @@ function clearCrash() {
|
|
|
9243
9128
|
}
|
|
9244
9129
|
|
|
9245
9130
|
// src/runtime.ts
|
|
9246
|
-
async function
|
|
9131
|
+
async function createCompanionRuntime(opts = {}) {
|
|
9247
9132
|
const log = getLogger();
|
|
9248
9133
|
installProcessSafetyNet(log);
|
|
9249
9134
|
const probeClaude = opts.probeClaude ?? claudeOnPath;
|
|
@@ -9277,23 +9162,29 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
9277
9162
|
url: "",
|
|
9278
9163
|
port: 0,
|
|
9279
9164
|
startedAt,
|
|
9280
|
-
daemon: process.env.
|
|
9165
|
+
daemon: process.env.CABANE_COMPANION_DAEMON === "1",
|
|
9281
9166
|
instanceId
|
|
9282
9167
|
});
|
|
9283
9168
|
if (!claim.acquired) {
|
|
9284
9169
|
return { ok: false, reason: "already-running", existing: claim.existing ?? null };
|
|
9285
9170
|
}
|
|
9286
9171
|
process.on("exit", () => clearRuntimeState());
|
|
9287
|
-
const hub = new
|
|
9172
|
+
const hub = new CompanionStateHub({
|
|
9288
9173
|
// CT29: one device, one base URL — the cabane instance this device is paired
|
|
9289
9174
|
// with. The dashboard's connection line shows it.
|
|
9290
9175
|
baseUrl: cfg.baseUrl,
|
|
9291
|
-
|
|
9176
|
+
companionVersion: COMPANION_VERSION,
|
|
9292
9177
|
// SJ516 F4: surfaced on `/api/status` so `stop`/`status` can confirm the
|
|
9293
|
-
// process behind the marker pid is this
|
|
9178
|
+
// process behind the marker pid is this companion (not a recycled pid).
|
|
9294
9179
|
instanceId
|
|
9295
9180
|
});
|
|
9296
|
-
const supervisor = new
|
|
9181
|
+
const supervisor = new CompanionSupervisor({
|
|
9182
|
+
config: cfg,
|
|
9183
|
+
log,
|
|
9184
|
+
hub,
|
|
9185
|
+
claudeCode,
|
|
9186
|
+
harnessVersions
|
|
9187
|
+
});
|
|
9297
9188
|
await supervisor.start();
|
|
9298
9189
|
const preferredPort = opts.port ?? cfg.dashboardPort;
|
|
9299
9190
|
const dashboard = await startDashboard({
|
|
@@ -9308,9 +9199,9 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
9308
9199
|
port: dashboard.port,
|
|
9309
9200
|
startedAt,
|
|
9310
9201
|
// SJ495: the daemon launcher sets this env on the detached child, so the
|
|
9311
|
-
// marker records whether this
|
|
9202
|
+
// marker records whether this companion is backgrounded (foreground start
|
|
9312
9203
|
// leaves it unset → false).
|
|
9313
|
-
daemon: process.env.
|
|
9204
|
+
daemon: process.env.CABANE_COMPANION_DAEMON === "1",
|
|
9314
9205
|
instanceId
|
|
9315
9206
|
});
|
|
9316
9207
|
clearCrash();
|
|
@@ -9327,20 +9218,34 @@ async function createBridgeRuntime(opts = {}) {
|
|
|
9327
9218
|
};
|
|
9328
9219
|
return {
|
|
9329
9220
|
ok: true,
|
|
9330
|
-
runtime: {
|
|
9221
|
+
runtime: {
|
|
9222
|
+
url: dashboard.url,
|
|
9223
|
+
port: dashboard.port,
|
|
9224
|
+
config: cfg,
|
|
9225
|
+
stop: stop2,
|
|
9226
|
+
drainForRestart: async (graceMs) => {
|
|
9227
|
+
clearRuntimeState();
|
|
9228
|
+
const result = await supervisor.drainForRestart(graceMs);
|
|
9229
|
+
await dashboard.close();
|
|
9230
|
+
stopped = true;
|
|
9231
|
+
return result;
|
|
9232
|
+
}
|
|
9233
|
+
}
|
|
9331
9234
|
};
|
|
9332
9235
|
}
|
|
9333
9236
|
|
|
9334
9237
|
// src/commands/start.ts
|
|
9335
9238
|
var FORCE_EXIT_MS = 4e3;
|
|
9239
|
+
var DEPLOY_REEXEC_EXIT = 75;
|
|
9240
|
+
var DEPLOY_GRACE_MS = 6.5 * 60 * 60 * 1e3;
|
|
9336
9241
|
async function start(opts = {}) {
|
|
9337
|
-
const result = await
|
|
9242
|
+
const result = await createCompanionRuntime({
|
|
9338
9243
|
...opts.port !== void 0 ? { port: opts.port } : {}
|
|
9339
9244
|
});
|
|
9340
9245
|
if (!result.ok) {
|
|
9341
9246
|
const existing = result.existing;
|
|
9342
9247
|
process.stdout.write(
|
|
9343
|
-
`Cabane
|
|
9248
|
+
`Cabane Companion is already running (pid ${existing?.pid ?? "?"}).
|
|
9344
9249
|
` + (existing?.url ? `\u2192 Dashboard: ${existing.url}
|
|
9345
9250
|
` : "") + `Stop it first with \`cabane-companion stop\` if you want to relaunch.
|
|
9346
9251
|
`
|
|
@@ -9349,14 +9254,14 @@ async function start(opts = {}) {
|
|
|
9349
9254
|
}
|
|
9350
9255
|
const runtime = result.runtime;
|
|
9351
9256
|
process.stdout.write(`
|
|
9352
|
-
Cabane
|
|
9257
|
+
Cabane Companion is running.
|
|
9353
9258
|
`);
|
|
9354
9259
|
process.stdout.write(`\u2192 Dashboard: ${runtime.url}
|
|
9355
9260
|
|
|
9356
9261
|
`);
|
|
9357
9262
|
if (!runtime.config.deviceToken) {
|
|
9358
9263
|
process.stdout.write(
|
|
9359
|
-
`This device isn't paired yet \u2014 run \`cabane-companion pair
|
|
9264
|
+
`This device isn't paired yet \u2014 run \`cabane-companion pair\`, then confirm the short code in Settings \u2192 Connectors.
|
|
9360
9265
|
|
|
9361
9266
|
`
|
|
9362
9267
|
);
|
|
@@ -9371,16 +9276,16 @@ Cabane Bridge is running.
|
|
|
9371
9276
|
const shutdown = async (signal) => {
|
|
9372
9277
|
if (shuttingDown) {
|
|
9373
9278
|
process.stdout.write(`
|
|
9374
|
-
|
|
9279
|
+
companion: second ${signal}, force-quitting.
|
|
9375
9280
|
`);
|
|
9376
9281
|
process.exit(1);
|
|
9377
9282
|
}
|
|
9378
9283
|
shuttingDown = true;
|
|
9379
9284
|
process.stdout.write(`
|
|
9380
|
-
|
|
9285
|
+
companion: received ${signal}, shutting down\u2026
|
|
9381
9286
|
`);
|
|
9382
9287
|
const forceExit = setTimeout(() => {
|
|
9383
|
-
process.stdout.write(`
|
|
9288
|
+
process.stdout.write(`companion: shutdown timed out, force-quitting.
|
|
9384
9289
|
`);
|
|
9385
9290
|
process.exit(1);
|
|
9386
9291
|
}, FORCE_EXIT_MS);
|
|
@@ -9392,6 +9297,31 @@ bridge: received ${signal}, shutting down\u2026
|
|
|
9392
9297
|
};
|
|
9393
9298
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
9394
9299
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
9300
|
+
process.on("SIGUSR2", () => {
|
|
9301
|
+
if (shuttingDown) return;
|
|
9302
|
+
shuttingDown = true;
|
|
9303
|
+
const configuredGrace = Number.parseInt(
|
|
9304
|
+
process.env.CABANE_COMPANION_DEPLOY_GRACE_MS ?? String(DEPLOY_GRACE_MS),
|
|
9305
|
+
10
|
|
9306
|
+
);
|
|
9307
|
+
const graceMs = Number.isFinite(configuredGrace) ? configuredGrace : DEPLOY_GRACE_MS;
|
|
9308
|
+
process.stdout.write(`
|
|
9309
|
+
companion: deploy drain requested (${graceMs}ms grace)\u2026
|
|
9310
|
+
`);
|
|
9311
|
+
void runtime.drainForRestart(graceMs).then(({ drained }) => {
|
|
9312
|
+
process.stdout.write(
|
|
9313
|
+
drained ? "companion: deploy drain complete; re-execing onto the new dist.\n" : "companion: deploy grace expired; re-execing \u2014 unfinished turns resume after restart.\n"
|
|
9314
|
+
);
|
|
9315
|
+
resolve();
|
|
9316
|
+
process.exit(DEPLOY_REEXEC_EXIT);
|
|
9317
|
+
}).catch((err) => {
|
|
9318
|
+
process.stderr.write(
|
|
9319
|
+
`companion: deploy drain failed: ${err instanceof Error ? err.message : String(err)}
|
|
9320
|
+
`
|
|
9321
|
+
);
|
|
9322
|
+
process.exit(DEPLOY_REEXEC_EXIT);
|
|
9323
|
+
});
|
|
9324
|
+
});
|
|
9395
9325
|
});
|
|
9396
9326
|
}
|
|
9397
9327
|
|
|
@@ -9401,7 +9331,7 @@ async function status() {
|
|
|
9401
9331
|
const cfg = loadConfig();
|
|
9402
9332
|
if (!cfg || !cfg.deviceToken) {
|
|
9403
9333
|
process.stdout.write(
|
|
9404
|
-
"
|
|
9334
|
+
"companion: not paired. Run `cabane-companion pair`, then confirm the short code in Settings \u2192 Connectors.\n"
|
|
9405
9335
|
);
|
|
9406
9336
|
process.exitCode = 1;
|
|
9407
9337
|
return;
|
|
@@ -9410,7 +9340,7 @@ async function status() {
|
|
|
9410
9340
|
`);
|
|
9411
9341
|
if (cfg.deviceLabel) process.stdout.write(`device: ${cfg.deviceLabel}
|
|
9412
9342
|
`);
|
|
9413
|
-
process.stdout.write(`log file: ${
|
|
9343
|
+
process.stdout.write(`log file: ${companionLogPath()}
|
|
9414
9344
|
`);
|
|
9415
9345
|
process.stdout.write(`transcripts: ${transcriptsLine()}
|
|
9416
9346
|
`);
|
|
@@ -9423,7 +9353,7 @@ async function status() {
|
|
|
9423
9353
|
const mode = running.daemon ? "background" : "foreground";
|
|
9424
9354
|
const uptime = formatUptime(running.startedAt);
|
|
9425
9355
|
process.stdout.write(
|
|
9426
|
-
`
|
|
9356
|
+
`companion: running in ${mode} (pid ${running.pid}${uptime ? `, up ${uptime}` : ""})
|
|
9427
9357
|
`
|
|
9428
9358
|
);
|
|
9429
9359
|
process.stdout.write(`dashboard: ${running.url} (assigned agents + run state live here)
|
|
@@ -9432,7 +9362,7 @@ async function status() {
|
|
|
9432
9362
|
`);
|
|
9433
9363
|
} else {
|
|
9434
9364
|
process.stdout.write(
|
|
9435
|
-
`
|
|
9365
|
+
`companion: not running \u2014 \`cabane-companion start\` (foreground) or \`cabane-companion start --daemon\` (background)
|
|
9436
9366
|
`
|
|
9437
9367
|
);
|
|
9438
9368
|
}
|
|
@@ -9486,40 +9416,42 @@ async function stop(deps = {}) {
|
|
|
9486
9416
|
const now = deps.now ?? (() => Date.now());
|
|
9487
9417
|
const state = readState();
|
|
9488
9418
|
if (!state) {
|
|
9489
|
-
process.stdout.write("
|
|
9419
|
+
process.stdout.write("companion: not running (nothing to stop).\n");
|
|
9490
9420
|
return;
|
|
9491
9421
|
}
|
|
9492
9422
|
if (await verify(state) === "stale") {
|
|
9493
9423
|
clearRuntimeState();
|
|
9494
|
-
process.stdout.write("
|
|
9424
|
+
process.stdout.write("companion: not running (stale marker swept).\n");
|
|
9495
9425
|
return;
|
|
9496
9426
|
}
|
|
9497
9427
|
const { pid } = state;
|
|
9498
|
-
process.stdout.write(`
|
|
9428
|
+
process.stdout.write(`companion: stopping (pid ${pid})\u2026
|
|
9499
9429
|
`);
|
|
9500
9430
|
try {
|
|
9501
9431
|
kill(pid, "SIGTERM");
|
|
9502
9432
|
} catch {
|
|
9503
9433
|
clearRuntimeState();
|
|
9504
|
-
process.stdout.write("
|
|
9434
|
+
process.stdout.write("companion: already stopped.\n");
|
|
9505
9435
|
return;
|
|
9506
9436
|
}
|
|
9507
9437
|
const deadline = now() + TERM_GRACE_MS;
|
|
9508
9438
|
while (now() < deadline) {
|
|
9509
9439
|
if (!isAlive(kill, pid)) {
|
|
9510
|
-
process.stdout.write("
|
|
9440
|
+
process.stdout.write("companion: stopped.\n");
|
|
9511
9441
|
return;
|
|
9512
9442
|
}
|
|
9513
9443
|
await sleep4(POLL_INTERVAL_MS2);
|
|
9514
9444
|
}
|
|
9515
|
-
process.stdout.write(
|
|
9516
|
-
`
|
|
9445
|
+
process.stdout.write(
|
|
9446
|
+
`companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
|
|
9447
|
+
`
|
|
9448
|
+
);
|
|
9517
9449
|
try {
|
|
9518
9450
|
kill(pid, "SIGKILL");
|
|
9519
9451
|
} catch {
|
|
9520
9452
|
}
|
|
9521
9453
|
clearRuntimeState();
|
|
9522
|
-
process.stdout.write("
|
|
9454
|
+
process.stdout.write("companion: force-stopped.\n");
|
|
9523
9455
|
}
|
|
9524
9456
|
function isAlive(kill, pid) {
|
|
9525
9457
|
try {
|
|
@@ -9531,8 +9463,8 @@ function isAlive(kill, pid) {
|
|
|
9531
9463
|
}
|
|
9532
9464
|
|
|
9533
9465
|
// src/commands/transcript.ts
|
|
9534
|
-
import { existsSync as existsSync12, readFileSync as
|
|
9535
|
-
import { isAbsolute, join as
|
|
9466
|
+
import { existsSync as existsSync12, readFileSync as readFileSync10, readdirSync as readdirSync4 } from "fs";
|
|
9467
|
+
import { isAbsolute, join as join15 } from "path";
|
|
9536
9468
|
async function transcript(opts = {}) {
|
|
9537
9469
|
const dir2 = transcriptsDir();
|
|
9538
9470
|
if (opts.follow) {
|
|
@@ -9549,7 +9481,7 @@ async function transcript(opts = {}) {
|
|
|
9549
9481
|
process.stdout.write(emptyMessage(dir2));
|
|
9550
9482
|
return;
|
|
9551
9483
|
}
|
|
9552
|
-
process.stdout.write(renderFile(
|
|
9484
|
+
process.stdout.write(renderFile(join15(dir2, newest)) + "\n");
|
|
9553
9485
|
return;
|
|
9554
9486
|
}
|
|
9555
9487
|
printList(dir2);
|
|
@@ -9625,14 +9557,14 @@ var TranscriptFollower = class {
|
|
|
9625
9557
|
function isComplete(content) {
|
|
9626
9558
|
for (const line of content.split("\n")) {
|
|
9627
9559
|
if (!line.trim()) continue;
|
|
9628
|
-
if (
|
|
9560
|
+
if (str2(rec(safeParse(line))?.type) === "_outcome") return true;
|
|
9629
9561
|
}
|
|
9630
9562
|
return false;
|
|
9631
9563
|
}
|
|
9632
9564
|
async function followTranscripts(dir2) {
|
|
9633
9565
|
const follower = new TranscriptFollower({
|
|
9634
9566
|
listFiles: () => listFiles(dir2),
|
|
9635
|
-
read: (f) =>
|
|
9567
|
+
read: (f) => readFileSync10(join15(dir2, f), "utf8"),
|
|
9636
9568
|
write: (s) => process.stdout.write(s),
|
|
9637
9569
|
// CSI: cursor up `n` lines, then erase from cursor to end of screen.
|
|
9638
9570
|
clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
|
|
@@ -9672,15 +9604,15 @@ function printList(dir2) {
|
|
|
9672
9604
|
|
|
9673
9605
|
`);
|
|
9674
9606
|
for (const f of files.slice(0, 20)) {
|
|
9675
|
-
const { meta, outcome } = peek(
|
|
9607
|
+
const { meta, outcome } = peek(join15(dir2, f));
|
|
9676
9608
|
const when = fmtTime(rec(meta)?.ts);
|
|
9677
|
-
const ws =
|
|
9609
|
+
const ws = str2(rec(meta)?.workspaceSlug);
|
|
9678
9610
|
const o = rec(outcome);
|
|
9679
|
-
const verdict = o ? o.ok === true ? "ok" : `ERROR${
|
|
9611
|
+
const verdict = o ? o.ok === true ? "ok" : `ERROR${str2(o.reason) ? ` (${str2(o.reason)})` : ""}` : "\u2026";
|
|
9680
9612
|
process.stdout.write(
|
|
9681
9613
|
` ${f}
|
|
9682
9614
|
${when} \xB7 ${ws} \xB7 ${verdict}
|
|
9683
|
-
\u201C${excerpt(
|
|
9615
|
+
\u201C${excerpt(str2(rec(meta)?.message), 70)}\u201D
|
|
9684
9616
|
|
|
9685
9617
|
`
|
|
9686
9618
|
);
|
|
@@ -9693,10 +9625,10 @@ function peek(path3) {
|
|
|
9693
9625
|
let meta;
|
|
9694
9626
|
let outcome;
|
|
9695
9627
|
try {
|
|
9696
|
-
for (const line of
|
|
9628
|
+
for (const line of readFileSync10(path3, "utf8").split("\n")) {
|
|
9697
9629
|
if (!line.trim()) continue;
|
|
9698
9630
|
const o = safeParse(line);
|
|
9699
|
-
const t =
|
|
9631
|
+
const t = str2(rec(o)?.type);
|
|
9700
9632
|
if (t === "_meta") meta = o;
|
|
9701
9633
|
else if (t === "_outcome") outcome = o;
|
|
9702
9634
|
}
|
|
@@ -9707,18 +9639,18 @@ function peek(path3) {
|
|
|
9707
9639
|
function resolveTarget(dir2, target) {
|
|
9708
9640
|
if (isAbsolute(target) || target.includes("/")) {
|
|
9709
9641
|
if (existsSync12(target)) return target;
|
|
9710
|
-
throw new
|
|
9642
|
+
throw new CompanionError(`no transcript at ${target}.`);
|
|
9711
9643
|
}
|
|
9712
|
-
const exact =
|
|
9644
|
+
const exact = join15(dir2, target);
|
|
9713
9645
|
if (existsSync12(exact)) return exact;
|
|
9714
9646
|
const matches = listFiles(dir2).filter((f) => f.includes(target));
|
|
9715
|
-
if (matches.length === 1) return
|
|
9647
|
+
if (matches.length === 1) return join15(dir2, matches[0]);
|
|
9716
9648
|
if (matches.length === 0) {
|
|
9717
|
-
throw new
|
|
9649
|
+
throw new CompanionError(
|
|
9718
9650
|
`no transcript matching "${target}" in ${dir2}. Run \`cabane-companion transcript\` to list them.`
|
|
9719
9651
|
);
|
|
9720
9652
|
}
|
|
9721
|
-
throw new
|
|
9653
|
+
throw new CompanionError(
|
|
9722
9654
|
`"${target}" matches ${matches.length} transcripts \u2014 be more specific:
|
|
9723
9655
|
` + matches.slice(0, 10).map((m) => ` ${m}`).join("\n")
|
|
9724
9656
|
);
|
|
@@ -9726,9 +9658,9 @@ function resolveTarget(dir2, target) {
|
|
|
9726
9658
|
function renderFile(path3) {
|
|
9727
9659
|
let content;
|
|
9728
9660
|
try {
|
|
9729
|
-
content =
|
|
9661
|
+
content = readFileSync10(path3, "utf8");
|
|
9730
9662
|
} catch (err) {
|
|
9731
|
-
throw new
|
|
9663
|
+
throw new CompanionError(
|
|
9732
9664
|
`couldn't read ${path3}: ${err instanceof Error ? err.message : String(err)}`
|
|
9733
9665
|
);
|
|
9734
9666
|
}
|
|
@@ -9741,19 +9673,19 @@ function renderTranscript(jsonlLines) {
|
|
|
9741
9673
|
if (!raw.trim()) continue;
|
|
9742
9674
|
const obj = rec(safeParse(raw));
|
|
9743
9675
|
if (!obj) continue;
|
|
9744
|
-
switch (
|
|
9676
|
+
switch (str2(obj.type)) {
|
|
9745
9677
|
case "_meta":
|
|
9746
9678
|
out.push(
|
|
9747
|
-
`${fmtTime(obj.ts)} \xB7 workspace=${
|
|
9679
|
+
`${fmtTime(obj.ts)} \xB7 workspace=${str2(obj.workspaceSlug)} \xB7 conversation=${str2(obj.conversationId)}`
|
|
9748
9680
|
);
|
|
9749
|
-
out.push("", `> USER: ${
|
|
9681
|
+
out.push("", `> USER: ${str2(obj.message)}`, "");
|
|
9750
9682
|
break;
|
|
9751
9683
|
case "system":
|
|
9752
|
-
if (
|
|
9753
|
-
out.push(`[session ${
|
|
9684
|
+
if (str2(obj.subtype) === "init") {
|
|
9685
|
+
out.push(`[session ${str2(obj.session_id) || "?"} \xB7 model ${str2(obj.model) || "?"}]`);
|
|
9754
9686
|
const servers = Array.isArray(obj.mcp_servers) ? obj.mcp_servers.map((s) => {
|
|
9755
9687
|
const r = rec(s);
|
|
9756
|
-
return r ? `${
|
|
9688
|
+
return r ? `${str2(r.name) || "?"}${str2(r.status) ? `(${str2(r.status)})` : ""}` : "";
|
|
9757
9689
|
}).filter(Boolean).join(", ") : "";
|
|
9758
9690
|
if (servers) out.push(` MCP servers: ${servers}`);
|
|
9759
9691
|
if (Array.isArray(obj.tools)) out.push(` tools: ${obj.tools.length} available`);
|
|
@@ -9779,8 +9711,8 @@ function renderTranscript(jsonlLines) {
|
|
|
9779
9711
|
if (!Array.isArray(content)) break;
|
|
9780
9712
|
for (const b of content) {
|
|
9781
9713
|
const block = rec(b);
|
|
9782
|
-
if (!block ||
|
|
9783
|
-
const id =
|
|
9714
|
+
if (!block || str2(block.type) !== "tool_result") continue;
|
|
9715
|
+
const id = str2(block.tool_use_id);
|
|
9784
9716
|
const label = pending.get(id) ?? "tool";
|
|
9785
9717
|
pending.delete(id);
|
|
9786
9718
|
const tag = block.is_error === true ? "ERROR" : "ok";
|
|
@@ -9789,18 +9721,18 @@ function renderTranscript(jsonlLines) {
|
|
|
9789
9721
|
break;
|
|
9790
9722
|
}
|
|
9791
9723
|
case "result": {
|
|
9792
|
-
const isErr = obj.is_error === true ||
|
|
9724
|
+
const isErr = obj.is_error === true || str2(obj.subtype) !== "success";
|
|
9793
9725
|
const dur = typeof obj.duration_ms === "number" ? ` \xB7 ${obj.duration_ms}ms` : "";
|
|
9794
9726
|
out.push(
|
|
9795
|
-
`[result ${isErr ? "error" : "ok"}${
|
|
9727
|
+
`[result ${isErr ? "error" : "ok"}${str2(obj.subtype) ? ` \xB7 ${str2(obj.subtype)}` : ""}${dur}]`
|
|
9796
9728
|
);
|
|
9797
|
-
if (isErr &&
|
|
9729
|
+
if (isErr && str2(obj.result).trim()) out.push(` ${indent(str2(obj.result))}`);
|
|
9798
9730
|
break;
|
|
9799
9731
|
}
|
|
9800
9732
|
case "_outcome": {
|
|
9801
9733
|
const dur = typeof obj.durationMs === "number" ? ` \xB7 ${obj.durationMs}ms` : "";
|
|
9802
9734
|
out.push(
|
|
9803
|
-
`[outcome ${obj.ok === true ? "ok" : "error"}${
|
|
9735
|
+
`[outcome ${obj.ok === true ? "ok" : "error"}${str2(obj.reason) ? ` \xB7 ${str2(obj.reason)}` : ""}${dur}]`
|
|
9804
9736
|
);
|
|
9805
9737
|
break;
|
|
9806
9738
|
}
|
|
@@ -9818,7 +9750,7 @@ function safeParse(s) {
|
|
|
9818
9750
|
function rec(v) {
|
|
9819
9751
|
return v && typeof v === "object" ? v : null;
|
|
9820
9752
|
}
|
|
9821
|
-
function
|
|
9753
|
+
function str2(v) {
|
|
9822
9754
|
return typeof v === "string" ? v : "";
|
|
9823
9755
|
}
|
|
9824
9756
|
function fmtTime(ts) {
|
|
@@ -9854,25 +9786,17 @@ They are written per dispatch while \`cabane-companion start\` is running.
|
|
|
9854
9786
|
var program = new Command();
|
|
9855
9787
|
program.name("cabane-companion").description(
|
|
9856
9788
|
"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(
|
|
9789
|
+
).version(COMPANION_VERSION);
|
|
9858
9790
|
program.command("pair").description(
|
|
9859
9791
|
"pair this device with cabane \u2014 shows a short code you confirm in Settings \u2192 Devices."
|
|
9860
|
-
).
|
|
9861
|
-
|
|
9862
|
-
|
|
9863
|
-
)
|
|
9864
|
-
|
|
9865
|
-
|
|
9866
|
-
)
|
|
9867
|
-
|
|
9868
|
-
await pair({
|
|
9869
|
-
...pairingString !== void 0 ? { arg: pairingString } : {},
|
|
9870
|
-
...opts.server !== void 0 ? { server: opts.server } : {},
|
|
9871
|
-
...opts.legacy ? { legacy: true } : {},
|
|
9872
|
-
...opts.file !== void 0 ? { file: opts.file } : {}
|
|
9873
|
-
});
|
|
9874
|
-
}
|
|
9875
|
-
);
|
|
9792
|
+
).allowExcessArguments(false).option("--server <url>", "the cabane instance to pair with (default https://cabane.ai)").action(async (opts) => {
|
|
9793
|
+
await pair({
|
|
9794
|
+
...opts.server !== void 0 ? { server: opts.server } : {}
|
|
9795
|
+
});
|
|
9796
|
+
});
|
|
9797
|
+
program.command("write-paired-config", { hidden: true }).description("persist an already-completed device enrollment payload from stdin.").action(() => {
|
|
9798
|
+
writeCompletedPairing(readFileSync11(0, "utf8"));
|
|
9799
|
+
});
|
|
9876
9800
|
program.command("start").description("pull this device\u2019s assigned agents from cabane and run them.").option("--open", "open the dashboard in a browser on startup (default: off)").option("--no-open", "don't auto-open the dashboard (overrides config.autoOpen)").option("--daemon", "run detached in the background (terminal returns; replies keep landing)").option("--port <port>", "dashboard port (default 7474; falls through if taken)", parsePort).action(async (opts) => {
|
|
9877
9801
|
if (opts.daemon) {
|
|
9878
9802
|
await startDaemon({
|
|
@@ -9887,10 +9811,10 @@ program.command("start").description("pull this device\u2019s assigned agents fr
|
|
|
9887
9811
|
...opts.port !== void 0 ? { port: opts.port } : {}
|
|
9888
9812
|
});
|
|
9889
9813
|
});
|
|
9890
|
-
program.command("stop").description("stop a running
|
|
9814
|
+
program.command("stop").description("stop a running companion (SIGTERM, then force-kill after a timeout).").action(async () => {
|
|
9891
9815
|
await stop();
|
|
9892
9816
|
});
|
|
9893
|
-
program.command("status").description("print the
|
|
9817
|
+
program.command("status").description("print the companion's local state (pairing, secrets, log path).").action(async () => {
|
|
9894
9818
|
await status();
|
|
9895
9819
|
});
|
|
9896
9820
|
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,12 +9838,12 @@ program.command("logout").description(
|
|
|
9914
9838
|
function parsePort(raw) {
|
|
9915
9839
|
const n = Number(raw);
|
|
9916
9840
|
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
9917
|
-
throw new
|
|
9841
|
+
throw new CompanionError(`invalid --port "${raw}": expected an integer between 1 and 65535.`);
|
|
9918
9842
|
}
|
|
9919
9843
|
return n;
|
|
9920
9844
|
}
|
|
9921
9845
|
program.parseAsync(process.argv).catch((err) => {
|
|
9922
|
-
if (err instanceof
|
|
9846
|
+
if (err instanceof CompanionError) {
|
|
9923
9847
|
process.stderr.write(`error: ${err.message}
|
|
9924
9848
|
`);
|
|
9925
9849
|
process.exitCode = 1;
|