@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/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 z3 } from "zod";
23
+ import { z as z2 } from "zod";
24
24
 
25
25
  // src/errors.ts
26
- var BridgeError = class extends Error {
26
+ var CompanionError = class extends Error {
27
27
  constructor(message) {
28
28
  super(message);
29
- this.name = "BridgeError";
29
+ this.name = "CompanionError";
30
30
  }
31
31
  };
32
- var ApiError = class extends BridgeError {
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 BridgeError {
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 BridgeError {
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 as z2 } from "zod";
126
- var prepareHookSchema = z2.object({
127
- command: z2.string().min(1),
128
- args: z2.array(z2.string()).optional(),
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: z2.record(z2.string(), z2.string()).optional(),
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 bridge.
134
- timeoutMs: z2.number().int().positive().optional()
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 = z2.object({
138
- cwd: z2.string().min(1),
139
- env: z2.record(z2.string(), z2.string()).optional()
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" (and an optional string-map "env")'
106
+ 'prepare hook JSON must carry a non-empty string "cwd" (plus optional "env")'
159
107
  );
160
108
  }
161
- return { cwd: r.data.cwd, ...r.data.env ? { env: r.data.env } : {} };
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.CABANE_BRIDGE_HOME || homedir(), ".cabane");
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. Bridge tests must swap process.env.HOME to a tmp dir before touching the config dir; this guard prevents wiping the operator's real bridge config (see apps/bridge/test/setup-home.ts).`
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 = z3.object({
275
- cwd: z3.string().optional(),
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 bridge (memory belongs in the Cabane workspace, and the
279
- // house bridge would otherwise share one cwd-keyed memory dir across users).
280
- // On a bridge you run yourself, set `claudeCode: { autoMemory: true }` to hand
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: z3.object({ autoMemory: z3.boolean().optional() }).strict().optional()
236
+ claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
285
237
  }).strict();
286
- var bridgeConfigSchema = z3.object({
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: z3.string().url().refine(isAllowedBaseUrl, {
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 bridge's one durable credential,
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: z3.string().optional(),
299
- // Identity hints, learned from the pairing string and refreshed on the first
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: z3.string().optional(),
302
- deviceLabel: z3.string().optional(),
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 bridge
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: z3.record(z3.string(), localAgentConfigSchema).optional(),
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 / `BRIDGE_NO_OPEN=1` override per-run); logLevel: pino
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: z3.number().int().min(1).max(65535).optional(),
312
- autoOpen: z3.boolean().optional(),
313
- logLevel: z3.enum(["warn", "info", "debug"]).optional(),
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 bridge at it
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: z3.object({
322
- serverUrl: z3.string().url()
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: z3.object({
334
- enabled: z3.boolean().optional()
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 = bridgeConfigSchema.safeParse(parsed);
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 bridge, or was hand-edited. Run \`cabane-companion pair\` to re-pair, or \`cabane-companion logout --purge\` to reset.`
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 = bridgeConfigSchema.safeParse(parsed);
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 = z3.record(z3.string(), localAgentConfigSchema).safeParse(obj.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 bridge; re-pairing rewrote it.`
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 bridge is not paired. Run `cabane-companion pair` \u2014 it shows a short code \u2014 then enter that code in the cabane app (Settings \u2192 Bridges) to connect this machine."
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 bridgeLogPath() {
474
- return join2(cabaneDir(), "bridge.log");
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
- "bridgeId"
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 = bridgeLogPath();
443
+ const path3 = companionLogPath();
499
444
  mkdirSync2(dirname2(path3), { recursive: true });
500
445
  const streams = [];
501
- if (process.env.CABANE_BRIDGE_DAEMON !== "1") {
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 BridgeError(
552
- "Claude Code, the bridge\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 bridge README)."
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 Bridge is already running (pid ${existing.pid}).
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 Bridge was launched (pid ${child.pid ?? "?"}) but didn't report ready within ${STARTUP_TIMEOUT_MS / 1e3}s.
686
- Check ${bridgeLogPath()} for errors, or run \`cabane-companion status\`.
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 Bridge started in the background (pid ${state.pid}).
638
+ `Cabane Companion started in the background (pid ${state.pid}).
694
639
  \u2192 Dashboard: ${state.url}
695
- Logs: ${bridgeLogPath()}
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(bridgeLogPath(), "a");
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, CABANE_BRIDGE_DAEMON: "1" }
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 z4 } from "zod";
675
+ import { z as z3 } from "zod";
731
676
  function credentialsPath() {
732
677
  return join4(cabaneDir(), "credentials.json");
733
678
  }
734
- var credentialStoreSchema = z4.record(z4.string(), z4.string());
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 bridge config (device + agent overrides + settings)?" : "Log out this device (removes the device token + cached agent credentials; keeps your config)?";
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 Agents \u2192 Bridges) if you want it gone there too.`;
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 bridge config. Re-pair with \`cabane-companion pair\` (agent credentials are re-delivered when the agents are re-assigned to this device). NOTE: ${serverNote}
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 BridgeError(
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 BridgeError(
867
- `this cabane server (${baseUrl}) doesn't support device-flow pairing yet. Update the server, or use \`cabane-companion pair --legacy\` with a pairing string from the app.`
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/bridge-enrollment/code", {});
817
+ return postJson(baseUrl, "/api/device-enrollment/code", {});
877
818
  }
878
819
  function pollOnce(baseUrl, deviceCode) {
879
- return postJson(baseUrl, "/api/bridge-enrollment/poll", { deviceCode });
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 BridgeError("pairing was cancelled.");
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 BridgeError("pairing was cancelled.");
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 BridgeError("the server reported the pairing complete but returned no token.");
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 BridgeError(
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 BridgeError(
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 BridgeError(
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
- async function pair(opts = {}) {
990
- const useLegacy = opts.legacy === true || opts.arg !== void 0 || opts.file !== void 0;
991
- if (useLegacy) {
992
- const rawString = await resolvePairingString(opts);
993
- const pairing = decodePairing(rawString);
994
- writePairedConfigCli({
995
- baseUrl: pairing.baseUrl,
996
- deviceToken: pairing.deviceToken,
997
- ...pairing.deviceId ? { deviceId: pairing.deviceId } : {},
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.BRIDGE_NO_OPEN === "1") return false;
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 BridgeStateHub = class {
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 BridgeStatusJson.harnesses).
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
- bridge_version: this.opts.bridgeVersion,
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(bridgeLogPath(), lines) });
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 BridgeError) {
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 BridgeError(
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
- "bridge: commit queued to outbox after transient failure (will drain when the API returns)"
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
- "bridge outbox: discarding entry on terminal 4xx (will never land)"
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
- // bridge PAT — so the spawn carries the same authority as the agent's other
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: bridge-only participant ops. All three authenticate with the
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 bridge's `agentSessionId` for this
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 bridge can
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 bridge resumes on. CT262: the per-turn piggybacks this
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 bridge's old four-fetch
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
- getTurnContext(conversationId, messageId2) {
1810
- const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}`;
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
- setBridgeActiveRun(workspaceId, conversationId, agentId, body) {
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
- "bridge: active-run write queued to outbox after transient failure (will drain when the API returns)"
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 bridge's turn (a `progress` interim note or
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 bridge holds an agent-bound
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 bridge's
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 bridge that didn't
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 bridges.
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
- // bridge calls this on each dispatch to get its `systemPrompt` (composed
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 bridge. Agent-PAT authed; the workspace is implied
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 bridge fetches the triggering message body by listing 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 bridge only reaches for the specific row immediately after 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
- // Per-device liveness ping. Reports the bridge build version and the env-var
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 readFileSync5, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
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 = readFileSync5(path3, "utf8").trim();
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 readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "fs";
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 readFileSync6(path3, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
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 readFileSync6(path3, "utf8").split("\n")) {
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.CABANE_BRIDGE_NO_RESUME === "1";
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 z5 } from "zod";
2199
- var hostPolicySchema = z5.object({
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: z5.boolean(),
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: z5.boolean(),
2208
- // Browser automation (the Playwright MCP surface). Varies by host: a bridge has
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: z5.boolean(),
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 bridge.
2213
- userMcp: z5.boolean(),
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
- // `canUseTool` or the user allowlist. The subagent completes within the turn, so
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: z5.boolean(),
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: z5.literal("never"),
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: z5.literal("never")
2169
+ uiPrompts: z4.literal("never")
2230
2170
  });
2231
2171
 
2232
2172
  // packages/agent-runtime/src/turn-event.ts
2233
- import { z as z6 } from "zod";
2234
- var turnEventSchema = z6.discriminatedUnion("type", [
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 bridge captures the raw SDK session id here; a
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 bridge, after the server committed the manifest). Runtime-neutral: a
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
- z6.object({
2255
- type: z6.literal("session"),
2256
- state: z6.string(),
2257
- degraded: z6.boolean().optional()
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
- z6.object({ type: z6.literal("thinking"), text: z6.string() }),
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
- z6.object({ type: z6.literal("text"), body: z6.string(), terminal: z6.boolean() }),
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
- z6.object({
2282
- type: z6.literal("tool"),
2283
- id: z6.string(),
2284
- name: z6.string(),
2285
- phase: z6.enum(["start", "done", "error"]),
2286
- summary: z6.string(),
2287
- input: z6.unknown().optional(),
2288
- result: z6.unknown().optional(),
2289
- mcpServer: z6.string().nullable().optional()
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 bridge / adapter that never sets it, and a
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/bridge omits them, a cancel has no result
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
- z6.object({
2321
- type: z6.literal("result"),
2322
- ok: z6.boolean(),
2323
- reason: z6.string().optional(),
2324
- usage: z6.object({
2325
- inputTokens: z6.number(),
2326
- outputTokens: z6.number(),
2327
- cacheReadTokens: z6.number().optional(),
2328
- cacheCreationTokens: z6.number().optional()
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: z6.string().optional(),
2331
- resolvedConfig: z6.object({
2332
- effort: z6.string().optional(),
2333
- thinking: z6.string().optional(),
2334
- reasoningEffort: z6.string().optional()
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 z7 } from "zod";
2341
- var turnFailureSchema = z7.discriminatedUnion("kind", [
2342
- z7.object({ kind: z7.literal("usage_capped"), resetsAt: z7.string().optional() }),
2343
- z7.object({ kind: z7.literal("rate_limited") }),
2344
- z7.object({ kind: z7.literal("server_error") }),
2345
- z7.object({ kind: z7.literal("auth_expired") })
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 capNoun = CAP_PATTERNS.some((re) => re.test(t));
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 z8 } from "zod";
2450
- var contentBlockSchema = z8.discriminatedUnion("type", [
2451
- z8.object({ type: z8.literal("text"), text: z8.string() }),
2452
- z8.object({
2453
- type: z8.literal("image"),
2454
- source: z8.object({ type: z8.literal("url"), url: z8.string() })
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
- z8.object({
2457
- type: z8.literal("document"),
2458
- source: z8.object({ type: z8.literal("url"), url: z8.string() })
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 = z8.enum(["low", "medium", "high", "xhigh", "max"]);
2462
- var resolvedRunConfigSchema = z8.object({
2463
- model: z8.string().nullable(),
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: z8.record(z8.string(), z8.unknown()).optional()
2422
+ runtimeOptions: z7.record(z7.string(), z7.unknown()).optional()
2466
2423
  });
2467
- var resolvedMcpServerSchema = z8.union([
2468
- z8.object({
2469
- type: z8.literal("stdio").optional(),
2470
- command: z8.string(),
2471
- args: z8.array(z8.string()).optional(),
2472
- env: z8.record(z8.string(), z8.string()).optional()
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
- z8.object({
2475
- type: z8.literal("http"),
2476
- url: z8.string(),
2477
- headers: z8.record(z8.string(), z8.string()).optional()
2431
+ z7.object({
2432
+ type: z7.literal("http"),
2433
+ url: z7.string(),
2434
+ headers: z7.record(z7.string(), z7.string()).optional()
2478
2435
  }),
2479
- z8.object({
2480
- type: z8.literal("sse"),
2481
- url: z8.string(),
2482
- headers: z8.record(z8.string(), z8.string()).optional()
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 = z8.record(z8.string(), resolvedMcpServerSchema);
2486
- var hostInjectedServersSchema = z8.record(z8.string(), z8.unknown());
2487
- var turnRequestSchema = z8.object({
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: z8.string(),
2447
+ systemPrompt: z7.string(),
2491
2448
  // Server-composed per-turn user text (anchor reminder + the triggering message).
2492
- prompt: z8.string(),
2449
+ prompt: z7.string(),
2493
2450
  // The multi-block user-message body (text + vision).
2494
- content: z8.array(contentBlockSchema),
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: z8.string().nullable(),
2457
+ session: z7.string().nullable(),
2501
2458
  // The cabane control-plane coordinates for this turn's MCP + post-back.
2502
- cabane: z8.object({
2503
- mcpUrl: z8.string(),
2504
- bearer: z8.string(),
2505
- activeConversationId: z8.string(),
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 bridge
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: z8.string().optional()
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: z8.object({
2520
- cwd: z8.string().optional(),
2521
- env: z8.record(z8.string(), z8.string()).optional(),
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
- // bridge they run themselves — the auto-memory escape hatch. `autoMemory:
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`). The house device never
2528
- // sets it, so house stays force-off unconditionally.
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: z8.object({
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 z9 } from "zod";
2539
- var conformanceFixtureSchema = z9.object({
2540
- name: z9.string(),
2509
+ import { z as z8 } from "zod";
2510
+ var conformanceFixtureSchema = z8.object({
2511
+ name: z8.string(),
2541
2512
  request: turnRequestSchema,
2542
- nativeStream: z9.array(z9.unknown()),
2543
- expected: z9.array(turnEventSchema)
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 z10 } from "zod";
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 = z10.object({
2899
- thinking: z10.discriminatedUnion("type", [
2900
- z10.object({
2901
- type: z10.literal("adaptive"),
2902
- display: z10.enum(["summarized", "omitted"]).optional()
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
- z10.object({
2905
- type: z10.literal("enabled"),
2906
- budgetTokens: z10.number().int().positive().optional(),
2907
- display: z10.enum(["summarized", "omitted"]).optional()
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
- z10.object({ type: z10.literal("disabled") })
2880
+ z9.object({ type: z9.literal("disabled") })
2910
2881
  ]).optional(),
2911
- allowedTools: z10.array(z10.string()).optional(),
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
- var allowEverything = async (_toolName, input) => ({
2966
- behavior: "allow",
2967
- updatedInput: input
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 customAllowed = dialect.success ? dialect.data.allowedTools ?? [] : [];
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), ...customDisallowed]);
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 bridge prepare hook's tokens/ports; the in-app's debug flags)
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
- canUseTool: allowEverything
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 failure = sawRejectedLimit || terminalReason === "blocking_limit" ? {
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: { status: status2, ...resetsAt !== void 0 ? { resetsAt } : {} },
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 `rate_limit_event` with
3452
- // `status: 'rejected'`; the terminal error result then classifies as the
3453
- // structured `usage_capped` reason (reset-less here — the reset-time extraction
3454
- // is pinned by a focused unit test, off the wall clock). Partial narration lands
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 z11 } from "zod";
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 = z11.object({
3852
- agent: z11.string().min(1).optional()
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 BRIDGE_POLICY = {
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: BRIDGE_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 one Cabane workspace tool has a plain name \u2014 `sdk` (you act on the",
4559
- "workspace by writing a TypeScript program and calling `sdk` with it); the",
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
- "plain-named too. There is no `read`/`write`/`search`/`edit` tool here \u2014 those",
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 prefix is not part of its name \u2014 call the",
4564
- "tool by its plain verb. Write your closing reply as the last thing you say in the",
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 { id, name: "web_search", input: { query }, status: toStatus(item.status) };
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 z12 } from "zod";
4859
+ import { z as z11 } from "zod";
4832
4860
  function codexToolPolicy(policy) {
4833
- return {
4834
- sandboxMode: policy.hostFs ? "workspace-write" : "read-only",
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
- // Only consulted under workspace-write; the public-web grant governs whether
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 = z12.object({
4844
- modelReasoningEffort: z12.enum(CODEX_REASONING_EFFORTS).optional()
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", "codex"]);
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
- return { mcp_servers, experimental_use_rmcp_client: true };
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.144.6/node_modules/@openai/codex-sdk/dist/index.js
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 BRIDGE_POLICY2 = {
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: BRIDGE_POLICY2,
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
- // bridge has an observation for. Empty until the first classified failure/heal,
6606
- // so a bridge that has seen nothing sends no `connectors[]` and the server's
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 z14 } from "zod";
6624
- var BRIDGE_LOCAL_MCP_SERVER = "cabane_bridge";
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__${BRIDGE_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
6627
- var BRIDGE_LOCAL_TOOL_GLOB = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__*`;
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__${BRIDGE_LOCAL_MCP_SERVER}__${SKIP_TURN_TOOL}`;
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__${BRIDGE_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
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__${BRIDGE_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
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__${BRIDGE_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
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: BRIDGE_LOCAL_MCP_SERVER,
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: z14.string().uuid().describe("The peer agent to summon \u2014 a workspace agent id from `list_agents`.")
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: z14.string().min(1).max(500).describe("Short reason you are declining \u2014 used for telemetry/debugging.")
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: z14.string().uuid().describe("The workspace member (human) to ask \u2014 a user id from `list_members`."),
6690
- question: z14.string().min(1).max(400).optional().describe(
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: z14.string().min(1).max(120).optional().describe(
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: z14.array(z14.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
6697
- questions: z14.array(
6698
- z14.object({
6699
- headline: z14.string().min(1).max(120).describe(
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: z14.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
6703
- options: z14.array(z14.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
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: z14.string().min(1).max(65536).describe(
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: z14.string().uuid().optional().describe(
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: z14.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
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: z14.number().int().positive().optional().describe(
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: z14.string().datetime({ offset: true }).optional().describe(
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: z14.string().min(1).max(2e3).describe(
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 buildBridgeTurnRequest(params) {
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 bridge PAT
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 bridge-local summon server, under its own namespace.
6879
- extra: { mcpServers: { [BRIDGE_LOCAL_MCP_SERVER]: params.summonServer } }
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 readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync7 } from "fs";
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 pathFor3(workspaceId, conversationId) {
6893
- return join9(dirFor(workspaceId), `${encodeURIComponent(conversationId)}.json`);
6495
+ function conversationDir(workspaceId, conversationId) {
6496
+ return join9(dirFor(workspaceId), encodeURIComponent(conversationId));
6894
6497
  }
6895
- function readPrepared(workspaceId, conversationId) {
6896
- const path3 = pathFor3(workspaceId, conversationId);
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(readFileSync7(path3, "utf8"));
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(dirFor(workspaceId), { recursive: true });
6913
- writeFileSync6(pathFor3(workspaceId, conversationId), JSON.stringify(result) + "\n", "utf8");
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 readFileSync8 } from "fs";
6527
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
6918
6528
  import { join as join10 } from "path";
6919
- import { z as z15 } from "zod";
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 = z15.record(z15.string(), z15.string());
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 = readFileSync8(path3, "utf8");
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 bridge.** 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 bridge\u2019s secret store (`~/.cabane/secrets.json`) and try again. Missing:";
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 bridge.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
7260
- var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this bridge) \u2014 the bridge is likely running outdated code; refresh it, then re-address the agent`;
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 = 45 * 6e4;
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 `setBridgeActiveRun` working-flip below).
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 bridge can
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 90-min age sweep.
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.setBridgeActiveRun(
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(payload.conversationId, payload.messageId);
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 bridge");
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.setBridgeActiveRun(workspaceId, payload.conversationId, payload.agentId, {
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 = buildBridgeTurnRequest({
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 bridge PAT (`agentPat`) inside `buildBridgeTurnRequest` otherwise.
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
- ...hookEnv ? { env: hookEnv } : {},
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.setBridgeActiveRun(
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.setBridgeActiveRun(
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 bridge process. Returns true if an in-flight run was aborted.
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 BRIDGE_MANIFEST = {
7739
+ var DEVICE_MANIFEST = {
7890
7740
  runtimes: [{ name: "claude-code", version: null }],
7891
7741
  capabilities: { hostFs: true, browser: true, userMcp: true }
7892
7742
  };
7893
- var HOUSE_MANIFEST = {
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
- if (opts.cabaneNative) runtimes.push({ name: "cabane-native", version: null });
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
- buildBridgeManifest({
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 mkdirSync10,
7950
+ mkdirSync as mkdirSync11,
8111
7951
  readdirSync as readdirSync2,
8112
- readFileSync as readFileSync9,
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 join12 } from "path";
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 join12(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7970
+ return join13(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
8131
7971
  }
8132
7972
  fileFor(turnId, seq) {
8133
- return join12(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
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
- mkdirSync10(dir2, { recursive: true });
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
- "bridge outbox: failed to persist entry"
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 = join12(dir2, name);
8015
+ const full = join13(dir2, name);
8176
8016
  try {
8177
- const parsed = JSON.parse(readFileSync9(full, "utf8"));
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
- "bridge outbox: dropping unreadable entry"
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
- "bridge outbox: evicting entry past max age (undeliverable)"
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
- "bridge outbox: evicting oldest entry past max size"
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 z16 } from "zod";
8250
- var mcpStdioServerSchema = z16.object({
8251
- type: z16.literal("stdio").optional(),
8252
- command: z16.string().min(1),
8253
- args: z16.array(z16.string()).optional(),
8254
- env: z16.record(z16.string(), z16.string()).optional()
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 = z16.object({
8257
- type: z16.literal("http"),
8258
- url: z16.string().url(),
8259
- headers: z16.record(z16.string(), z16.string()).optional()
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 = z16.object({
8262
- type: z16.literal("sse"),
8263
- url: z16.string().url(),
8264
- headers: z16.record(z16.string(), z16.string()).optional()
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 = z16.union([
8106
+ var mcpServerDefSchema = z14.union([
8267
8107
  mcpHttpServerSchema,
8268
8108
  mcpSseServerSchema,
8269
8109
  mcpStdioServerSchema
8270
8110
  ]);
8271
- var thinkingConfigSchema = z16.discriminatedUnion("type", [
8272
- z16.object({ type: z16.literal("adaptive") }),
8273
- z16.object({ type: z16.literal("enabled"), budgetTokens: z16.number().int().positive().optional() }),
8274
- z16.object({ type: z16.literal("disabled") })
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 = z16.enum(["low", "medium", "high", "xhigh", "max"]);
8277
- var runConfigSchema = z16.object({
8278
- mode: z16.enum(["assistant", "coding", "custom"]).optional(),
8279
- allowedTools: z16.array(z16.string()).optional(),
8280
- disallowedTools: z16.array(z16.string()).optional(),
8281
- mcpServers: z16.record(z16.string(), mcpServerDefSchema).optional(),
8282
- model: z16.string().min(1).optional(),
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 BRIDGE_VERSION = pkg.version;
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 BridgeSupervisor = class {
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
- // CT484: latch so the bridge/server version-skew warning is logged once, not
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 bridge with no device token (logged out) does
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: BRIDGE_VERSION },
8475
- "bridge: starting"
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("bridge: not paired (no device token) \u2014 run `cabane-companion pair`");
8324
+ this.log.warn("companion: not paired (no device token) \u2014 run `cabane-companion pair`");
8480
8325
  process.stdout.write(
8481
- "bridge: this device is not paired \u2014 run `cabane-companion pair` and paste the string from the cabane app.\n"
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
- void this.sendHeartbeat();
8491
- this.heartbeatTimer = setInterval(() => void this.sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
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 }, "bridge: secrets"));
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: BRIDGE_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: buildBridgeManifest({
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
- // bridge has seen any. Omitted (undefined) until the first observed
8538
- // failure/heal, so a fresh bridge's beat is unchanged and the server's
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
- "bridge: device heartbeat failed (will retry on next tick)"
8401
+ "companion: device heartbeat failed (will retry on next tick)"
8553
8402
  );
8554
8403
  }
8555
8404
  }
8556
- // CT484: belt-and-suspenders beside shipping the bridge in lockstep inside 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
- // bridge's, warn loudly (once). This is exactly the skew that bit the M1 walk:
8559
- // an npm-pinned bridge lagging a from-develop server. The elimination (bundled
8560
- // bridge) makes it match by construction; this catches a bridge run out of band.
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 === BRIDGE_VERSION) return;
8412
+ if (!serverVersion || serverVersion === COMPANION_VERSION) return;
8564
8413
  this.versionSkewWarned = true;
8565
8414
  this.log.warn(
8566
- { bridgeVersion: BRIDGE_VERSION, serverVersion },
8567
- "bridge: VERSION SKEW \u2014 this bridge and its server were built from different versions. Turns may misbehave. The self-host artifact ships a matching bridge; run `cabane update` so the bridge matches its server."
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
- "bridge: assignments pull failed \u2014 check the device is still active in the cabane app"
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 }, "bridge: run-config")
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
- "bridge: agent assigned but no credential on this device \u2014 re-assign it in the cabane app"
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.mode ?? "assistant",
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.mode ?? "assistant",
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
- "bridge: agent needs secrets this device does not expose (turns using them will fail)"
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
- "bridge: running agent"
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({ workspaceId: wr.workspaceId, agentId }, "bridge: stopped agent (unassigned)");
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
- // CT598: register the cabane-native adapter when an OpenRouter key is set;
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 / 45 min total).
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
- "bridge: workspace stream auth failed \u2014 re-pulling assignments"
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 }, "bridge: subscribed");
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 === "bridge:cancel_requested") {
8699
+ if (wire.type === "device:cancel_requested") {
8851
8700
  const payload2 = {
8852
- type: "bridge:cancel_requested",
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 !== "bridge:dispatch_requested") {
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: "bridge:dispatch_requested",
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
- "bridge: conversation turn handler threw"
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
- "bridge: skipping already-completed event (resume after restart)"
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
- "bridge: skipping interrupted turn (CABANE_BRIDGE_NO_RESUME=1) \u2014 resume disabled for this boot"
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
- "bridge: giving up on an interrupted turn after too many resume attempts \u2014 retiring it so boot is never wedged (run `cabane bridge reset` to clear resume state)"
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
- "bridge: re-dispatching interrupted turn (resume after restart)"
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
- "bridge: outbox drain pass threw (will retry with backoff)"
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
- "bridge: harness probe failed (will retry on next beat)"
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 = bridgeConfigSchema.shape.opencode.safeParse({ serverUrl });
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
- void this.sendHeartbeat();
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
- "bridge: recovered from a socket error (kept running \u2014 a broken pipe never kills the bridge)"
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
- "bridge: uncaught error (kept running \u2014 see the stack above)"
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 mkdirSync11, readFileSync as readFileSync10, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
9226
- import { join as join13 } from "path";
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 join13(cabaneDir(), "last-error.json");
9113
+ return join14(cabaneDir(), "last-error.json");
9229
9114
  }
9230
9115
  function recordCrash(rec2) {
9231
9116
  try {
9232
- mkdirSync11(cabaneDir(), { recursive: true });
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 createBridgeRuntime(opts = {}) {
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.CABANE_BRIDGE_DAEMON === "1",
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 BridgeStateHub({
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
- bridgeVersion: BRIDGE_VERSION,
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 bridge (not a recycled pid).
9178
+ // process behind the marker pid is this companion (not a recycled pid).
9294
9179
  instanceId
9295
9180
  });
9296
- const supervisor = new BridgeSupervisor({ config: cfg, log, hub, claudeCode, harnessVersions });
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 bridge is backgrounded (foreground start
9202
+ // marker records whether this companion is backgrounded (foreground start
9312
9203
  // leaves it unset → false).
9313
- daemon: process.env.CABANE_BRIDGE_DAEMON === "1",
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: { url: dashboard.url, port: dashboard.port, config: cfg, stop: stop2 }
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 createBridgeRuntime({
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 Bridge is already running (pid ${existing?.pid ?? "?"}).
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 Bridge is running.
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\` and paste the string from the cabane app.
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
- bridge: second ${signal}, force-quitting.
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
- bridge: received ${signal}, shutting down\u2026
9285
+ companion: received ${signal}, shutting down\u2026
9381
9286
  `);
9382
9287
  const forceExit = setTimeout(() => {
9383
- process.stdout.write(`bridge: shutdown timed out, force-quitting.
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
- "bridge: not paired. Register a device in the cabane app (Settings \u2192 Agents \u2192 Bridges), then run `cabane-companion pair` and paste the string when prompted.\n"
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: ${bridgeLogPath()}
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
- `bridge: running in ${mode} (pid ${running.pid}${uptime ? `, up ${uptime}` : ""})
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
- `bridge: not running \u2014 \`cabane-companion start\` (foreground) or \`cabane-companion start --daemon\` (background)
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("bridge: not running (nothing to stop).\n");
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("bridge: not running (stale marker swept).\n");
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(`bridge: stopping (pid ${pid})\u2026
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("bridge: already stopped.\n");
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("bridge: stopped.\n");
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(`bridge: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
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("bridge: force-stopped.\n");
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 readFileSync11, readdirSync as readdirSync4 } from "fs";
9535
- import { isAbsolute, join as join14 } from "path";
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(join14(dir2, newest)) + "\n");
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 (str3(rec(safeParse(line))?.type) === "_outcome") return true;
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) => readFileSync11(join14(dir2, f), "utf8"),
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(join14(dir2, f));
9607
+ const { meta, outcome } = peek(join15(dir2, f));
9676
9608
  const when = fmtTime(rec(meta)?.ts);
9677
- const ws = str3(rec(meta)?.workspaceSlug);
9609
+ const ws = str2(rec(meta)?.workspaceSlug);
9678
9610
  const o = rec(outcome);
9679
- const verdict = o ? o.ok === true ? "ok" : `ERROR${str3(o.reason) ? ` (${str3(o.reason)})` : ""}` : "\u2026";
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(str3(rec(meta)?.message), 70)}\u201D
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 readFileSync11(path3, "utf8").split("\n")) {
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 = str3(rec(o)?.type);
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 BridgeError(`no transcript at ${target}.`);
9642
+ throw new CompanionError(`no transcript at ${target}.`);
9711
9643
  }
9712
- const exact = join14(dir2, target);
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 join14(dir2, matches[0]);
9647
+ if (matches.length === 1) return join15(dir2, matches[0]);
9716
9648
  if (matches.length === 0) {
9717
- throw new BridgeError(
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 BridgeError(
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 = readFileSync11(path3, "utf8");
9661
+ content = readFileSync10(path3, "utf8");
9730
9662
  } catch (err) {
9731
- throw new BridgeError(
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 (str3(obj.type)) {
9676
+ switch (str2(obj.type)) {
9745
9677
  case "_meta":
9746
9678
  out.push(
9747
- `${fmtTime(obj.ts)} \xB7 workspace=${str3(obj.workspaceSlug)} \xB7 conversation=${str3(obj.conversationId)}`
9679
+ `${fmtTime(obj.ts)} \xB7 workspace=${str2(obj.workspaceSlug)} \xB7 conversation=${str2(obj.conversationId)}`
9748
9680
  );
9749
- out.push("", `> USER: ${str3(obj.message)}`, "");
9681
+ out.push("", `> USER: ${str2(obj.message)}`, "");
9750
9682
  break;
9751
9683
  case "system":
9752
- if (str3(obj.subtype) === "init") {
9753
- out.push(`[session ${str3(obj.session_id) || "?"} \xB7 model ${str3(obj.model) || "?"}]`);
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 ? `${str3(r.name) || "?"}${str3(r.status) ? `(${str3(r.status)})` : ""}` : "";
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 || str3(block.type) !== "tool_result") continue;
9783
- const id = str3(block.tool_use_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 || str3(obj.subtype) !== "success";
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"}${str3(obj.subtype) ? ` \xB7 ${str3(obj.subtype)}` : ""}${dur}]`
9727
+ `[result ${isErr ? "error" : "ok"}${str2(obj.subtype) ? ` \xB7 ${str2(obj.subtype)}` : ""}${dur}]`
9796
9728
  );
9797
- if (isErr && str3(obj.result).trim()) out.push(` ${indent(str3(obj.result))}`);
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"}${str3(obj.reason) ? ` \xB7 ${str3(obj.reason)}` : ""}${dur}]`
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 str3(v) {
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(BRIDGE_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
- ).argument(
9861
- "[string]",
9862
- "(legacy) pairing string copied from cabane. Implies --legacy; omit it for the device-code flow."
9863
- ).option("--server <url>", "the cabane instance to pair with (default https://cabane.ai)").option("--legacy", "use the base64url pairing-string paste instead of the device-code flow").option(
9864
- "--file <path>",
9865
- "(legacy) read the pairing string from a file instead of passing it as an argument"
9866
- ).action(
9867
- async (pairingString, opts) => {
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 bridge (SIGTERM, then force-kill after a timeout).").action(async () => {
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 bridge's local state (pairing, secrets, log path).").action(async () => {
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 BridgeError(`invalid --port "${raw}": expected an integer between 1 and 65535.`);
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 BridgeError) {
9846
+ if (err instanceof CompanionError) {
9923
9847
  process.stderr.write(`error: ${err.message}
9924
9848
  `);
9925
9849
  process.exitCode = 1;