@cabane/companion 0.5.0 → 0.6.0

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