@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/runtime.js CHANGED
@@ -16,13 +16,13 @@ import { dirname, join } from "path";
16
16
  import { z as z3 } from "zod";
17
17
 
18
18
  // src/errors.ts
19
- var BridgeError = class extends Error {
19
+ var CompanionError = class extends Error {
20
20
  constructor(message) {
21
21
  super(message);
22
- this.name = "BridgeError";
22
+ this.name = "CompanionError";
23
23
  }
24
24
  };
25
- var ApiError = class extends BridgeError {
25
+ var ApiError = class extends CompanionError {
26
26
  constructor(status, message, body) {
27
27
  super(message);
28
28
  this.status = status;
@@ -32,13 +32,13 @@ var ApiError = class extends BridgeError {
32
32
  status;
33
33
  body;
34
34
  };
35
- var ConfigError = class extends BridgeError {
35
+ var ConfigError = class extends CompanionError {
36
36
  constructor(message) {
37
37
  super(message);
38
38
  this.name = "ConfigError";
39
39
  }
40
40
  };
41
- var PrepareHookError = class extends BridgeError {
41
+ var PrepareHookError = class extends CompanionError {
42
42
  constructor(message) {
43
43
  super(message);
44
44
  this.name = "PrepareHookError";
@@ -69,12 +69,12 @@ var pairingSchema = z.object({
69
69
  baseUrl: z.string().url().refine(isAllowedBaseUrl, {
70
70
  message: "baseUrl must be https (loopback http is allowed for local dev only)"
71
71
  }),
72
- // The `cabdev_` device token plaintext — the bridge's one durable credential.
72
+ // The `cabdev_` device token plaintext — the companion's one durable credential.
73
73
  deviceToken: z.string().min(1).startsWith(DEVICE_TOKEN_PREFIX, {
74
74
  message: 'deviceToken must be a cabane device token (starts with "cabdev_")'
75
75
  }),
76
76
  // Optional identity hints the app may include for nicer local display. The
77
- // bridge also learns these from the first assignments pull, so they're not
77
+ // companion also learns these from the first assignments pull, so they're not
78
78
  // required.
79
79
  deviceId: z.string().min(1).optional(),
80
80
  deviceLabel: z.string().min(1).optional()
@@ -90,13 +90,18 @@ var prepareHookSchema = z2.object({
90
90
  env: z2.record(z2.string(), z2.string()).optional(),
91
91
  // Wall-clock cap for the hook. Provisioning is slow (minutes), so the
92
92
  // default is generous; a hook that hangs past this is killed and the turn
93
- // fails with a clear timeout message rather than pinning the bridge.
93
+ // fails with a clear timeout message rather than pinning the companion.
94
94
  timeoutMs: z2.number().int().positive().optional()
95
95
  }).strict();
96
96
  var DEFAULT_TIMEOUT_MS = 10 * 6e4;
97
97
  var prepareResultSchema = z2.object({
98
98
  cwd: z2.string().min(1),
99
- env: z2.record(z2.string(), z2.string()).optional()
99
+ env: z2.record(z2.string(), z2.string()).optional(),
100
+ nativeWorkAssignment: z2.object({
101
+ itemId: z2.string(),
102
+ executionId: z2.string(),
103
+ activationEpoch: z2.number().int().nonnegative()
104
+ }).strict().optional()
100
105
  });
101
106
  function parsePrepareOutput(stdout) {
102
107
  const last = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).at(-1);
@@ -115,10 +120,14 @@ function parsePrepareOutput(stdout) {
115
120
  const r = prepareResultSchema.safeParse(parsed);
116
121
  if (!r.success) {
117
122
  throw new PrepareHookError(
118
- 'prepare hook JSON must carry a non-empty string "cwd" (and an optional string-map "env")'
123
+ 'prepare hook JSON must carry a non-empty string "cwd" (plus optional "env" or "nativeWorkAssignment")'
119
124
  );
120
125
  }
121
- return { cwd: r.data.cwd, ...r.data.env ? { env: r.data.env } : {} };
126
+ return {
127
+ cwd: r.data.cwd,
128
+ ...r.data.env ? { env: r.data.env } : {},
129
+ ...r.data.nativeWorkAssignment ? { nativeWorkAssignment: r.data.nativeWorkAssignment } : {}
130
+ };
122
131
  }
123
132
  return { cwd: last };
124
133
  }
@@ -143,6 +152,7 @@ var runPrepareHook = (hook, input) => {
143
152
  CABANE_CONVERSATION_ID: input.conversationId,
144
153
  CABANE_AGENT_ID: input.agentId,
145
154
  CABANE_AGENT_USERNAME: input.agentUsername,
155
+ CABANE_RUNTIME: input.runtime ?? "",
146
156
  // CT319: the conversation anchor is gone. These are kept as DEPRECATED
147
157
  // back-compat constants so an old user prepare script that still reads
148
158
  // them doesn't crash; a hook should key off CABANE_TRIGGER_ENTRY_PATHS.
@@ -154,10 +164,10 @@ var runPrepareHook = (hook, input) => {
154
164
  CABANE_CONVERSATION_TITLE: input.title ?? ""
155
165
  }
156
166
  });
157
- } catch (err) {
167
+ } catch (err2) {
158
168
  reject(
159
169
  new PrepareHookError(
160
- `prepare hook failed to start: ${err instanceof Error ? err.message : String(err)}`
170
+ `prepare hook failed to start: ${err2 instanceof Error ? err2.message : String(err2)}`
161
171
  )
162
172
  );
163
173
  return;
@@ -177,8 +187,8 @@ var runPrepareHook = (hook, input) => {
177
187
  child.stderr?.on("data", (d) => {
178
188
  stderr += d.toString();
179
189
  });
180
- child.on("error", (err) => {
181
- finish(() => reject(new PrepareHookError(`prepare hook failed to start: ${err.message}`)));
190
+ child.on("error", (err2) => {
191
+ finish(() => reject(new PrepareHookError(`prepare hook failed to start: ${err2.message}`)));
182
192
  });
183
193
  child.on("close", (code) => {
184
194
  finish(() => {
@@ -191,8 +201,8 @@ var runPrepareHook = (hook, input) => {
191
201
  }
192
202
  try {
193
203
  resolve(parsePrepareOutput(stdout));
194
- } catch (err) {
195
- reject(err instanceof PrepareHookError ? err : new PrepareHookError(String(err)));
204
+ } catch (err2) {
205
+ reject(err2 instanceof PrepareHookError ? err2 : new PrepareHookError(String(err2)));
196
206
  }
197
207
  });
198
208
  });
@@ -217,12 +227,12 @@ function realAccountHome() {
217
227
  return realHomeCache;
218
228
  }
219
229
  function cabaneDir() {
220
- const dir2 = join(process.env.CABANE_BRIDGE_HOME || homedir(), ".cabane");
230
+ const dir2 = join(process.env.CABANE_COMPANION_HOME || homedir(), ".cabane");
221
231
  if (process.env.VITEST) {
222
232
  const real = realAccountHome();
223
233
  if (real && dir2 === join(real, ".cabane")) {
224
234
  throw new Error(
225
- `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).`
235
+ `cabaneDir() resolved to the real ${dir2} during a test run. Companion tests must swap process.env.HOME to a tmp dir before touching the config dir; this guard prevents wiping the operator's real companion config (see apps/companion/test/setup-home.ts).`
226
236
  );
227
237
  }
228
238
  }
@@ -235,22 +245,22 @@ var localAgentConfigSchema = z3.object({
235
245
  cwd: z3.string().optional(),
236
246
  prepareHook: prepareHookSchema.optional(),
237
247
  // CT289: the Claude Code auto-memory escape hatch. Auto-memory is forced OFF
238
- // by default on every bridge (memory belongs in the Cabane workspace, and the
239
- // house bridge would otherwise share one cwd-keyed memory dir across users).
240
- // On a bridge you run yourself, set `claudeCode: { autoMemory: true }` to hand
248
+ // by default on every companion (memory belongs in the Cabane workspace, and a
249
+ // shared companion would otherwise pool one cwd-keyed memory dir across users).
250
+ // On a companion you run yourself, set `claudeCode: { autoMemory: true }` to hand
241
251
  // auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
242
252
  // injecting the off switch and your normal Claude Code memory workflow applies
243
253
  // (in coding mode, where the checkout's project settings are read).
244
254
  claudeCode: z3.object({ autoMemory: z3.boolean().optional() }).strict().optional()
245
255
  }).strict();
246
- var bridgeConfigSchema = z3.object({
256
+ var companionConfigSchema = z3.object({
247
257
  // The cabane instance this device is paired with. SJ515: https-enforced
248
258
  // (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
249
259
  // base URL onto the MITM-able channel the device token + prompt ride.
250
260
  baseUrl: z3.string().url().refine(isAllowedBaseUrl, {
251
261
  message: "baseUrl must be https (loopback http is allowed for local dev only)"
252
262
  }),
253
- // The `cabdev_` device token plaintext — the bridge's one durable credential,
263
+ // The `cabdev_` device token plaintext — the companion's one durable credential,
254
264
  // presented as `Authorization: Bearer <token>` on the device-facing pull +
255
265
  // heartbeat endpoints. Optional so `cabane-companion logout` can strip it (a
256
266
  // "paired but logged out" state the supervisor refuses to run) while keeping
@@ -261,19 +271,19 @@ var bridgeConfigSchema = z3.object({
261
271
  deviceId: z3.string().optional(),
262
272
  deviceLabel: z3.string().optional(),
263
273
  // Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
264
- // agentId / username / `slug/username`. Hand-added by the operator; the bridge
274
+ // agentId / username / `slug/username`. Hand-added by the operator; the companion
265
275
  // never writes this (it only persists credentials + the device, elsewhere).
266
276
  agents: z3.record(z3.string(), localAgentConfigSchema).optional(),
267
277
  // Dashboard settings (all optional). dashboardPort: preferred bind port (next
268
278
  // free one if taken); autoOpen: whether `start` opens the browser (the
269
- // `--no-open` flag / `BRIDGE_NO_OPEN=1` override per-run); logLevel: pino
279
+ // `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
270
280
  // level, live-editable from the dashboard settings panel.
271
281
  dashboardPort: z3.number().int().min(1).max(65535).optional(),
272
282
  autoOpen: z3.boolean().optional(),
273
283
  logLevel: z3.enum(["warn", "info", "debug"]).optional(),
274
284
  // CT270: the opencode runtime, when the operator runs one on this machine. The
275
285
  // operator installs opencode, starts `opencode serve` (auth via opencode's own
276
- // `/connect` — Cabane never sees provider keys), and points the bridge at it
286
+ // `/connect` — Cabane never sees provider keys), and points the companion at it
277
287
  // here. Setting this makes the device advertise the `opencode` runtime on its
278
288
  // heartbeat manifest (so the server offers DeepSeek/opencode models here and
279
289
  // routes those turns to this device) AND registers the opencode adapter in the
@@ -314,21 +324,21 @@ function loadConfig() {
314
324
  let raw;
315
325
  try {
316
326
  raw = readFileSync(path3, "utf8");
317
- } catch (err) {
327
+ } catch (err2) {
318
328
  throw new ConfigError(
319
- `couldn't read ${path3}: ${err instanceof Error ? err.message : String(err)}`
329
+ `couldn't read ${path3}: ${err2 instanceof Error ? err2.message : String(err2)}`
320
330
  );
321
331
  }
322
332
  if (raw.trim().length === 0) return null;
323
333
  let parsed;
324
334
  try {
325
335
  parsed = JSON.parse(raw);
326
- } catch (err) {
336
+ } catch (err2) {
327
337
  throw new ConfigError(
328
- `${path3} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
338
+ `${path3} is not valid JSON: ${err2 instanceof Error ? err2.message : String(err2)}`
329
339
  );
330
340
  }
331
- const result = bridgeConfigSchema.safeParse(parsed);
341
+ const result = companionConfigSchema.safeParse(parsed);
332
342
  if (!result.success) {
333
343
  const agentIssue = result.error.issues.find((i) => i.path[0] === "agents");
334
344
  if (agentIssue) {
@@ -337,7 +347,7 @@ function loadConfig() {
337
347
  );
338
348
  }
339
349
  throw new ConfigError(
340
- `${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.`
350
+ `${path3} is from an incompatible or older version of the companion, or was hand-edited. Run \`cabane-companion pair\` to re-pair, or \`cabane-companion logout --purge\` to reset.`
341
351
  );
342
352
  }
343
353
  return result.data;
@@ -357,19 +367,19 @@ function saveConfig(cfg) {
357
367
  } catch {
358
368
  }
359
369
  renameSync(tmp, path3);
360
- } catch (err) {
370
+ } catch (err2) {
361
371
  try {
362
372
  rmSync(tmp, { force: true });
363
373
  } catch {
364
374
  }
365
- throw err;
375
+ throw err2;
366
376
  }
367
377
  }
368
378
  function requireConfig() {
369
379
  const cfg = loadConfig();
370
380
  if (!cfg) {
371
381
  throw new ConfigError(
372
- "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."
382
+ "this companion is not paired. Run `cabane-companion pair` \u2014 it shows a short code \u2014 then enter that code in the cabane app (Settings \u2192 Companions) to connect this machine."
373
383
  );
374
384
  }
375
385
  return cfg;
@@ -392,8 +402,8 @@ import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
392
402
  import { dirname as dirname2, join as join2 } from "path";
393
403
  import pino from "pino";
394
404
  import pretty from "pino-pretty";
395
- function bridgeLogPath() {
396
- return join2(cabaneDir(), "bridge.log");
405
+ function companionLogPath() {
406
+ return join2(cabaneDir(), "companion.log");
397
407
  }
398
408
  var CONSOLE_IGNORE = [
399
409
  "pid",
@@ -403,7 +413,7 @@ var CONSOLE_IGNORE = [
403
413
  "agentId",
404
414
  "messageId",
405
415
  "sessionId",
406
- "bridgeId"
416
+ "companionId"
407
417
  ].join(",");
408
418
  function consoleShortId(log) {
409
419
  const id = log.conversationId ?? log.workspaceId;
@@ -417,10 +427,10 @@ function consoleMessageFormat(log, messageKey) {
417
427
  var cached = null;
418
428
  function getLogger() {
419
429
  if (cached) return cached;
420
- const path3 = bridgeLogPath();
430
+ const path3 = companionLogPath();
421
431
  mkdirSync2(dirname2(path3), { recursive: true });
422
432
  const streams = [];
423
- if (process.env.CABANE_BRIDGE_DAEMON !== "1") {
433
+ if (process.env.CABANE_COMPANION_DAEMON !== "1") {
424
434
  const consoleStream = pretty({
425
435
  colorize: true,
426
436
  ignore: CONSOLE_IGNORE,
@@ -452,7 +462,7 @@ var MAX_DISPATCHES = 50;
452
462
  function today() {
453
463
  return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
454
464
  }
455
- var BridgeStateHub = class {
465
+ var CompanionStateHub = class {
456
466
  constructor(opts) {
457
467
  this.opts = opts;
458
468
  this.emitter.setMaxListeners(0);
@@ -466,7 +476,7 @@ var BridgeStateHub = class {
466
476
  deviceLabel = null;
467
477
  deviceError = null;
468
478
  // CT586: the latest harness snapshot the supervisor probed, or null before the
469
- // first probe (see BridgeStatusJson.harnesses).
479
+ // first probe (see CompanionStatusJson.harnesses).
470
480
  harnesses = null;
471
481
  // ---- subscription (SSE) ----
472
482
  on(listener) {
@@ -658,7 +668,7 @@ var BridgeStateHub = class {
658
668
  })),
659
669
  last_event_at_overall: lastOverall,
660
670
  dashboard_url: this.dashboardUrl,
661
- bridge_version: this.opts.bridgeVersion,
671
+ companion_version: this.opts.companionVersion,
662
672
  instance_id: this.opts.instanceId ?? null,
663
673
  harnesses: this.harnesses
664
674
  };
@@ -699,7 +709,7 @@ function registerRoutes(app, deps) {
699
709
  });
700
710
  app.get("/api/logs", async (c) => {
701
711
  const lines = clampLimit(c.req.query("lines"), 200, 1e3);
702
- return c.json({ lines: tailFile(bridgeLogPath(), lines) });
712
+ return c.json({ lines: tailFile(companionLogPath(), lines) });
703
713
  });
704
714
  app.post("/api/settings", async (c) => {
705
715
  const body = await readJson(c);
@@ -835,15 +845,15 @@ var DEFAULT_PORT = 7474;
835
845
  var PORT_FALLBACK_SPAN = 10;
836
846
  function buildDashboardApp(deps) {
837
847
  const app = new Hono();
838
- app.onError((err, c) => {
839
- if (err instanceof ApiError) {
840
- const status = err.status >= 400 && err.status < 600 ? err.status : 502;
841
- return c.json({ error: err.message }, status);
848
+ app.onError((err2, c) => {
849
+ if (err2 instanceof ApiError) {
850
+ const status = err2.status >= 400 && err2.status < 600 ? err2.status : 502;
851
+ return c.json({ error: err2.message }, status);
842
852
  }
843
- if (err instanceof BridgeError) {
844
- return c.json({ error: err.message }, 400);
853
+ if (err2 instanceof CompanionError) {
854
+ return c.json({ error: err2.message }, 400);
845
855
  }
846
- return c.json({ error: err instanceof Error ? err.message : "internal error" }, 500);
856
+ return c.json({ error: err2 instanceof Error ? err2.message : "internal error" }, 500);
847
857
  });
848
858
  registerRoutes(app, { ...deps, staticDir: resolveStaticDir() });
849
859
  return app;
@@ -864,15 +874,15 @@ async function startDashboard(opts) {
864
874
  server.closeAllConnections?.();
865
875
  })
866
876
  };
867
- } catch (err) {
868
- if (isAddrInUse(err)) {
869
- lastErr = err;
877
+ } catch (err2) {
878
+ if (isAddrInUse(err2)) {
879
+ lastErr = err2;
870
880
  continue;
871
881
  }
872
- throw err;
882
+ throw err2;
873
883
  }
874
884
  }
875
- throw new BridgeError(
885
+ throw new CompanionError(
876
886
  `couldn't bind the dashboard to any port in ${preferred}\u2013${preferred + PORT_FALLBACK_SPAN - 1} (all in use). Free one up or pass --port. (last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)})`
877
887
  );
878
888
  }
@@ -885,16 +895,16 @@ function listen(app, port) {
885
895
  resolve(server);
886
896
  }
887
897
  });
888
- server.on("error", (err) => {
898
+ server.on("error", (err2) => {
889
899
  if (!settled) {
890
900
  settled = true;
891
- reject(err);
901
+ reject(err2);
892
902
  }
893
903
  });
894
904
  });
895
905
  }
896
- function isAddrInUse(err) {
897
- return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
906
+ function isAddrInUse(err2) {
907
+ return Boolean(err2 && typeof err2 === "object" && "code" in err2 && err2.code === "EADDRINUSE");
898
908
  }
899
909
  function resolveStaticDir() {
900
910
  return join4(dirname3(fileURLToPath(import.meta.url)), "static");
@@ -1018,8 +1028,8 @@ async function ensureRuntimeAvailable(cfg, deps = {}) {
1018
1028
  );
1019
1029
  return;
1020
1030
  }
1021
- throw new BridgeError(
1022
- "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)."
1031
+ throw new CompanionError(
1032
+ "Claude Code, the companion\u2019s default runtime, is not on your PATH. Install it with `npm i -g @anthropic-ai/claude-code`, log in (`claude` then follow the prompts), and run `cabane-companion start` again. The default runtime uses your local Claude Code subscription to run each agent turn; opencode is supported as an alternate runtime you configure per device (see the companion README)."
1023
1033
  );
1024
1034
  }
1025
1035
 
@@ -1091,8 +1101,8 @@ async function verifyRuntime(state, fetchImpl = fetch) {
1091
1101
  let res;
1092
1102
  try {
1093
1103
  res = await fetchImpl(url, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
1094
- } catch (err) {
1095
- return isConnRefused(err) ? "stale" : "unknown";
1104
+ } catch (err2) {
1105
+ return isConnRefused(err2) ? "stale" : "unknown";
1096
1106
  }
1097
1107
  if (!res.ok) return "unknown";
1098
1108
  let body;
@@ -1104,9 +1114,9 @@ async function verifyRuntime(state, fetchImpl = fetch) {
1104
1114
  if (typeof body.instance_id !== "string") return "unknown";
1105
1115
  return body.instance_id === state.instanceId ? "ours" : "stale";
1106
1116
  }
1107
- function isConnRefused(err) {
1108
- if (!err || typeof err !== "object") return false;
1109
- const cause = err.cause;
1117
+ function isConnRefused(err2) {
1118
+ if (!err2 || typeof err2 !== "object") return false;
1119
+ const cause = err2.cause;
1110
1120
  return !!cause && typeof cause === "object" && cause.code === "ECONNREFUSED";
1111
1121
  }
1112
1122
  function trimSlash(s) {
@@ -1169,10 +1179,10 @@ var CabaneApi = class {
1169
1179
  for (let attempt = 1; ; attempt++) {
1170
1180
  try {
1171
1181
  return await this.attempt(method, path3, body, signal);
1172
- } catch (err) {
1173
- if (attempt >= maxAttempts || signal?.aborted || !isRetryable(err)) throw err;
1182
+ } catch (err2) {
1183
+ if (attempt >= maxAttempts || signal?.aborted || !isRetryable(err2)) throw err2;
1174
1184
  await sleep(RETRY_BACKOFF_MS[attempt - 1], signal);
1175
- if (signal?.aborted) throw err;
1185
+ if (signal?.aborted) throw err2;
1176
1186
  }
1177
1187
  }
1178
1188
  }
@@ -1200,15 +1210,15 @@ var CabaneApi = class {
1200
1210
  retry: true,
1201
1211
  ...signal ? { signal } : {}
1202
1212
  });
1203
- } catch (err) {
1213
+ } catch (err2) {
1204
1214
  const outbox = this.opts.outbox;
1205
- if (!outbox) throw err;
1206
- if (signal?.aborted || isAbortError(err)) throw err;
1207
- if (!isRetryable(err)) throw err;
1215
+ if (!outbox) throw err2;
1216
+ if (signal?.aborted || isAbortError(err2)) throw err2;
1217
+ if (!isRetryable(err2)) throw err2;
1208
1218
  outbox.persist({ enqueuedAt: Date.now(), turnId, seq, method: "POST", path: path3, body, kind });
1209
1219
  this.opts.log?.warn(
1210
- { kind, turnId, seq, err: err instanceof Error ? err.message : String(err) },
1211
- "bridge: commit queued to outbox after transient failure (will drain when the API returns)"
1220
+ { kind, turnId, seq, err: err2 instanceof Error ? err2.message : String(err2) },
1221
+ "companion: commit queued to outbox after transient failure (will drain when the API returns)"
1212
1222
  );
1213
1223
  }
1214
1224
  }
@@ -1231,11 +1241,11 @@ var CabaneApi = class {
1231
1241
  await this.request(entry.method, entry.path, entry.body, { retry: true });
1232
1242
  outbox.remove(entry.turnId, entry.seq);
1233
1243
  progressed = true;
1234
- } catch (err) {
1235
- if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
1244
+ } catch (err2) {
1245
+ if (err2 instanceof ApiError && err2.status >= 400 && err2.status < 500) {
1236
1246
  this.opts.log?.warn(
1237
- { kind: entry.kind, turnId: entry.turnId, seq: entry.seq, status: err.status },
1238
- "bridge outbox: discarding entry on terminal 4xx (will never land)"
1247
+ { kind: entry.kind, turnId: entry.turnId, seq: entry.seq, status: err2.status },
1248
+ "companion outbox: discarding entry on terminal 4xx (will never land)"
1239
1249
  );
1240
1250
  outbox.remove(entry.turnId, entry.seq);
1241
1251
  progressed = true;
@@ -1262,7 +1272,7 @@ var CabaneApi = class {
1262
1272
  // the machinery the `sub_agent` turn-control tool is sugar over. Two things make
1263
1273
  // it distinct from an ordinary `request` call, so it does its own `fetch`:
1264
1274
  // - a PER-CALL bearer — the turn's OBO token when the API minted one, else the
1265
- // bridge PAT — so the spawn carries the same authority as the agent's other
1275
+ // companion PAT — so the spawn carries the same authority as the agent's other
1266
1276
  // cabane calls this turn (mirrors build-options' `turnToken ?? agentPat`);
1267
1277
  // - the `x-cabane-active-conversation` header naming the caller's turn, which
1268
1278
  // the server verifies against the live run to resolve the caller pair for the
@@ -1295,15 +1305,15 @@ var CabaneApi = class {
1295
1305
  }
1296
1306
  return { status: res.status, body: parsed };
1297
1307
  }
1298
- // SJ383: bridge-only participant ops. All three authenticate with the
1308
+ // SJ383: companion-only participant ops. All three authenticate with the
1299
1309
  // workspace's agent-bound PAT (passed as `token` on this client) — never
1300
1310
  // the user PAT.
1301
- // Recovery-path read: returns the bridge's `agentSessionId` for this
1311
+ // Recovery-path read: returns the companion's `agentSessionId` for this
1302
1312
  // conversation so the dispatcher can pass it as `Options.resume`, plus
1303
- // the current `activeRunStartedAt` so a freshly-reconnected bridge can
1313
+ // the current `activeRunStartedAt` so a freshly-reconnected companion can
1304
1314
  // see whether a prior run is still flagged in-flight.
1305
1315
  // SJ383: recovery-path read for a participant row — the `agentSessionId` a
1306
- // freshly-reconnected bridge resumes on. CT262: the per-turn piggybacks this
1316
+ // freshly-reconnected companion resumes on. CT262: the per-turn piggybacks this
1307
1317
  // fetch grew (agentRules / visionBlocks / channel / members / conversationContext)
1308
1318
  // are gone — `getTurnContext` composes the whole turn now — so this is back to
1309
1319
  // the plain recovery read, with no `messageId` param.
@@ -1313,17 +1323,37 @@ var CabaneApi = class {
1313
1323
  `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}`
1314
1324
  );
1315
1325
  }
1316
- // CT262: the ONE turn-context fetch. Collapses the bridge's old four-fetch
1326
+ // CT262: the ONE turn-context fetch. Collapses the companion's old four-fetch
1317
1327
  // choreography (getConversation + getMessage + getAgentSelf + getParticipantAgent)
1318
1328
  // into a single call: the server composes the whole server portion of the
1319
1329
  // `TurnRequest` — systemPrompt, per-turn prompt + vision content, effective
1320
1330
  // run-config, `HostPolicy`, prior session, the user MCP definitions to resolve
1321
1331
  // locally, plus the anchor/title + trigger-message summary the host needs.
1322
1332
  // Agent-PAT authed; the workspace is implied by the PAT.
1323
- getTurnContext(conversationId, messageId2) {
1324
- const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}`;
1333
+ // CT714: `turnId` is the host-minted id for THIS turn, passed so the server can
1334
+ // bind the minted turn token to it — the turn-control surface then rejects a
1335
+ // token whose turn has ended. The dispatcher mints it before this call and
1336
+ // reuses the same value on its active-run PATCH, so the token's turn id and the
1337
+ // pair's `active_turn_id` agree.
1338
+ getTurnContext(conversationId, messageId2, turnId) {
1339
+ const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
1325
1340
  return this.request("GET", `/api/agent/turn-context?${q}`);
1326
1341
  }
1342
+ // CT714: read a turn's recorded turn-control intent (ask/wake/summon/skip). An
1343
+ // EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
1344
+ // `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
1345
+ // in-memory closures, so the dispatcher fetches this once at settle — by
1346
+ // `turnId` — and populates those closures, letting the unchanged settle path
1347
+ // materialize the effects identically to claude-code. Agent-PAT authed +
1348
+ // self-scoped (`:agentId` must match the PAT's agent). A turn that recorded no
1349
+ // control verb returns all-empty fields.
1350
+ getTurnIntent(workspaceId, conversationId, agentId, turnId) {
1351
+ const q = `turnId=${encodeURIComponent(turnId)}`;
1352
+ return this.request(
1353
+ "GET",
1354
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-intent?${q}`
1355
+ );
1356
+ }
1327
1357
  // Flips `active_run_started_at` and optionally captures the SDK session
1328
1358
  // id. The dispatcher hits this twice per turn (now() before the SDK
1329
1359
  // loop; null when it settles) plus once with the session id on the
@@ -1336,7 +1366,7 @@ var CabaneApi = class {
1336
1366
  // `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
1337
1367
  // left best-effort: it's lower-stakes and self-heals on the next turn, so it
1338
1368
  // stays a single-shot PATCH and is deliberately out of CT93's scope.
1339
- setBridgeActiveRun(workspaceId, conversationId, agentId, body) {
1369
+ setActiveRun(workspaceId, conversationId, agentId, body) {
1340
1370
  const path3 = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
1341
1371
  const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
1342
1372
  if (touchesFlag && this.opts.outbox) {
@@ -1362,11 +1392,11 @@ var CabaneApi = class {
1362
1392
  try {
1363
1393
  await this.request("PATCH", path3, body, { retry: true });
1364
1394
  outbox?.remove(key, ACTIVE_RUN_OUTBOX_SEQ);
1365
- } catch (err) {
1366
- if (!outbox) throw err;
1367
- if (!isRetryable(err)) {
1395
+ } catch (err2) {
1396
+ if (!outbox) throw err2;
1397
+ if (!isRetryable(err2)) {
1368
1398
  outbox.remove(key, ACTIVE_RUN_OUTBOX_SEQ);
1369
- throw err;
1399
+ throw err2;
1370
1400
  }
1371
1401
  outbox.persist({
1372
1402
  enqueuedAt: Date.now(),
@@ -1378,17 +1408,17 @@ var CabaneApi = class {
1378
1408
  kind: "active-run"
1379
1409
  });
1380
1410
  this.opts.log?.warn(
1381
- { conversationId, agentId, err: err instanceof Error ? err.message : String(err) },
1382
- "bridge: active-run write queued to outbox after transient failure (will drain when the API returns)"
1411
+ { conversationId, agentId, err: err2 instanceof Error ? err2.message : String(err2) },
1412
+ "companion: active-run write queued to outbox after transient failure (will drain when the API returns)"
1383
1413
  );
1384
1414
  }
1385
1415
  }
1386
1416
  // CT29: per-device liveness moved off the per-workspace agent PAT and onto the
1387
1417
  // device token — see `DeviceApi.heartbeat`. There is no agent-PAT heartbeat
1388
1418
  // anymore.
1389
- // SJ477: commit one row of the bridge's turn (a `progress` interim note or
1419
+ // SJ477: commit one row of the companion's turn (a `progress` interim note or
1390
1420
  // the `final` reply), derived from its own SDK transcript. Posts to the same
1391
- // public messages endpoint a user hits — the bridge holds an agent-bound
1421
+ // public messages endpoint a user hits — the companion holds an agent-bound
1392
1422
  // PAT, so the server attributes the row to this agent (role `agent`) and
1393
1423
  // won't re-dispatch (the route gates re-dispatch on role `user`). `turnId`
1394
1424
  // groups every row of one turn so the chat drawer renders them as a single
@@ -1403,12 +1433,12 @@ var CabaneApi = class {
1403
1433
  // CT11: `kind` now includes `'stopped'` for the terminal marker the
1404
1434
  // dispatcher writes when a turn is cancelled — same wire shape as
1405
1435
  // `progress`/`final`, distinguished only by `kind` so the chat drawer's
1406
- // turn-group renderer treats it as a closing row. `seq` is the bridge's
1436
+ // turn-group renderer treats it as a closing row. `seq` is the companion's
1407
1437
  // per-turn monotonic counter, stamped on the row so the merged timeline
1408
1438
  // orders the commit deterministically against the persisted tool/thinking
1409
- // rows. Both fields are optional on the wire — an older bridge that didn't
1439
+ // rows. Both fields are optional on the wire — an older companion that didn't
1410
1440
  // mint seq still validates (the server defaults to 0); `stopped` is only
1411
- // emitted by post-CT11 bridges.
1441
+ // emitted by post-CT11 companions.
1412
1442
  postTurnMessage(workspaceId, conversationId, body, signal) {
1413
1443
  return this.durableCommit(
1414
1444
  "message",
@@ -1449,11 +1479,11 @@ var CabaneApi = class {
1449
1479
  );
1450
1480
  }
1451
1481
  // SJ493: fetch the agent's self-view — identity + operating context. The
1452
- // bridge calls this on each dispatch to get its `systemPrompt` (composed
1482
+ // companion calls this on each dispatch to get its `systemPrompt` (composed
1453
1483
  // server-side from the bundled default + the agent's charter) rather than
1454
1484
  // baking a copy of the prompt into the download. Cabane is the control plane
1455
1485
  // for the prompt, so changing it (or the per-agent charter) takes effect
1456
- // without shipping a new bridge. Agent-PAT authed; the workspace is implied
1486
+ // without shipping a new companion. Agent-PAT authed; the workspace is implied
1457
1487
  // by the PAT, so no workspace arg.
1458
1488
  // CT245: pass the triggering turn's `conversationId` so the server returns the
1459
1489
  // run-config RESOLVED for this conversation (agent default + that
@@ -1464,10 +1494,10 @@ var CabaneApi = class {
1464
1494
  const path3 = conversationId ? `/api/agent/me?conversationId=${encodeURIComponent(conversationId)}` : `/api/agent/me`;
1465
1495
  return this.request("GET", path3);
1466
1496
  }
1467
- // The bridge fetches the triggering message body by listing the
1497
+ // The companion fetches the triggering message body by listing the
1468
1498
  // conversation's messages and finding the one with `id === messageId`.
1469
1499
  // Cabane has no single-message GET endpoint; for v0 this is fine because
1470
- // the bridge only reaches for the specific row immediately after the
1500
+ // the companion only reaches for the specific row immediately after the
1471
1501
  // event fires (the thread is small at that point).
1472
1502
  async getMessage(workspaceId, conversationId, messageId2) {
1473
1503
  const res = await this.request(
@@ -1477,13 +1507,13 @@ var CabaneApi = class {
1477
1507
  return res.messages.find((m) => m.id === messageId2) ?? null;
1478
1508
  }
1479
1509
  };
1480
- function isRetryable(err) {
1481
- if (err instanceof ApiError) return err.status >= 500;
1482
- if (isAbortError(err)) return false;
1510
+ function isRetryable(err2) {
1511
+ if (err2 instanceof ApiError) return err2.status >= 500;
1512
+ if (isAbortError(err2)) return false;
1483
1513
  return true;
1484
1514
  }
1485
- function isAbortError(err) {
1486
- return err instanceof Error && err.name === "AbortError";
1515
+ function isAbortError(err2) {
1516
+ return err2 instanceof Error && err2.name === "AbortError";
1487
1517
  }
1488
1518
  function sleep(ms, signal) {
1489
1519
  return new Promise((resolve) => {
@@ -1501,8 +1531,8 @@ function sleep(ms, signal) {
1501
1531
  }
1502
1532
  function errorMessage(status, body) {
1503
1533
  if (body && typeof body === "object" && "error" in body) {
1504
- const err = body.error;
1505
- if (typeof err === "string") return `${status} ${err}`;
1534
+ const err2 = body.error;
1535
+ if (typeof err2 === "string") return `${status} ${err2}`;
1506
1536
  }
1507
1537
  if (typeof body === "string" && body.length > 0) return `${status} ${body.slice(0, 200)}`;
1508
1538
  return `${status} error`;
@@ -1546,7 +1576,10 @@ var DeviceApi = class {
1546
1576
  getAssignments() {
1547
1577
  return this.request("GET", "/api/companion/assignments");
1548
1578
  }
1549
- // Per-device liveness ping. Reports the bridge build version and the env-var
1579
+ beginDrain() {
1580
+ return this.request("POST", "/api/companion/drain", {});
1581
+ }
1582
+ // Per-device liveness ping. Reports the companion build version and the env-var
1550
1583
  // names the operator's secret store exposes (never values), so CT30's UI can
1551
1584
  // warn pre-emptively about an agent that needs a secret this device lacks.
1552
1585
  heartbeat(body) {
@@ -1555,8 +1588,8 @@ var DeviceApi = class {
1555
1588
  };
1556
1589
  function errorMessage2(status, body) {
1557
1590
  if (body && typeof body === "object" && "error" in body) {
1558
- const err = body.error;
1559
- if (typeof err === "string") return `${status} ${err}`;
1591
+ const err2 = body.error;
1592
+ if (typeof err2 === "string") return `${status} ${err2}`;
1560
1593
  }
1561
1594
  if (typeof body === "string" && body.length > 0) return `${status} ${body.slice(0, 200)}`;
1562
1595
  return `${status} error`;
@@ -1610,12 +1643,12 @@ function save(map) {
1610
1643
  } catch {
1611
1644
  }
1612
1645
  renameSync2(tmp, path3);
1613
- } catch (err) {
1646
+ } catch (err2) {
1614
1647
  try {
1615
1648
  rmSync3(tmp, { force: true });
1616
1649
  } catch {
1617
1650
  }
1618
- throw err;
1651
+ throw err2;
1619
1652
  }
1620
1653
  }
1621
1654
  function getCredential(agentId) {
@@ -1781,7 +1814,7 @@ function bumpResumeAttempt(workspaceId, eventId) {
1781
1814
  return next;
1782
1815
  }
1783
1816
  function noResume() {
1784
- return process.env.CABANE_BRIDGE_NO_RESUME === "1";
1817
+ return process.env.CABANE_COMPANION_NO_RESUME === "1";
1785
1818
  }
1786
1819
 
1787
1820
  // packages/agent-runtime/src/version.ts
@@ -1798,16 +1831,17 @@ var hostPolicySchema = z5.object({
1798
1831
  // Public web (today's `WebSearch` / `WebFetch`, the `DEFAULT_WEB_TOOLS`). Public
1799
1832
  // web, not host reach — granted by default today, but expressible as a grant.
1800
1833
  web: z5.boolean(),
1801
- // Browser automation (the Playwright MCP surface). Varies by host: a bridge has
1834
+ // Browser automation (the Playwright MCP surface). Varies by host: a companion has
1802
1835
  // it, the house executor does not (CT230).
1803
1836
  browser: z5.boolean(),
1804
1837
  // User-configured MCP servers permitted. False for the house executor
1805
- // (CT227: Cabane agents run no user MCP servers), true for a personal bridge.
1838
+ // (CT227: Cabane agents run no user MCP servers), true for a personal companion.
1806
1839
  userMcp: z5.boolean(),
1807
1840
  // Subagent / sub-dispatch (`Task` / `Agent`). A per-host GRANT (see the CT261
1808
1841
  // amendment above): `false` on the locked assistant/house surface (banned via
1809
1842
  // `DISALLOWED_TOOLS`), `true` under coding / custom where the fan-out is left to
1810
- // `canUseTool` or the user allowlist. The subagent completes within the turn, so
1843
+ // the coding allow-hook (which reaches sub-agent tool calls, CT680) or the user
1844
+ // allowlist. The subagent completes within the turn, so
1811
1845
  // it's not the turn-model invariant `scheduling` is.
1812
1846
  subagents: z5.boolean(),
1813
1847
  // ── Hard platform invariants — always denied, never granted ────────────────
@@ -1828,7 +1862,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1828
1862
  // The runtime's opaque session state, emitted when the adapter learns it (e.g.
1829
1863
  // the SDK `system/init` frame). The platform stores `state` verbatim per
1830
1864
  // (conversation, agent) and hands it back on the next turn; only the adapter
1831
- // knows what it means. Today's bridge captures the raw SDK session id here; a
1865
+ // knows what it means. Today's companion captures the raw SDK session id here; a
1832
1866
  // future adapter may encode more (e.g. `{sdkSessionId, cwd}`) — still one
1833
1867
  // opaque string to the platform.
1834
1868
  //
@@ -1841,7 +1875,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1841
1875
  // so the mark now over-reaches an empty session. The host relays `degraded`
1842
1876
  // on settle and the server rewinds the mark, so the NEXT turn rebuilds a full
1843
1877
  // catch-up (this failing turn is unavoidably lossy — the degrade is only known
1844
- // on the bridge, after the server committed the manifest). Runtime-neutral: a
1878
+ // on the companion, after the server committed the manifest). Runtime-neutral: a
1845
1879
  // plain boolean, not a runtime-specific reason string (that stays in the
1846
1880
  // adapter's `onWarn` log). Additive + optional — an old receiver ignores it.
1847
1881
  z6.object({
@@ -1889,7 +1923,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1889
1923
  // dropped on the floor before. `inputTokens` is the full context the model saw
1890
1924
  // (uncached + cache-read + cache-creation input), so it doubles as the
1891
1925
  // context-window cost; `outputTokens` the generated tokens. Optional ⇒
1892
- // backward-compatible: an old bridge / adapter that never sets it, and a
1926
+ // backward-compatible: an old companion / adapter that never sets it, and a
1893
1927
  // receiver that never reads it, are unaffected (the turn's token columns stay
1894
1928
  // null → the UI shows `—`).
1895
1929
  //
@@ -1899,6 +1933,19 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1899
1933
  // the server just leaves the cache columns null. Captured now because honest
1900
1934
  // costing later prices a cache-read token far below a fresh input token.
1901
1935
  //
1936
+ // CT699: `inputTokens` (and the cache slice) is a BILLING quantity — for
1937
+ // claude-code/codex it's the runtime's CUMULATIVE total summed across every model
1938
+ // request in the agentic turn, so it grows with the tool-call count and is NOT
1939
+ // "how full is the window." `contextTokens` is the distinct CONTEXT-OCCUPANCY
1940
+ // read: the input the model saw on its FINAL request of the turn (uncached +
1941
+ // cache, since cached tokens still occupy the window) — the number the composer
1942
+ // gauge wants. `contextWindow` is the model's true window in tokens when the
1943
+ // runtime reports it (claude-code's SDK does, per model) — a real denominator so
1944
+ // the gauge can show a fraction. Both optional: a runtime that can't source a
1945
+ // clean final-request figure (codex's cumulative-only usage) omits `contextTokens`
1946
+ // and the gauge falls back to the raw count; `contextWindow` falls back to the
1947
+ // model catalog.
1948
+ //
1902
1949
  // CT601: two more optional carry-homes on the terminal result, alongside
1903
1950
  // `usage`. `resolvedModel` is the CONCRETE model the runtime actually ran —
1904
1951
  // claude-code learns it from the `system/init` frame mid-run (even for a
@@ -1908,7 +1955,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1908
1955
  // for claude-code today). Both only known after the run streams — so they ride
1909
1956
  // the terminal event home, the host relays them on settle, and the server writes
1910
1957
  // `message_turn_metadata.resolved_model` / `resolved_config`. Optional ⇒
1911
- // backward-compatible: an old adapter/bridge omits them, a cancel has no result
1958
+ // backward-compatible: an old adapter/companion omits them, a cancel has no result
1912
1959
  // event at all, and the columns stay null → the UI shows `—`.
1913
1960
  z6.object({
1914
1961
  type: z6.literal("result"),
@@ -1918,7 +1965,9 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1918
1965
  inputTokens: z6.number(),
1919
1966
  outputTokens: z6.number(),
1920
1967
  cacheReadTokens: z6.number().optional(),
1921
- cacheCreationTokens: z6.number().optional()
1968
+ cacheCreationTokens: z6.number().optional(),
1969
+ contextTokens: z6.number().optional(),
1970
+ contextWindow: z6.number().optional()
1922
1971
  }).optional(),
1923
1972
  resolvedModel: z6.string().optional(),
1924
1973
  resolvedConfig: z6.object({
@@ -2006,7 +2055,8 @@ function classifyErrorText(text) {
2006
2055
  const t = text.toLowerCase();
2007
2056
  if (AUTH_PATTERNS.some((re) => re.test(t))) return { kind: "auth_expired" };
2008
2057
  if (SERVER_PATTERNS.some((re) => re.test(t))) return { kind: "server_error" };
2009
- const capNoun = CAP_PATTERNS.some((re) => re.test(t));
2058
+ const withoutNegatedCap = t.replace(NEGATED_CAP, "");
2059
+ const capNoun = CAP_PATTERNS.some((re) => re.test(withoutNegatedCap));
2010
2060
  const rateToken = RATE_PATTERNS.some((re) => re.test(t));
2011
2061
  if (capNoun) return { kind: "usage_capped" };
2012
2062
  if (rateToken) return { kind: "rate_limited" };
@@ -2035,6 +2085,7 @@ var SERVER_PATTERNS = [
2035
2085
  /fetch failed/
2036
2086
  ];
2037
2087
  var CAP_PATTERNS = [/usage limit/, /weekly limit/, /session limit/, /\bquota\b/];
2088
+ var NEGATED_CAP = /not (your|a) usage limit/g;
2038
2089
  var RATE_PATTERNS = [/rate[_ ]?limit/, /\b429\b/, /too many requests/];
2039
2090
  var BARE_LIMIT = /\blimit (reached|exceeded)\b/;
2040
2091
 
@@ -2096,34 +2147,62 @@ var turnRequestSchema = z8.object({
2096
2147
  mcpUrl: z8.string(),
2097
2148
  bearer: z8.string(),
2098
2149
  activeConversationId: z8.string(),
2150
+ // CT714: the scoped TURN-CONTROL MCP endpoint (`/api/turn-control`). The
2151
+ // EXTERNAL adapters (Codex / opencode) mount it by URL under the key
2152
+ // `cabane_companion` — using the same `bearer` (the turn token) and the same
2153
+ // active-conversation header they send to the `cabane` server — so their
2154
+ // agents get `ask`/`wake_me`/`summon_agent`/`sub_agent`/`skip_turn`, the
2155
+ // verbs they can't get from the companion's in-process SDK server. Optional:
2156
+ // claude-code ignores it (it mounts the in-process instance instead), and
2157
+ // every existing `cabane`-block fixture keeps parsing unchanged; the
2158
+ // companion always populates it (`build-options.ts`).
2159
+ turnControlUrl: z8.string().optional(),
2099
2160
  // CT598: the workspace this turn runs in. The claude-code/opencode/codex
2100
2161
  // adapters never need it (they reach Cabane through the `cabane` MCP server,
2101
2162
  // which takes `workspaceId` as a per-tool arg the model supplies); the
2102
2163
  // native runtime's interim tool surface calls the workspace-scoped REST API
2103
2164
  // DIRECTLY, so it needs the id host-side rather than trusting the model to
2104
2165
  // pass it. Optional so every existing `cabane`-block constructor (the three
2105
- // adapters' conformance fixtures, tests) keeps parsing unchanged — the bridge
2166
+ // adapters' conformance fixtures, tests) keeps parsing unchanged — the companion
2106
2167
  // always populates it (`build-options.ts`), and the native adapter fails the
2107
2168
  // turn loudly when it is somehow absent rather than guessing.
2108
- workspaceId: z8.string().optional()
2169
+ workspaceId: z8.string().optional(),
2170
+ // CT752: the server-resolved workspace surface this credential exposes.
2171
+ // Readiness uses this explicit fact to require `sdk` for code mode and the
2172
+ // granular floor for classic mode; inventory contents alone cannot infer it
2173
+ // because `sdk` is intentionally also available on the classic surface.
2174
+ workspaceToolSurface: z8.enum(["code", "classic"]).optional()
2109
2175
  }),
2110
2176
  // Machine-local resolution (host-filled): the checkout cwd, extra env from a
2111
2177
  // prepare hook, and the resolved user MCP servers.
2112
2178
  local: z8.object({
2113
2179
  cwd: z8.string().optional(),
2114
2180
  env: z8.record(z8.string(), z8.string()).optional(),
2181
+ nativeWorkAssignment: z8.object({
2182
+ itemId: z8.string(),
2183
+ executionId: z8.string(),
2184
+ activationEpoch: z8.number().int().nonnegative()
2185
+ }).strict().optional(),
2115
2186
  mcpServers: resolvedMcpServersSchema.optional(),
2116
2187
  // CT289: machine-local claude-code adapter knobs the operator sets on a
2117
- // bridge they run themselves — the auto-memory escape hatch. `autoMemory:
2188
+ // companion they run themselves — the auto-memory escape hatch. `autoMemory:
2118
2189
  // true` opts back into Claude Code's auto-memory (governed by the operator's
2119
2190
  // own `.claude/settings.json`); absent/false leaves the adapter's force-off
2120
- // default in place (see `buildClaudeCodeOptions`). The house device never
2191
+ // default in place (see `buildClaudeCodeOptions`). The In-Cabane executor never
2121
2192
  // sets it, so house stays force-off unconditionally.
2122
2193
  claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
2123
2194
  }),
2124
2195
  // Host-owned injected servers (host-filled) — e.g. the summon server.
2125
2196
  extra: z8.object({
2126
- mcpServers: hostInjectedServersSchema
2197
+ mcpServers: hostInjectedServersSchema,
2198
+ // CT666: the per-turn turn-control HANDLER for the in-process cabane-native
2199
+ // runtime — the seam that gives it ask/wake_me/summon_agent/sub_agent/skip_turn
2200
+ // without a subprocess `cabane_companion` MCP server. Typed `unknown` for the same
2201
+ // reason as `mcpServers`: it's a live host object (a `NativeTurnControl` whose
2202
+ // methods close over the dispatcher's per-turn state), passed to the native
2203
+ // adapter WITHOUT the contract package inspecting it. The subprocess adapters
2204
+ // ignore it (they get the same verbs from the injected SDK server instead).
2205
+ turnControl: z8.unknown().optional()
2127
2206
  })
2128
2207
  });
2129
2208
 
@@ -2143,8 +2222,8 @@ function createTerminalTextBuffer() {
2143
2222
  async function safeEmit(emit, event, onError) {
2144
2223
  try {
2145
2224
  await emit(event);
2146
- } catch (err) {
2147
- onError?.(err, event.type);
2225
+ } catch (err2) {
2226
+ onError?.(err2, event.type);
2148
2227
  }
2149
2228
  }
2150
2229
  async function processAssistantMessage(msg, emit, pending, buffer, onError) {
@@ -2405,16 +2484,16 @@ var TurnPump = class {
2405
2484
  // minimal note. Skipped when cancelled or already final. The held-text flush
2406
2485
  // that precedes it is a classification concern, driven by the caller before
2407
2486
  // this runs.
2408
- async finalize(ok) {
2409
- if (!ok || this.opts.signal.aborted || this.emittedFinal) return;
2487
+ async finalize(ok2) {
2488
+ if (!ok2 || this.opts.signal.aborted || this.emittedFinal) return;
2410
2489
  const body = this.lastProgressBody ?? this.opts.emptyFinalBody;
2411
2490
  const seq = this.opts.nextSeq();
2412
2491
  try {
2413
2492
  await this.opts.commit.commitMessage({ body, kind: "final", seq });
2414
2493
  this.emittedFinal = true;
2415
2494
  this.finalReplyBody = body;
2416
- } catch (err) {
2417
- this.opts.onError?.(err, "empty-final");
2495
+ } catch (err2) {
2496
+ this.opts.onError?.(err2, "empty-final");
2418
2497
  }
2419
2498
  }
2420
2499
  // Whether the turn has committed its `final` row — read by the host to decide
@@ -2504,10 +2583,10 @@ var claudeCodeDialectSchema = z10.object({
2504
2583
  allowedTools: z10.array(z10.string()).optional(),
2505
2584
  disallowedTools: z10.array(z10.string()).optional(),
2506
2585
  // Which claude-code harness shape to run. `coding` switches to the
2507
- // `claude_code` preset + project settings + always-allow `canUseTool`;
2586
+ // `claude_code` preset + project settings + always-allow `PreToolUse` hook;
2508
2587
  // `custom`/`assistant` (or absent) use a plain-string prompt + bypass. This
2509
2588
  // is the claude-code-specific PRESET selector — kept distinct from
2510
- // `policy.hostFs` (the host-fs BLOCK), because bridge `custom` mode wants host
2589
+ // `policy.hostFs` (the host-fs BLOCK), because companion `custom` mode wants host
2511
2590
  // fs available (via its own allowlist) WITHOUT the coding harness, and in-app
2512
2591
  // `custom` wants host fs blocked — neither of which a single `hostFs` boolean
2513
2592
  // can express alongside the preset choice.
@@ -2555,10 +2634,18 @@ function decideResume(stored, currentCwd) {
2555
2634
  // packages/agent-runtime/src/claude-code/options.ts
2556
2635
  var CABANE_MCP_SERVER = "cabane";
2557
2636
  var ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
2558
- var allowEverything = async (_toolName, input) => ({
2559
- behavior: "allow",
2560
- updatedInput: input
2561
- });
2637
+ async function allowEverythingHook(input) {
2638
+ const toolInput = "tool_input" in input && input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
2639
+ return {
2640
+ continue: true,
2641
+ hookSpecificOutput: {
2642
+ hookEventName: "PreToolUse",
2643
+ permissionDecision: "allow",
2644
+ permissionDecisionReason: "coding mode: headless never-prompt (CT680)",
2645
+ updatedInput: toolInput
2646
+ }
2647
+ };
2648
+ }
2562
2649
  function buildClaudeCodeOptions(req, augment) {
2563
2650
  const { policy, config } = req;
2564
2651
  const cwd = req.local.cwd;
@@ -2619,7 +2706,7 @@ function buildClaudeCodeOptions(req, augment) {
2619
2706
  ...devControlsAutoMemory ? {} : { settings: { autoMemoryEnabled: false } },
2620
2707
  mcpServers,
2621
2708
  ...cwd ? { cwd } : {},
2622
- // Extra env (a bridge prepare hook's tokens/ports; the in-app's debug flags)
2709
+ // Extra env (a companion prepare hook's tokens/ports; the in-app's debug flags)
2623
2710
  // merged OVER the inherited environment.
2624
2711
  ...req.local.env ? { env: { ...process.env, ...req.local.env } } : {},
2625
2712
  ...resume ? { resume } : {}
@@ -2632,7 +2719,7 @@ function buildClaudeCodeOptions(req, augment) {
2632
2719
  settingSources: ["project"],
2633
2720
  allowedTools,
2634
2721
  disallowedTools,
2635
- canUseTool: allowEverything
2722
+ hooks: { PreToolUse: [{ hooks: [allowEverythingHook] }] }
2636
2723
  };
2637
2724
  } else {
2638
2725
  options = {
@@ -2659,13 +2746,15 @@ async function* decodeSdkStream(iter, ctx) {
2659
2746
  out.push(event);
2660
2747
  };
2661
2748
  let sessionEmitted = false;
2662
- let ok = false;
2749
+ let ok2 = false;
2663
2750
  let resultReason;
2664
2751
  let sawResult = false;
2665
2752
  let usage;
2753
+ let lastRequestContextTokens;
2666
2754
  let resolvedModel;
2667
2755
  let sawRejectedLimit = false;
2668
2756
  let rateLimitResetIso;
2757
+ let rateLimitType;
2669
2758
  let authError;
2670
2759
  let lastAssistantError;
2671
2760
  try {
@@ -2688,6 +2777,8 @@ async function* decodeSdkStream(iter, ctx) {
2688
2777
  if (typeof assistantErr === "string" && assistantErr.length > 0) {
2689
2778
  lastAssistantError = assistantErr;
2690
2779
  }
2780
+ const reqContext = readRequestContextTokens(msg);
2781
+ if (reqContext !== void 0) lastRequestContextTokens = reqContext;
2691
2782
  await processAssistantMessage(msg, emit, pending, buffer);
2692
2783
  yield* drain(out);
2693
2784
  } else if (msg.type === "user") {
@@ -2698,16 +2789,23 @@ async function* decodeSdkStream(iter, ctx) {
2698
2789
  if (info?.status === "rejected") {
2699
2790
  sawRejectedLimit = true;
2700
2791
  rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? rateLimitResetIso;
2792
+ if (typeof info.rateLimitType === "string") rateLimitType = info.rateLimitType;
2701
2793
  }
2702
2794
  } else if (msg.type === "auth_status") {
2703
- const err = msg.error;
2704
- if (typeof err === "string" && err.length > 0) authError = err;
2795
+ const err2 = msg.error;
2796
+ if (typeof err2 === "string" && err2.length > 0) authError = err2;
2705
2797
  } else if (msg.type === "result") {
2706
2798
  sawResult = true;
2707
2799
  usage = readSdkUsage(msg);
2800
+ if (usage) {
2801
+ if (lastRequestContextTokens !== void 0)
2802
+ usage.contextTokens = lastRequestContextTokens;
2803
+ const window = readContextWindow(msg, resolvedModel);
2804
+ if (window !== void 0) usage.contextWindow = window;
2805
+ }
2708
2806
  const isError = msg.is_error === true;
2709
2807
  if (msg.subtype === "success" && !isError) {
2710
- ok = true;
2808
+ ok2 = true;
2711
2809
  } else {
2712
2810
  const resultText = msg.result ?? "";
2713
2811
  const terminalReason = msg.terminal_reason;
@@ -2715,36 +2813,40 @@ async function* decodeSdkStream(iter, ctx) {
2715
2813
  const errorText = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(
2716
2814
  " "
2717
2815
  );
2718
- const failure = sawRejectedLimit || terminalReason === "blocking_limit" ? {
2816
+ const rejectedIsCap = sawRejectedLimit && (rateLimitResetIso !== void 0 || isSubscriptionWindow(rateLimitType));
2817
+ const failure = rejectedIsCap || terminalReason === "blocking_limit" ? {
2719
2818
  kind: "usage_capped",
2720
2819
  ...rateLimitResetIso ? { resetsAt: rateLimitResetIso } : {}
2721
2820
  } : classifyAssistantError(lastAssistantError) ?? classifyErrorText([authError, errorText].filter(Boolean).join(" "));
2722
2821
  resultReason = failure ? encodeFailureReason(failure) : isError ? `error:${resultText.slice(0, 200) || "unknown"}` : `result_error:${msg.subtype}`;
2723
- ok = false;
2822
+ ok2 = false;
2724
2823
  }
2725
2824
  break;
2726
2825
  }
2727
2826
  }
2728
- } catch (err) {
2729
- if (ctx.signal.aborted) throw err;
2730
- const failure = classifyErrorText(err instanceof Error ? err.message : String(err));
2731
- if (!failure) throw err;
2732
- ok = false;
2827
+ } catch (err2) {
2828
+ if (ctx.signal.aborted) throw err2;
2829
+ const failure = classifyErrorText(err2 instanceof Error ? err2.message : String(err2));
2830
+ if (!failure) throw err2;
2831
+ ok2 = false;
2733
2832
  resultReason = encodeFailureReason(failure);
2734
2833
  sawResult = true;
2735
2834
  }
2736
2835
  if (ctx.signal.aborted) return;
2737
- await flushHeldText(buffer, emit, ok);
2836
+ await flushHeldText(buffer, emit, ok2);
2738
2837
  yield* drain(out);
2739
- if (!ok && !resultReason && !sawResult) resultReason = "no_result";
2838
+ if (!ok2 && !resultReason && !sawResult) resultReason = "no_result";
2740
2839
  yield {
2741
2840
  type: "result",
2742
- ok,
2841
+ ok: ok2,
2743
2842
  ...resultReason ? { reason: resultReason } : {},
2744
2843
  ...usage ? { usage } : {},
2745
2844
  ...resolvedModel ? { resolvedModel } : {}
2746
2845
  };
2747
2846
  }
2847
+ function isSubscriptionWindow(value) {
2848
+ return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
2849
+ }
2748
2850
  function* drain(out) {
2749
2851
  while (out.length > 0) yield out.shift();
2750
2852
  }
@@ -2758,6 +2860,27 @@ function readSdkUsage(msg) {
2758
2860
  const outputTokens = num(usage.output_tokens);
2759
2861
  return { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens };
2760
2862
  }
2863
+ function readRequestContextTokens(msg) {
2864
+ const usage = msg.message?.usage;
2865
+ if (!usage || typeof usage !== "object") return void 0;
2866
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
2867
+ return num(usage.input_tokens) + num(usage.cache_read_input_tokens) + num(usage.cache_creation_input_tokens);
2868
+ }
2869
+ function readContextWindow(msg, resolvedModel) {
2870
+ const modelUsage = msg.modelUsage;
2871
+ if (!modelUsage || typeof modelUsage !== "object") return void 0;
2872
+ const pos = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
2873
+ if (resolvedModel) {
2874
+ const direct = pos(modelUsage[resolvedModel]?.contextWindow);
2875
+ if (direct !== void 0) return direct;
2876
+ }
2877
+ let max;
2878
+ for (const entry of Object.values(modelUsage)) {
2879
+ const w = pos(entry?.contextWindow);
2880
+ if (w !== void 0 && (max === void 0 || w > max)) max = w;
2881
+ }
2882
+ return max;
2883
+ }
2761
2884
 
2762
2885
  // packages/agent-runtime/src/claude-code/prompt-input.ts
2763
2886
  function buildQueryPrompt(req) {
@@ -2875,9 +2998,13 @@ var resultErrorFull = (subtype, extra = {}) => ({
2875
2998
  session_id: "s",
2876
2999
  ...extra
2877
3000
  });
2878
- var rateLimitEvent = (status, resetsAt) => ({
3001
+ var rateLimitEvent = (status, resetsAt, rateLimitType) => ({
2879
3002
  type: "rate_limit_event",
2880
- rate_limit_info: { status, ...resetsAt !== void 0 ? { resetsAt } : {} },
3003
+ rate_limit_info: {
3004
+ status,
3005
+ ...resetsAt !== void 0 ? { resetsAt } : {},
3006
+ ...rateLimitType !== void 0 ? { rateLimitType } : {}
3007
+ },
2881
3008
  session_id: "s"
2882
3009
  });
2883
3010
  var authStatus = (error) => ({
@@ -3041,17 +3168,16 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
3041
3168
  ]
3042
3169
  },
3043
3170
  {
3044
- // CT558/CT592: a subscription cap. The SDK emits a `rate_limit_event` with
3045
- // `status: 'rejected'`; the terminal error result then classifies as the
3046
- // structured `usage_capped` reason (reset-less here — the reset-time extraction
3047
- // is pinned by a focused unit test, off the wall clock). Partial narration lands
3048
- // as `progress`. CT592: the cap event is `usage_capped`, distinct from a 429.
3171
+ // CT558/CT592/CT731: a subscription cap. The SDK emits a rejected
3172
+ // `rate_limit_event` with a named subscription window; the terminal error result
3173
+ // then classifies as the structured `usage_capped` reason. Partial narration
3174
+ // lands as `progress`. The cap event is distinct from a provider 429 throttle.
3049
3175
  name: "subscription cap \u2192 usage_capped",
3050
3176
  request: makeRequest(),
3051
3177
  nativeStream: [
3052
3178
  init("s1"),
3053
3179
  assistantText("Let me work on that."),
3054
- rateLimitEvent("rejected"),
3180
+ rateLimitEvent("rejected", void 0, "five_hour"),
3055
3181
  resultError("error_during_execution")
3056
3182
  ],
3057
3183
  expected: [
@@ -3275,9 +3401,9 @@ function readSessionId(properties) {
3275
3401
  }
3276
3402
  function readSessionError(properties) {
3277
3403
  const props = asRecord(properties);
3278
- const err = props?.error;
3279
- if (typeof err === "string") return err;
3280
- const rec = asRecord(err);
3404
+ const err2 = props?.error;
3405
+ if (typeof err2 === "string") return err2;
3406
+ const rec = asRecord(err2);
3281
3407
  if (!rec) return "unknown";
3282
3408
  const { name, message } = deepestError(rec);
3283
3409
  if (message && name && !isGenericErrorName(name)) return `${name}: ${message}`;
@@ -3313,7 +3439,7 @@ async function* decodeOpencodeStream(events, ctx) {
3313
3439
  const pending = /* @__PURE__ */ new Map();
3314
3440
  const startedTools = /* @__PURE__ */ new Set();
3315
3441
  const finishedTools = /* @__PURE__ */ new Set();
3316
- let ok = false;
3442
+ let ok2 = false;
3317
3443
  let reason;
3318
3444
  let settled = false;
3319
3445
  const userMessageIds = /* @__PURE__ */ new Set();
@@ -3376,7 +3502,7 @@ async function* decodeOpencodeStream(events, ctx) {
3376
3502
  const sealed = sealHeld(held, true);
3377
3503
  held = null;
3378
3504
  if (sealed) yield sealed;
3379
- ok = true;
3505
+ ok2 = true;
3380
3506
  settled = true;
3381
3507
  break;
3382
3508
  } else if (ev.type === "session.error") {
@@ -3385,7 +3511,7 @@ async function* decodeOpencodeStream(events, ctx) {
3385
3511
  const sealed = sealHeld(held, false);
3386
3512
  held = null;
3387
3513
  if (sealed) yield sealed;
3388
- ok = false;
3514
+ ok2 = false;
3389
3515
  const errorText = readSessionError(ev.properties);
3390
3516
  const failure = classifyErrorText(errorText);
3391
3517
  reason = failure ? encodeFailureReason(failure) : `error:${errorText.slice(0, 200)}`;
@@ -3400,7 +3526,7 @@ async function* decodeOpencodeStream(events, ctx) {
3400
3526
  if (sealed) yield sealed;
3401
3527
  reason = "no_terminal";
3402
3528
  }
3403
- yield { type: "result", ok, ...reason ? { reason } : {} };
3529
+ yield { type: "result", ok: ok2, ...reason ? { reason } : {} };
3404
3530
  }
3405
3531
  function hasToolInput(input) {
3406
3532
  return !!input && typeof input === "object" && Object.keys(input).length > 0;
@@ -3458,6 +3584,7 @@ function parseOpencodeModel(model) {
3458
3584
 
3459
3585
  // packages/agent-runtime/src/opencode/run-spec.ts
3460
3586
  var CABANE_MCP_SERVER2 = "cabane";
3587
+ var TURN_CONTROL_MCP_SERVER = "cabane_companion";
3461
3588
  var ACTIVE_CONVERSATION_HEADER3 = "x-cabane-active-conversation";
3462
3589
  function buildRunSpec(req, resumeSessionId) {
3463
3590
  const { policy, config } = req;
@@ -3519,6 +3646,17 @@ function buildMcp(req) {
3519
3646
  },
3520
3647
  enabled: true
3521
3648
  };
3649
+ if (req.cabane.turnControlUrl) {
3650
+ mcp[TURN_CONTROL_MCP_SERVER] = {
3651
+ type: "remote",
3652
+ url: req.cabane.turnControlUrl,
3653
+ headers: {
3654
+ Authorization: `Bearer ${req.cabane.bearer}`,
3655
+ [ACTIVE_CONVERSATION_HEADER3]: req.cabane.activeConversationId
3656
+ },
3657
+ enabled: true
3658
+ };
3659
+ }
3522
3660
  for (const [name, raw] of Object.entries(req.extra.mcpServers)) {
3523
3661
  const server = raw;
3524
3662
  if (typeof server.url === "string") {
@@ -3651,9 +3789,9 @@ function createHttpOpencodeTransport(opts) {
3651
3789
  // The lock is released when this stream finishes draining.
3652
3790
  events: releaseAfter(parseSseStream(eventRes.body, sessionId, signal), release)
3653
3791
  };
3654
- } catch (err) {
3792
+ } catch (err2) {
3655
3793
  release();
3656
- throw err;
3794
+ throw err2;
3657
3795
  }
3658
3796
  }
3659
3797
  };
@@ -3784,7 +3922,7 @@ var opencodeAdapter = createOpencodeAdapter();
3784
3922
  // packages/agent-runtime/src/opencode/conformance.ts
3785
3923
  var ABORT_SENTINEL2 = { __abortHere: true };
3786
3924
  var NEW_SESSION_ID = "sess_new";
3787
- var BRIDGE_POLICY = {
3925
+ var COMPANION_POLICY = {
3788
3926
  hostFs: false,
3789
3927
  web: true,
3790
3928
  browser: true,
@@ -3800,7 +3938,7 @@ function makeRequest2(overrides = {}) {
3800
3938
  prompt: "hi there",
3801
3939
  content: [{ type: "text", text: "hi there" }],
3802
3940
  config: { model: "deepseek/deepseek-chat" },
3803
- policy: BRIDGE_POLICY,
3941
+ policy: COMPANION_POLICY,
3804
3942
  session: null,
3805
3943
  cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
3806
3944
  local: { cwd: DIR },
@@ -4148,13 +4286,19 @@ var CODEX_ADDENDUM = [
4148
4286
  "as the turn\u2019s reply."
4149
4287
  ].join(" ");
4150
4288
  var CODEX_ADDENDUM_CODE_MODE = [
4151
- "Your one Cabane workspace tool has a plain name \u2014 `sdk` (you act on the",
4152
- "workspace by writing a TypeScript program and calling `sdk` with it); the",
4289
+ "Your Cabane workspace tool is exposed to Codex as the qualified MCP tool",
4290
+ "`mcp__cabane__sdk`. On Codex versions that defer MCP tools, locate it in the",
4291
+ "`functions.exec` deferred-tool inventory and invoke that exact qualified tool",
4292
+ "from the exec program; do not look for or call a bare top-level `sdk` tool.",
4293
+ "If discovery or an invocation fails, report the recorded tool error; never",
4294
+ "declare the SDK absent without attempting discovery and invocation. The SDK",
4295
+ "call runs a TypeScript program against the ambient `cabane` object. The",
4153
4296
  "turn-control verbs (`ask`, `wake_me`, `summon_agent`, `skip_turn`) are",
4154
- "plain-named too. There is no `read`/`write`/`search`/`edit` tool here \u2014 those",
4297
+ "qualified `mcp__cabane_companion__\u2026` tools (and may be deferred too). There is",
4298
+ "no Cabane `read`/`write`/`search`/`edit` tool here \u2014 those",
4155
4299
  "are `cabane` SDK calls inside your program, not tools. If a tool appears in this",
4156
- "prompt with an `mcp__\u2026__` prefix, that prefix is not part of its name \u2014 call the",
4157
- "tool by its plain verb. Write your closing reply as the last thing you say in the",
4300
+ "prompt with an `mcp__\u2026__` prefix, preserve that qualified name. Write your",
4301
+ "closing reply as the last thing you say in the",
4158
4302
  "turn: you can interleave narration with tool calls, but only your final message",
4159
4303
  "is recorded as the turn\u2019s reply."
4160
4304
  ].join(" ");
@@ -4181,7 +4325,7 @@ function readItemMessage(item) {
4181
4325
  function isModelMetadataError(message) {
4182
4326
  return message.includes("Defaulting to fallback metadata");
4183
4327
  }
4184
- function readToolItem(item) {
4328
+ function readToolItem(item, eventType) {
4185
4329
  const type = str(item.type);
4186
4330
  const id = str(item.id);
4187
4331
  if (!type || !id) return null;
@@ -4225,7 +4369,12 @@ function readToolItem(item) {
4225
4369
  }
4226
4370
  if (type === "web_search") {
4227
4371
  const query = str(item.query) ?? "";
4228
- return { id, name: "web_search", input: { query }, status: toStatus(item.status) };
4372
+ return {
4373
+ id,
4374
+ name: "web_search",
4375
+ input: { query },
4376
+ status: eventType === "item.completed" ? "completed" : "in_progress"
4377
+ };
4229
4378
  }
4230
4379
  return null;
4231
4380
  }
@@ -4235,9 +4384,9 @@ function readItemType(item) {
4235
4384
  function readErrorMessage(ev) {
4236
4385
  const direct = str(ev.message);
4237
4386
  if (direct) return direct;
4238
- const err = asRecord2(ev.error);
4239
- if (err) {
4240
- const m = str(err.message);
4387
+ const err2 = asRecord2(ev.error);
4388
+ if (err2) {
4389
+ const m = str(err2.message);
4241
4390
  if (m) return m;
4242
4391
  }
4243
4392
  return "unknown";
@@ -4293,7 +4442,7 @@ async function* decodeCodexStream(events, ctx) {
4293
4442
  const startedTools = /* @__PURE__ */ new Set();
4294
4443
  const finishedTools = /* @__PURE__ */ new Set();
4295
4444
  let sessionEmitted = false;
4296
- let ok = false;
4445
+ let ok2 = false;
4297
4446
  let reason;
4298
4447
  let usage;
4299
4448
  let settled = false;
@@ -4324,7 +4473,7 @@ async function* decodeCodexStream(events, ctx) {
4324
4473
  const message = readItemMessage(item);
4325
4474
  if (isModelMetadataError(message)) {
4326
4475
  yield* flushInterim();
4327
- ok = false;
4476
+ ok2 = false;
4328
4477
  reason = `model_unavailable:${message.slice(0, 200)}`;
4329
4478
  settled = true;
4330
4479
  break;
@@ -4343,7 +4492,7 @@ async function* decodeCodexStream(events, ctx) {
4343
4492
  if (text) yield { type: "thinking", text };
4344
4493
  continue;
4345
4494
  }
4346
- const tool2 = readToolItem(item);
4495
+ const tool2 = readToolItem(item, ev.type);
4347
4496
  if (!tool2) continue;
4348
4497
  yield* flushInterim();
4349
4498
  const name = prettyToolName(tool2.name);
@@ -4379,13 +4528,13 @@ async function* decodeCodexStream(events, ctx) {
4379
4528
  held = null;
4380
4529
  if (sealed) yield sealed;
4381
4530
  usage = readUsage(ev);
4382
- ok = true;
4531
+ ok2 = true;
4383
4532
  settled = true;
4384
4533
  break;
4385
4534
  }
4386
4535
  if (ev.type === "turn.failed" || ev.type === "error") {
4387
4536
  yield* flushInterim();
4388
- ok = false;
4537
+ ok2 = false;
4389
4538
  const text = readErrorMessage(ev);
4390
4539
  const failure = classifyErrorText(text);
4391
4540
  reason = failure ? encodeFailureReason(failure) : `error:${text.slice(0, 200)}`;
@@ -4403,7 +4552,7 @@ async function* decodeCodexStream(events, ctx) {
4403
4552
  const resolvedConfig = ctx.resolvedReasoningEffort ? { reasoningEffort: ctx.resolvedReasoningEffort } : void 0;
4404
4553
  yield {
4405
4554
  type: "result",
4406
- ok,
4555
+ ok: ok2,
4407
4556
  ...reason ? { reason } : {},
4408
4557
  ...usage ? { usage } : {},
4409
4558
  ...ctx.resolvedModel ? { resolvedModel: ctx.resolvedModel } : {},
@@ -4423,12 +4572,15 @@ function sealHeld2(held, terminal) {
4423
4572
  // packages/agent-runtime/src/codex/policy.ts
4424
4573
  import { z as z12 } from "zod";
4425
4574
  function codexToolPolicy(policy) {
4426
- return {
4427
- sandboxMode: policy.hostFs ? "workspace-write" : "read-only",
4575
+ return policy.hostFs ? {
4576
+ permissionProfile: "cabane-coding",
4577
+ approvalPolicy: "never",
4578
+ networkAccessEnabled: policy.web
4579
+ } : {
4580
+ sandboxMode: "read-only",
4428
4581
  // Headless: the sandbox is the boundary; never pause for a human.
4429
4582
  approvalPolicy: "never",
4430
- // Only consulted under workspace-write; the public-web grant governs whether
4431
- // Codex's shell commands may reach the network.
4583
+ // Retained in the policy value for symmetry; read-only ignores it.
4432
4584
  networkAccessEnabled: policy.web
4433
4585
  };
4434
4586
  }
@@ -4451,6 +4603,7 @@ function parseCodexModel(model) {
4451
4603
 
4452
4604
  // packages/agent-runtime/src/codex/run-spec.ts
4453
4605
  var CABANE_MCP_SERVER3 = "cabane";
4606
+ var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
4454
4607
  var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
4455
4608
  function buildRunSpec2(req, resumeThreadId) {
4456
4609
  const { policy, config } = req;
@@ -4481,13 +4634,15 @@ function buildConfig(req) {
4481
4634
  if ("url" in server) {
4482
4635
  mcp_servers[name] = {
4483
4636
  url: server.url,
4484
- ...server.headers ? { http_headers: server.headers } : {}
4637
+ ...server.headers ? { http_headers: server.headers } : {},
4638
+ default_tools_approval_mode: "approve"
4485
4639
  };
4486
4640
  } else if ("command" in server) {
4487
4641
  mcp_servers[name] = {
4488
4642
  command: server.command,
4489
4643
  ...server.args ? { args: server.args } : {},
4490
- ...server.env ? { env: server.env } : {}
4644
+ ...server.env ? { env: server.env } : {},
4645
+ default_tools_approval_mode: "approve"
4491
4646
  };
4492
4647
  }
4493
4648
  }
@@ -4496,12 +4651,14 @@ function buildConfig(req) {
4496
4651
  if (typeof server.url === "string") {
4497
4652
  mcp_servers[name] = {
4498
4653
  url: server.url,
4499
- ...isStringRecord2(server.headers) ? { http_headers: server.headers } : {}
4654
+ ...isStringRecord2(server.headers) ? { http_headers: server.headers } : {},
4655
+ default_tools_approval_mode: "approve"
4500
4656
  };
4501
4657
  } else if (typeof server.command === "string") {
4502
4658
  mcp_servers[name] = {
4503
4659
  command: server.command,
4504
- ...Array.isArray(server.args) ? { args: server.args } : {}
4660
+ ...Array.isArray(server.args) ? { args: server.args } : {},
4661
+ default_tools_approval_mode: "approve"
4505
4662
  };
4506
4663
  }
4507
4664
  }
@@ -4513,13 +4670,46 @@ function buildConfig(req) {
4513
4670
  },
4514
4671
  default_tools_approval_mode: "approve"
4515
4672
  };
4516
- return { mcp_servers, experimental_use_rmcp_client: true };
4673
+ if (req.cabane.turnControlUrl) {
4674
+ mcp_servers[TURN_CONTROL_MCP_SERVER2] = {
4675
+ url: req.cabane.turnControlUrl,
4676
+ http_headers: {
4677
+ Authorization: `Bearer ${req.cabane.bearer}`,
4678
+ [ACTIVE_CONVERSATION_HEADER4]: req.cabane.activeConversationId
4679
+ },
4680
+ default_tools_approval_mode: "approve"
4681
+ };
4682
+ }
4683
+ const policy = codexToolPolicy(req.policy);
4684
+ return {
4685
+ mcp_servers,
4686
+ experimental_use_rmcp_client: true,
4687
+ ...policy.permissionProfile ? {
4688
+ // CT733: named permission profiles are Codex's split-filesystem path.
4689
+ // `:root = read` preserves coding-mode host reads; the one explicit
4690
+ // workspace-root write grants the checkout, and the more-specific
4691
+ // `.git` write reopens the metadata Codex protects by default. Neither
4692
+ // rule grants an adjacent directory. Do not combine this with legacy `sandbox_mode` /
4693
+ // `sandbox_workspace_write`, which would restore the `.git` carve-out.
4694
+ approval_policy: policy.approvalPolicy,
4695
+ default_permissions: policy.permissionProfile,
4696
+ permissions: {
4697
+ [policy.permissionProfile]: {
4698
+ filesystem: {
4699
+ ":root": "read",
4700
+ ":workspace_roots": { ".": "write", ".git": "write" }
4701
+ },
4702
+ network: { enabled: policy.networkAccessEnabled, mode: "full" }
4703
+ }
4704
+ }
4705
+ } : {}
4706
+ };
4517
4707
  }
4518
4708
  function isStringRecord2(v) {
4519
4709
  return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
4520
4710
  }
4521
4711
 
4522
- // node_modules/.pnpm/@openai+codex-sdk@0.144.6/node_modules/@openai/codex-sdk/dist/index.js
4712
+ // node_modules/.pnpm/@openai+codex-sdk@0.146.0/node_modules/@openai/codex-sdk/dist/index.js
4523
4713
  import { promises as fs } from "fs";
4524
4714
  import os from "os";
4525
4715
  import path from "path";
@@ -4608,6 +4798,8 @@ var Thread = class {
4608
4798
  }
4609
4799
  if (parsed.type === "thread.started") {
4610
4800
  this._id = parsed.thread_id;
4801
+ } else if (parsed.type === "turn.completed") {
4802
+ parsed.usage.cache_write_input_tokens ??= 0;
4611
4803
  }
4612
4804
  yield parsed;
4613
4805
  }
@@ -4769,7 +4961,7 @@ var CodexExec = class {
4769
4961
  signal: args.signal
4770
4962
  });
4771
4963
  let spawnError = null;
4772
- child.once("error", (err) => spawnError = err);
4964
+ child.once("error", (err2) => spawnError = err2);
4773
4965
  if (!child.stdin) {
4774
4966
  child.kill();
4775
4967
  throw new Error("Child process has no stdin");
@@ -5047,6 +5239,17 @@ var Codex = class {
5047
5239
  };
5048
5240
 
5049
5241
  // packages/agent-runtime/src/codex/transport.ts
5242
+ function buildSdkThreadOptions(spec) {
5243
+ return {
5244
+ ...spec.model ? { model: spec.model } : {},
5245
+ ...spec.policy.sandboxMode ? { sandboxMode: spec.policy.sandboxMode } : {},
5246
+ workingDirectory: spec.directory,
5247
+ skipGitRepoCheck: spec.skipGitRepoCheck,
5248
+ ...spec.policy.sandboxMode ? { approvalPolicy: spec.policy.approvalPolicy } : {},
5249
+ ...spec.policy.sandboxMode === "workspace-write" ? { networkAccessEnabled: spec.policy.networkAccessEnabled } : {},
5250
+ ...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
5251
+ };
5252
+ }
5050
5253
  function createSdkCodexTransport(opts = {}) {
5051
5254
  return {
5052
5255
  async run(spec, signal) {
@@ -5060,17 +5263,7 @@ function createSdkCodexTransport(opts = {}) {
5060
5263
  config: spec.config
5061
5264
  };
5062
5265
  const codex = new Codex(codexOptions);
5063
- const threadOptions = {
5064
- ...spec.model ? { model: spec.model } : {},
5065
- sandboxMode: spec.policy.sandboxMode,
5066
- workingDirectory: spec.directory,
5067
- skipGitRepoCheck: spec.skipGitRepoCheck,
5068
- approvalPolicy: spec.policy.approvalPolicy,
5069
- // `networkAccessEnabled` only bites under `workspace-write` (read-only
5070
- // denies command network regardless); set it there off the `web` grant.
5071
- ...spec.policy.sandboxMode === "workspace-write" ? { networkAccessEnabled: spec.policy.networkAccessEnabled } : {},
5072
- ...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
5073
- };
5266
+ const threadOptions = buildSdkThreadOptions(spec);
5074
5267
  const thread = spec.resumeThreadId ? codex.resumeThread(spec.resumeThreadId, threadOptions) : codex.startThread(threadOptions);
5075
5268
  const streamed = await thread.runStreamed(spec.input, { signal });
5076
5269
  return { events: streamed.events };
@@ -5127,7 +5320,7 @@ var codexAdapter = createCodexAdapter();
5127
5320
  // packages/agent-runtime/src/codex/conformance.ts
5128
5321
  var ABORT_SENTINEL3 = { __abortHere: true };
5129
5322
  var NEW_THREAD_ID = "th_new";
5130
- var BRIDGE_POLICY2 = {
5323
+ var COMPANION_POLICY2 = {
5131
5324
  hostFs: false,
5132
5325
  web: true,
5133
5326
  browser: true,
@@ -5148,7 +5341,7 @@ function makeRequest3(overrides = {}) {
5148
5341
  // emitted → their expected results stay unchanged; a dedicated capture fixture
5149
5342
  // sets a real model + effort.
5150
5343
  config: { model: null },
5151
- policy: BRIDGE_POLICY2,
5344
+ policy: COMPANION_POLICY2,
5152
5345
  session: null,
5153
5346
  cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
5154
5347
  local: { cwd: DIR2 },
@@ -5312,6 +5505,75 @@ var CODEX_CONFORMANCE_FIXTURES = [
5312
5505
  { type: "result", ok: true }
5313
5506
  ]
5314
5507
  },
5508
+ {
5509
+ // CT715: web_search LIFECYCLE. Unlike the other three tool kinds, a `web_search`
5510
+ // item carries NO `status` field — completion is signaled by the frame TYPE
5511
+ // (`item.started` → `item.completed`). The `start` fires off the first frame
5512
+ // (empty query, no output card); the `done` must resolve off `item.completed`
5513
+ // and carry the populated query the completed frame filled in. Before CT715 the
5514
+ // card was pinned at "running" forever (status derived from a missing field).
5515
+ name: "web_search lifecycle \u2014 completes off frame type, populated query on done",
5516
+ request: makeRequest3(),
5517
+ nativeStream: [
5518
+ threadStarted(NEW_THREAD_ID),
5519
+ toolFrame("item.started", { id: "ws1", type: "web_search", query: "" }),
5520
+ toolFrame("item.completed", { id: "ws1", type: "web_search", query: "best pizza in nyc" }),
5521
+ turnCompleted()
5522
+ ],
5523
+ expected: [
5524
+ sessionEvent3(NEW_THREAD_ID),
5525
+ {
5526
+ type: "tool",
5527
+ id: "ws1",
5528
+ name: "web_search",
5529
+ phase: "start",
5530
+ summary: "",
5531
+ input: { query: "" }
5532
+ },
5533
+ {
5534
+ type: "tool",
5535
+ id: "ws1",
5536
+ name: "web_search",
5537
+ phase: "done",
5538
+ summary: "best pizza in nyc",
5539
+ input: { query: "best pizza in nyc" }
5540
+ },
5541
+ { type: "result", ok: true }
5542
+ ]
5543
+ },
5544
+ {
5545
+ // CT715: a non-text web action (`action.type: "other"`) legitimately completes
5546
+ // with an EMPTY query — that's Codex's own data, not our bug. It must still
5547
+ // resolve to `done` (empty query acceptable; stuck-running is not).
5548
+ name: "web_search lifecycle \u2014 empty-query completion still resolves to done",
5549
+ request: makeRequest3(),
5550
+ nativeStream: [
5551
+ threadStarted(NEW_THREAD_ID),
5552
+ toolFrame("item.started", { id: "ws2", type: "web_search", query: "" }),
5553
+ toolFrame("item.completed", { id: "ws2", type: "web_search", query: "" }),
5554
+ turnCompleted()
5555
+ ],
5556
+ expected: [
5557
+ sessionEvent3(NEW_THREAD_ID),
5558
+ {
5559
+ type: "tool",
5560
+ id: "ws2",
5561
+ name: "web_search",
5562
+ phase: "start",
5563
+ summary: "",
5564
+ input: { query: "" }
5565
+ },
5566
+ {
5567
+ type: "tool",
5568
+ id: "ws2",
5569
+ name: "web_search",
5570
+ phase: "done",
5571
+ summary: "",
5572
+ input: { query: "" }
5573
+ },
5574
+ { type: "result", ok: true }
5575
+ ]
5576
+ },
5315
5577
  {
5316
5578
  // HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
5317
5579
  // adapter stops before the closing reply — no final text, no `result` event.
@@ -5606,15 +5868,35 @@ var CODEX_CONFORMANCE_FIXTURES = [
5606
5868
  // packages/agent-runtime/src/cabane-native/addendum.ts
5607
5869
  var CABANE_NATIVE_ADDENDUM = `## Your tools (native runtime)
5608
5870
 
5609
- You are running on Cabane's own agent runtime. You have a small, fixed set of workspace tools, all prefixed \`cabane_\`:
5871
+ You are running on Cabane's own agent runtime. You have a small, curated set of workspace tools, all prefixed \`cabane_\`:
5610
5872
 
5611
5873
  - \`cabane_list\` \u2014 list a folder's files and subfolders.
5612
5874
  - \`cabane_read\` \u2014 read one file's contents by path.
5613
- - \`cabane_search\` \u2014 substring search across file names and contents.
5875
+ - \`cabane_search\` \u2014 substring search across file/folder names, file contents, and conversations.
5614
5876
  - \`cabane_write\` \u2014 create or overwrite a file (pass \`overwrite: true\` to replace).
5615
5877
  - \`cabane_edit\` \u2014 find/replace inside an existing file.
5878
+ - \`cabane_mkdir\` \u2014 create a folder (pass \`recursive: true\` to also make missing parents).
5879
+ - \`cabane_move\` \u2014 move or rename a file or folder.
5880
+ - \`cabane_delete\` \u2014 delete a file or folder (folders need \`recursive: true\`; soft-delete, recoverable).
5881
+ - \`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.
5882
+ - \`cabane_messages\` \u2014 read a conversation's messages by id (from \`cabane_search\` / \`cabane_context\`).
5883
+
5884
+ 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.`;
5885
+ var CABANE_NATIVE_ADDENDUM_CODE_MODE = `## Your tools (native runtime \u2014 code mode)
5886
+
5887
+ 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.
5616
5888
 
5617
- Paths are workspace-relative with a leading slash (\`/notes/todo.md\`). This is a deliberately small interim tool set \u2014 if a job needs an operation that isn't here, say so plainly in your reply rather than inventing a tool. Your reply text is streamed straight into the conversation; there is no separate send step.`;
5889
+ 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.
5890
+
5891
+ 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):
5892
+ - \`ask\` \u2014 put a structured question to a human and END your turn (they may answer in days).
5893
+ - \`wake_me\` \u2014 end this turn now and be re-dispatched later to check a condition (a PR merging, a reply landing).
5894
+ - \`summon_agent\` \u2014 pull a peer agent into THIS conversation to reply on your turn.
5895
+ - \`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).
5896
+ - \`skip_turn\` \u2014 end your turn with NO reply, when the message doesn't need one from you.
5897
+ - \`mint_render_token\` \u2014 mint a short-lived credential to load a workspace HTML page in a browser.
5898
+
5899
+ Your reply text is streamed straight into the conversation; there is no separate send step.`;
5618
5900
 
5619
5901
  // packages/agent-runtime/src/cabane-native/context.ts
5620
5902
  var DEFAULT_HISTORY_LIMIT = 20;
@@ -5679,6 +5961,18 @@ function parseCabaneNativeModel(model) {
5679
5961
  return model.startsWith(CABANE_NATIVE_MODEL_PREFIX) ? model.slice(CABANE_NATIVE_MODEL_PREFIX.length) : model;
5680
5962
  }
5681
5963
 
5964
+ // packages/agent-runtime/src/cabane-native/policy.ts
5965
+ import { z as z13 } from "zod";
5966
+ var NATIVE_SURFACES = ["code", "classic"];
5967
+ var cabaneNativeDialectSchema = z13.object({ surface: z13.enum(NATIVE_SURFACES).optional() }).loose();
5968
+ function readCabaneNativeDialect(runtimeOptions) {
5969
+ const parsed = cabaneNativeDialectSchema.safeParse(runtimeOptions?.["cabane-native"] ?? {});
5970
+ return parsed.success ? parsed.data : {};
5971
+ }
5972
+ function cabaneNativeSurface(runtimeOptions) {
5973
+ return readCabaneNativeDialect(runtimeOptions).surface ?? "code";
5974
+ }
5975
+
5682
5976
  // packages/agent-runtime/src/cabane-native/tools.ts
5683
5977
  var TOOL_RESULT_MAX_CHARS = 8e3;
5684
5978
  var CABANE_NATIVE_TOOLS = [
@@ -5762,11 +6056,157 @@ var CABANE_NATIVE_TOOLS = [
5762
6056
  required: ["path", "find", "replace"]
5763
6057
  }
5764
6058
  }
6059
+ },
6060
+ {
6061
+ type: "function",
6062
+ function: {
6063
+ name: "cabane_mkdir",
6064
+ 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.",
6065
+ parameters: {
6066
+ type: "object",
6067
+ properties: {
6068
+ path: { type: "string", description: "Workspace folder path, e.g. /notes/archive." },
6069
+ recursive: {
6070
+ type: "boolean",
6071
+ description: "Create missing parent folders too (default false)."
6072
+ }
6073
+ },
6074
+ required: ["path"]
6075
+ }
6076
+ }
6077
+ },
6078
+ {
6079
+ type: "function",
6080
+ function: {
6081
+ name: "cabane_move",
6082
+ 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.",
6083
+ parameters: {
6084
+ type: "object",
6085
+ properties: {
6086
+ fromPath: { type: "string", description: "The current file/folder path." },
6087
+ toPath: { type: "string", description: "The new full path (parent + name)." }
6088
+ },
6089
+ required: ["fromPath", "toPath"]
6090
+ }
6091
+ }
6092
+ },
6093
+ {
6094
+ type: "function",
6095
+ function: {
6096
+ name: "cabane_delete",
6097
+ 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.",
6098
+ parameters: {
6099
+ type: "object",
6100
+ properties: {
6101
+ path: { type: "string", description: "Workspace file/folder path to delete." },
6102
+ recursive: {
6103
+ type: "boolean",
6104
+ description: "Required to delete a non-empty folder (default false)."
6105
+ }
6106
+ },
6107
+ required: ["path"]
6108
+ }
6109
+ }
6110
+ },
6111
+ {
6112
+ type: "function",
6113
+ function: {
6114
+ name: "cabane_context",
6115
+ 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`.",
6116
+ parameters: {
6117
+ type: "object",
6118
+ properties: {
6119
+ kind: {
6120
+ type: "string",
6121
+ enum: ["conversation", "file", "folder", "user", "agent", "channel"],
6122
+ description: "The kind of object to orient around."
6123
+ },
6124
+ ref: {
6125
+ type: "string",
6126
+ description: "The object id (or the workspace path for a file/folder)."
6127
+ }
6128
+ },
6129
+ required: ["kind", "ref"]
6130
+ }
6131
+ }
6132
+ },
6133
+ {
6134
+ type: "function",
6135
+ function: {
6136
+ name: "cabane_messages",
6137
+ 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.',
6138
+ parameters: {
6139
+ type: "object",
6140
+ properties: {
6141
+ conversationId: { type: "string", description: "The conversation id to read." },
6142
+ order: {
6143
+ type: "string",
6144
+ enum: ["asc", "desc"],
6145
+ description: "newest-first (desc, default) or oldest-first (asc)."
6146
+ },
6147
+ limit: {
6148
+ type: "number",
6149
+ description: "Max messages to return (default 50, max 100)."
6150
+ }
6151
+ },
6152
+ required: ["conversationId"]
6153
+ }
6154
+ }
5765
6155
  }
5766
6156
  ];
6157
+ var CABANE_NATIVE_CODE_TOOL = {
6158
+ type: "function",
6159
+ function: {
6160
+ name: "sdk",
6161
+ 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.",
6162
+ parameters: {
6163
+ type: "object",
6164
+ properties: {
6165
+ code: {
6166
+ type: "string",
6167
+ description: "The TypeScript program to run (an async function body)."
6168
+ }
6169
+ },
6170
+ required: ["code"]
6171
+ }
6172
+ }
6173
+ };
6174
+ var CABANE_NATIVE_RENDER_TOKEN_TOOL = {
6175
+ type: "function",
6176
+ function: {
6177
+ name: "mint_render_token",
6178
+ 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.",
6179
+ parameters: {
6180
+ type: "object",
6181
+ properties: {
6182
+ pathPrefix: {
6183
+ type: "string",
6184
+ description: "Workspace-rooted path-prefix the token authorizes; `/` for whole-workspace."
6185
+ },
6186
+ ttlSeconds: {
6187
+ type: "number",
6188
+ description: "Override the default 30-minute TTL; capped at 24h (86400s)."
6189
+ }
6190
+ }
6191
+ }
6192
+ }
6193
+ };
5767
6194
  function summarizeCabaneToolArgs(name, args) {
5768
- if (name === "cabane_search") return typeof args.q === "string" ? args.q : "";
5769
- return typeof args.path === "string" ? args.path : "";
6195
+ if (name === "mint_render_token") return str2(args.pathPrefix) ?? "/";
6196
+ if (name === "sdk") {
6197
+ const code = str2(args.code) ?? "";
6198
+ const firstLine = code.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
6199
+ return firstLine.length > 80 ? `${firstLine.slice(0, 80)}\u2026` : firstLine;
6200
+ }
6201
+ if (name === "cabane_search") return str2(args.q) ?? "";
6202
+ if (name === "cabane_move") {
6203
+ const from = str2(args.fromPath) ?? "";
6204
+ const to = str2(args.toPath) ?? "";
6205
+ return from && to ? `${from} \u2192 ${to}` : from || to;
6206
+ }
6207
+ if (name === "cabane_context") return str2(args.ref) ?? "";
6208
+ if (name === "cabane_messages") return str2(args.conversationId) ?? "";
6209
+ return str2(args.path) ?? "";
5770
6210
  }
5771
6211
  async function executeCabaneNativeTool(name, args, ctx, signal) {
5772
6212
  const doFetch = ctx.fetchImpl ?? fetch;
@@ -5785,10 +6225,10 @@ async function executeCabaneNativeTool(name, args, ctx, signal) {
5785
6225
  ...init2?.body !== void 0 ? { body: JSON.stringify(init2.body) } : {},
5786
6226
  signal
5787
6227
  });
5788
- } catch (err) {
6228
+ } catch (err2) {
5789
6229
  return {
5790
6230
  ok: false,
5791
- result: `error: request failed: ${err instanceof Error ? err.message : String(err)}`
6231
+ result: `error: request failed: ${err2 instanceof Error ? err2.message : String(err2)}`
5792
6232
  };
5793
6233
  }
5794
6234
  const contentType = res.headers.get("content-type") ?? "";
@@ -5797,6 +6237,20 @@ async function executeCabaneNativeTool(name, args, ctx, signal) {
5797
6237
  return { ok: true, result: truncate2(text) };
5798
6238
  };
5799
6239
  switch (name) {
6240
+ case "sdk":
6241
+ return call("POST", "/sdk", {
6242
+ body: {
6243
+ program: str2(args.code) ?? "",
6244
+ ...ctx.conversationId ? { activeConversationId: ctx.conversationId } : {}
6245
+ }
6246
+ });
6247
+ case "mint_render_token":
6248
+ return call("POST", "/render-tokens", {
6249
+ body: {
6250
+ pathPrefix: str2(args.pathPrefix) ?? "/",
6251
+ ...typeof args.ttlSeconds === "number" ? { ttlSeconds: args.ttlSeconds } : {}
6252
+ }
6253
+ });
5800
6254
  case "cabane_list":
5801
6255
  return call("GET", "/files/tree", { query: { path: str2(args.path) ?? "/" } });
5802
6256
  case "cabane_read":
@@ -5820,6 +6274,39 @@ async function executeCabaneNativeTool(name, args, ctx, signal) {
5820
6274
  ...args.replaceAll === true ? { replaceAll: true } : {}
5821
6275
  }
5822
6276
  });
6277
+ case "cabane_mkdir":
6278
+ return call("POST", "/folders", {
6279
+ body: {
6280
+ path: str2(args.path),
6281
+ ...args.recursive === true ? { recursive: true } : {}
6282
+ }
6283
+ });
6284
+ case "cabane_move":
6285
+ return call("POST", "/entries/move", {
6286
+ body: { fromPath: str2(args.fromPath), toPath: str2(args.toPath) }
6287
+ });
6288
+ case "cabane_delete":
6289
+ return call("DELETE", "/entries", {
6290
+ query: {
6291
+ path: str2(args.path),
6292
+ recursive: args.recursive === true ? "true" : void 0
6293
+ }
6294
+ });
6295
+ case "cabane_context":
6296
+ return call("POST", "/graph/query", {
6297
+ body: { preset: "orient", args: { kind: str2(args.kind), ref: str2(args.ref) } }
6298
+ });
6299
+ case "cabane_messages":
6300
+ return call(
6301
+ "GET",
6302
+ `/conversations/${encodeURIComponent(str2(args.conversationId) ?? "")}/messages`,
6303
+ {
6304
+ query: {
6305
+ order: str2(args.order),
6306
+ limit: typeof args.limit === "number" ? String(args.limit) : void 0
6307
+ }
6308
+ }
6309
+ );
5823
6310
  default:
5824
6311
  return { ok: false, result: `error: unknown tool "${name}"` };
5825
6312
  }
@@ -5832,6 +6319,238 @@ function truncate2(s) {
5832
6319
  \u2026 [truncated]` : s;
5833
6320
  }
5834
6321
 
6322
+ // packages/agent-runtime/src/cabane-native/turn-control.ts
6323
+ function describeSubAgentError(status, body) {
6324
+ const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
6325
+ switch (code) {
6326
+ case "callout_cap_exceeded":
6327
+ 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.";
6328
+ case "callout_depth_exceeded":
6329
+ return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
6330
+ case "dispatch_agent_not_found":
6331
+ return "sub_agent: no live agent in this workspace matches that `agentId`. Check `list_agents`, or omit `agentId` to spawn yourself.";
6332
+ case "dispatch_return_requires_turn":
6333
+ case "dispatch_return_requires_agent":
6334
+ case "dispatch_return_requires_dispatch":
6335
+ 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.`;
6336
+ default:
6337
+ return `sub_agent: the spawn failed (${code ?? `HTTP ${status}`}).`;
6338
+ }
6339
+ }
6340
+ var CABANE_NATIVE_TURN_CONTROL_TOOLS = [
6341
+ {
6342
+ type: "function",
6343
+ function: {
6344
+ name: "ask",
6345
+ 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.",
6346
+ parameters: {
6347
+ type: "object",
6348
+ properties: {
6349
+ targetUserId: {
6350
+ type: "string",
6351
+ description: "The workspace member (human) to ask \u2014 a user id from the roster."
6352
+ },
6353
+ question: {
6354
+ type: "string",
6355
+ description: "Single-question form: a short body of framing (one or two sentences)."
6356
+ },
6357
+ headline: {
6358
+ type: "string",
6359
+ description: "Single-question form: the question itself as one clear capitalized sentence ending in `?`."
6360
+ },
6361
+ options: {
6362
+ type: "array",
6363
+ items: { type: "string" },
6364
+ description: "Single-question form: 2\u20134 suggested one-click answers."
6365
+ },
6366
+ questions: {
6367
+ type: "array",
6368
+ items: {
6369
+ type: "object",
6370
+ properties: {
6371
+ headline: { type: "string" },
6372
+ body: { type: "string" },
6373
+ options: { type: "array", items: { type: "string" } }
6374
+ },
6375
+ required: ["headline"]
6376
+ },
6377
+ description: "Multi-question form: 1\u20135 questions to ask at once."
6378
+ }
6379
+ },
6380
+ required: ["targetUserId"]
6381
+ }
6382
+ }
6383
+ },
6384
+ {
6385
+ type: "function",
6386
+ function: {
6387
+ name: "wake_me",
6388
+ 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.',
6389
+ parameters: {
6390
+ type: "object",
6391
+ properties: {
6392
+ afterSeconds: {
6393
+ type: "number",
6394
+ description: "Relative delay in seconds from when this turn ends (floor 60)."
6395
+ },
6396
+ at: {
6397
+ type: "string",
6398
+ description: "Absolute ISO-8601 timestamp with a zone (e.g. 2026-07-16T09:00:00-07:00)."
6399
+ },
6400
+ note: {
6401
+ type: "string",
6402
+ description: "A note to your future self \u2014 becomes the wake body (the condition to re-check)."
6403
+ }
6404
+ },
6405
+ required: ["note"]
6406
+ }
6407
+ }
6408
+ },
6409
+ {
6410
+ type: "function",
6411
+ function: {
6412
+ name: "summon_agent",
6413
+ 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.",
6414
+ parameters: {
6415
+ type: "object",
6416
+ properties: {
6417
+ agentId: {
6418
+ type: "string",
6419
+ description: "The peer agent to summon \u2014 a workspace agent id from the roster."
6420
+ }
6421
+ },
6422
+ required: ["agentId"]
6423
+ }
6424
+ }
6425
+ },
6426
+ {
6427
+ type: "function",
6428
+ function: {
6429
+ name: "sub_agent",
6430
+ 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.",
6431
+ parameters: {
6432
+ type: "object",
6433
+ properties: {
6434
+ prompt: {
6435
+ type: "string",
6436
+ description: "The sub-agent's self-contained opening instruction."
6437
+ },
6438
+ agentId: {
6439
+ type: "string",
6440
+ description: "Optional peer to run the sub-agent as; omit to spawn yourself."
6441
+ },
6442
+ title: { type: "string", description: "Optional title for the child thread." }
6443
+ },
6444
+ required: ["prompt"]
6445
+ }
6446
+ }
6447
+ },
6448
+ {
6449
+ type: "function",
6450
+ function: {
6451
+ name: "skip_turn",
6452
+ 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.",
6453
+ parameters: {
6454
+ type: "object",
6455
+ properties: {
6456
+ reason: {
6457
+ type: "string",
6458
+ description: "Short reason you are declining \u2014 used for telemetry."
6459
+ }
6460
+ },
6461
+ required: ["reason"]
6462
+ }
6463
+ }
6464
+ }
6465
+ ];
6466
+ var CABANE_NATIVE_TURN_CONTROL_NAMES = new Set(
6467
+ CABANE_NATIVE_TURN_CONTROL_TOOLS.map((t) => t.function.name)
6468
+ );
6469
+ function summarizeTurnControlArgs(name, args) {
6470
+ if (name === "ask") return str3(args.headline) ?? str3(args.question) ?? "";
6471
+ if (name === "wake_me") return str3(args.note) ?? "";
6472
+ if (name === "summon_agent") return str3(args.agentId) ?? "";
6473
+ if (name === "sub_agent") return str3(args.title) ?? truncateHead(str3(args.prompt) ?? "", 60);
6474
+ if (name === "skip_turn") return str3(args.reason) ?? "";
6475
+ return "";
6476
+ }
6477
+ async function executeNativeTurnControl(name, args, tc) {
6478
+ switch (name) {
6479
+ case "summon_agent": {
6480
+ const agentId = str3(args.agentId);
6481
+ if (!agentId) return err("summon_agent: `agentId` is required.");
6482
+ tc.summon(agentId);
6483
+ return ok({ summoned: agentId });
6484
+ }
6485
+ case "skip_turn": {
6486
+ const reason = str3(args.reason);
6487
+ if (!reason) return err("skip_turn: `reason` is required.");
6488
+ tc.skip(reason);
6489
+ return ok({ skipped: true });
6490
+ }
6491
+ case "ask": {
6492
+ const targetUserId = str3(args.targetUserId);
6493
+ if (!targetUserId) return err("ask: `targetUserId` is required.");
6494
+ const hasSingle = args.question !== void 0;
6495
+ const hasArray = Array.isArray(args.questions) && args.questions.length > 0;
6496
+ if (hasSingle && hasArray)
6497
+ return err("ask: provide either `question` or `questions`, not both.");
6498
+ if (!hasSingle && !hasArray) return err("ask: provide `question` or `questions`.");
6499
+ const payload = hasArray ? { targetUserId, questions: args.questions } : {
6500
+ targetUserId,
6501
+ question: str3(args.question),
6502
+ ...str3(args.headline) ? { headline: str3(args.headline) } : {},
6503
+ ...Array.isArray(args.options) ? { options: args.options } : {}
6504
+ };
6505
+ tc.ask(payload);
6506
+ return ok({ asked: targetUserId });
6507
+ }
6508
+ case "wake_me": {
6509
+ const note = str3(args.note);
6510
+ if (!note) return err("wake_me: `note` is required.");
6511
+ const hasAfter = typeof args.afterSeconds === "number";
6512
+ const hasAt = typeof args.at === "string";
6513
+ if (hasAfter && hasAt)
6514
+ return err("wake_me: provide either `afterSeconds` or `at`, not both.");
6515
+ if (!hasAfter && !hasAt) return err("wake_me: provide `afterSeconds` or `at`.");
6516
+ tc.wake({
6517
+ ...hasAfter ? { afterSeconds: args.afterSeconds } : {},
6518
+ ...hasAt ? { at: args.at } : {},
6519
+ note
6520
+ });
6521
+ return ok({ armed: hasAt ? { at: args.at } : { afterSeconds: args.afterSeconds } });
6522
+ }
6523
+ case "sub_agent": {
6524
+ const prompt = str3(args.prompt);
6525
+ if (!prompt) return err("sub_agent: `prompt` is required.");
6526
+ const result = await tc.subAgent({
6527
+ prompt,
6528
+ ...str3(args.agentId) ? { agentId: str3(args.agentId) } : {},
6529
+ ...str3(args.title) ? { title: str3(args.title) } : {}
6530
+ });
6531
+ if (!result.ok) return err(result.error);
6532
+ return ok({
6533
+ conversationId: result.conversationId,
6534
+ note: "Spawned. Don't wait \u2014 finish what else this turn can do, then end your turn; the result posts back here."
6535
+ });
6536
+ }
6537
+ default:
6538
+ return err(`unknown turn-control tool "${name}"`);
6539
+ }
6540
+ }
6541
+ function ok(payload) {
6542
+ return { ok: true, result: JSON.stringify(payload) };
6543
+ }
6544
+ function err(message) {
6545
+ return { ok: false, result: `error: ${message}` };
6546
+ }
6547
+ function str3(v) {
6548
+ return typeof v === "string" ? v : void 0;
6549
+ }
6550
+ function truncateHead(s, n) {
6551
+ return s.length > n ? `${s.slice(0, n)}\u2026` : s;
6552
+ }
6553
+
5835
6554
  // packages/agent-runtime/src/cabane-native/loop.ts
5836
6555
  var DEFAULT_MAX_ITERATIONS = 12;
5837
6556
  async function* runCabaneNativeTurn(req, signal, deps) {
@@ -5840,10 +6559,20 @@ async function* runCabaneNativeTurn(req, signal, deps) {
5840
6559
  return;
5841
6560
  }
5842
6561
  const model = parseCabaneNativeModel(req.config.model);
6562
+ const surface = cabaneNativeSurface(req.config.runtimeOptions);
6563
+ const turnControl = req.extra.turnControl;
6564
+ const tools = surface === "code" ? [
6565
+ CABANE_NATIVE_CODE_TOOL,
6566
+ CABANE_NATIVE_RENDER_TOKEN_TOOL,
6567
+ ...turnControl ? CABANE_NATIVE_TURN_CONTROL_TOOLS : []
6568
+ ] : CABANE_NATIVE_TOOLS;
5843
6569
  const toolCtx = {
5844
6570
  apiRoot: deps.apiRoot,
5845
6571
  workspaceId: deps.workspaceId,
5846
6572
  bearer: deps.bearer,
6573
+ // CT666: the origin conversation, forwarded by the `sdk` tool to the `/sdk`
6574
+ // endpoint so a code-mode program's returning callout binds to this turn.
6575
+ ...deps.conversationId ? { conversationId: deps.conversationId } : {},
5847
6576
  ...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
5848
6577
  };
5849
6578
  const messages = await assembleMessages(req.systemPrompt, req.content, req.prompt, {
@@ -5863,10 +6592,7 @@ async function* runCabaneNativeTurn(req, signal, deps) {
5863
6592
  const toolAcc = /* @__PURE__ */ new Map();
5864
6593
  let finishReason;
5865
6594
  let errored2;
5866
- for await (const ev of deps.provider.stream(
5867
- { model, messages, tools: CABANE_NATIVE_TOOLS },
5868
- signal
5869
- )) {
6595
+ for await (const ev of deps.provider.stream({ model, messages, tools }, signal)) {
5870
6596
  if (signal.aborted) return;
5871
6597
  switch (ev.type) {
5872
6598
  case "text":
@@ -5881,7 +6607,11 @@ async function* runCabaneNativeTurn(req, signal, deps) {
5881
6607
  break;
5882
6608
  }
5883
6609
  case "usage":
5884
- usage = { inputTokens: ev.inputTokens, outputTokens: ev.outputTokens };
6610
+ usage = {
6611
+ inputTokens: ev.inputTokens,
6612
+ outputTokens: ev.outputTokens,
6613
+ contextTokens: ev.inputTokens
6614
+ };
5885
6615
  break;
5886
6616
  case "model":
5887
6617
  resolvedModel = ev.model;
@@ -5932,9 +6662,10 @@ async function* runCabaneNativeTurn(req, signal, deps) {
5932
6662
  if (signal.aborted) return;
5933
6663
  const args = parseArgs(t.args);
5934
6664
  const displayName = prettyToolName(t.name);
5935
- const summary = summarizeCabaneToolArgs(t.name, args);
6665
+ const isTurnControl = CABANE_NATIVE_TURN_CONTROL_NAMES.has(t.name);
6666
+ const summary = isTurnControl ? summarizeTurnControlArgs(t.name, args) : summarizeCabaneToolArgs(t.name, args);
5936
6667
  yield { type: "tool", id: t.id, name: displayName, phase: "start", summary, input: args };
5937
- const result = await executeCabaneNativeTool(t.name, args, toolCtx, signal);
6668
+ const result = isTurnControl && turnControl ? await executeNativeTurnControl(t.name, args, turnControl) : await executeCabaneNativeTool(t.name, args, toolCtx, signal);
5938
6669
  if (signal.aborted) return;
5939
6670
  yield {
5940
6671
  type: "tool",
@@ -5978,10 +6709,6 @@ function parseArgs(raw) {
5978
6709
  }
5979
6710
  }
5980
6711
 
5981
- // packages/agent-runtime/src/cabane-native/policy.ts
5982
- import { z as z13 } from "zod";
5983
- var cabaneNativeDialectSchema = z13.object({}).loose();
5984
-
5985
6712
  // packages/agent-runtime/src/cabane-native/provider.ts
5986
6713
  var DEFAULT_OPENROUTER_BASE = "https://openrouter.ai/api/v1";
5987
6714
  function createOpenRouterProvider(opts) {
@@ -6011,9 +6738,9 @@ function createOpenRouterProvider(opts) {
6011
6738
  }),
6012
6739
  signal
6013
6740
  });
6014
- } catch (err) {
6741
+ } catch (err2) {
6015
6742
  if (signal.aborted) return;
6016
- yield { type: "error", message: `request failed: ${errText(err)}` };
6743
+ yield { type: "error", message: `request failed: ${errText(err2)}` };
6017
6744
  return;
6018
6745
  }
6019
6746
  if (!res.ok || !res.body) {
@@ -6083,9 +6810,9 @@ function createOpenRouterProvider(opts) {
6083
6810
  }
6084
6811
  }
6085
6812
  }
6086
- } catch (err) {
6813
+ } catch (err2) {
6087
6814
  if (signal.aborted) return;
6088
- yield { type: "error", message: `stream read failed: ${errText(err)}` };
6815
+ yield { type: "error", message: `stream read failed: ${errText(err2)}` };
6089
6816
  return;
6090
6817
  }
6091
6818
  yield { type: "done", ...finishReason ? { finishReason } : {} };
@@ -6101,8 +6828,8 @@ function providerErrorMessage(status, body) {
6101
6828
  }
6102
6829
  return `HTTP ${status}: ${detail}`;
6103
6830
  }
6104
- function errText(err) {
6105
- return err instanceof Error ? err.message : String(err);
6831
+ function errText(err2) {
6832
+ return err2 instanceof Error ? err2.message : String(err2);
6106
6833
  }
6107
6834
 
6108
6835
  // packages/agent-runtime/src/cabane-native/index.ts
@@ -6115,13 +6842,15 @@ function createCabaneNativeAdapter(deps = {}) {
6115
6842
  }) : null);
6116
6843
  return {
6117
6844
  name: CABANE_NATIVE_RUNTIME_NAME,
6118
- // CT614: the native runtime carries its OWN `cabane_*` tool surface (not the
6119
- // `cabane` MCP server), so the CT609 code-mode lever which filters the MCP
6120
- // server down to `code` never touches what's mounted here. The addendum is
6121
- // therefore flag-INDEPENDENT: it always teaches the `cabane_*` names that are
6122
- // actually mounted (the `codeMode` arg is accepted-and-ignored). Native's
6123
- // adoption of `code` as its primary surface is a separate, later track.
6124
- promptAddendum: () => CABANE_NATIVE_ADDENDUM,
6845
+ // CT666: the native runtime's addendum is now SURFACE-aware. Code mode (the
6846
+ // default In-Cabane surface) mounts the single `sdk` tool, so it's taught the
6847
+ // code-mode addendum (act by writing a `cabane` SDK program; the `cabane_*`
6848
+ // names don't exist here); classic (the retained CT663 10-tool fallback) is
6849
+ // taught the granular `cabane_*` listing. The caller (turn-context) passes
6850
+ // `codeMode` off the SAME lifted surface value the loop mounts tools from, so
6851
+ // the prompt and the mounted surface can't drift. (Supersedes CT614's
6852
+ // flag-independent addendum: native no longer ignores this arg.)
6853
+ promptAddendum: (codeMode) => codeMode ? CABANE_NATIVE_ADDENDUM_CODE_MODE : CABANE_NATIVE_ADDENDUM,
6125
6854
  dialectSchema: cabaneNativeDialectSchema,
6126
6855
  async *runTurn(req, signal) {
6127
6856
  if (!provider) {
@@ -6195,8 +6924,8 @@ var ConnectorHealthStore = class {
6195
6924
  return this.byRuntime.get(runtime);
6196
6925
  }
6197
6926
  // The per-connector reports to attach to a heartbeat — one entry per runtime the
6198
- // bridge has an observation for. Empty until the first classified failure/heal,
6199
- // so a bridge that has seen nothing sends no `connectors[]` and the server's
6927
+ // companion has an observation for. Empty until the first classified failure/heal,
6928
+ // so a companion that has seen nothing sends no `connectors[]` and the server's
6200
6929
  // manifest synthesis (status-less rows) is unaffected.
6201
6930
  reports() {
6202
6931
  return [...this.byRuntime.entries()].map(([runtime, h]) => ({
@@ -6210,22 +6939,23 @@ var ConnectorHealthStore = class {
6210
6939
 
6211
6940
  // src/dispatcher.ts
6212
6941
  import { randomUUID } from "crypto";
6213
- import { existsSync as existsSync9 } from "fs";
6942
+ import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync9 } from "fs";
6943
+ import { join as join12 } from "path";
6214
6944
 
6215
6945
  // src/summon.ts
6216
6946
  import { z as z14 } from "zod";
6217
- var BRIDGE_LOCAL_MCP_SERVER = "cabane_bridge";
6947
+ var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
6218
6948
  var SUMMON_AGENT_TOOL = "summon_agent";
6219
- var SUMMON_AGENT_TOOL_NAME = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
6220
- var BRIDGE_LOCAL_TOOL_GLOB = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__*`;
6949
+ var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
6950
+ var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
6221
6951
  var SKIP_TURN_TOOL = "skip_turn";
6222
- var SKIP_TURN_TOOL_NAME = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__${SKIP_TURN_TOOL}`;
6952
+ var SKIP_TURN_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SKIP_TURN_TOOL}`;
6223
6953
  var ASK_TOOL = "ask";
6224
- var ASK_TOOL_NAME = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
6954
+ var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
6225
6955
  var SUB_AGENT_TOOL = "sub_agent";
6226
- var SUB_AGENT_TOOL_NAME = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
6956
+ var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
6227
6957
  var WAKE_ME_TOOL = "wake_me";
6228
- var WAKE_ME_TOOL_NAME = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
6958
+ var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
6229
6959
  function createSummonState() {
6230
6960
  return { agentId: null };
6231
6961
  }
@@ -6240,7 +6970,7 @@ function createWakeState() {
6240
6970
  }
6241
6971
  function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
6242
6972
  return createSdkMcpServer({
6243
- name: BRIDGE_LOCAL_MCP_SERVER,
6973
+ name: COMPANION_LOCAL_MCP_SERVER,
6244
6974
  version: "0.0.0",
6245
6975
  tools: [
6246
6976
  tool(
@@ -6442,7 +7172,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6442
7172
  function cabaneMcpUrl(baseUrl) {
6443
7173
  return `${trimSlash3(baseUrl)}/api/mcp`;
6444
7174
  }
6445
- function buildBridgeTurnRequest(params) {
7175
+ function turnControlMcpUrl(baseUrl) {
7176
+ return `${trimSlash3(baseUrl)}/api/turn-control`;
7177
+ }
7178
+ function buildCompanionTurnRequest(params) {
6446
7179
  const { turnContext: t } = params;
6447
7180
  return {
6448
7181
  systemPrompt: t.systemPrompt,
@@ -6453,23 +7186,35 @@ function buildBridgeTurnRequest(params) {
6453
7186
  session: t.session,
6454
7187
  cabane: {
6455
7188
  mcpUrl: cabaneMcpUrl(params.baseUrl),
6456
- // CT306: prefer the per-turn OBO credential; fall back to the bridge PAT
7189
+ // CT306: prefer the per-turn OBO credential; fall back to the companion PAT
6457
7190
  // when the API didn't mint one (older API / unresolvable delegation).
6458
7191
  bearer: params.turnToken ?? params.agentPat,
6459
7192
  activeConversationId: params.activeConversationId,
6460
- workspaceId: params.workspaceId
7193
+ workspaceId: params.workspaceId,
7194
+ ...t.workspaceToolSurface ? { workspaceToolSurface: t.workspaceToolSurface } : {},
7195
+ // CT714: mount the turn-control surface ONLY when a real turn token backs
7196
+ // this turn — the surface admits `turn_token` auth exclusively, so a
7197
+ // PAT-fallback bearer (older API / unresolved delegation) would be rejected
7198
+ // there. Absent it, external adapters simply don't mount it that turn (the
7199
+ // same graceful degrade as the rest of the OBO path).
7200
+ ...params.turnToken ? { turnControlUrl: turnControlMcpUrl(params.baseUrl) } : {}
6461
7201
  },
6462
7202
  local: {
6463
7203
  ...params.cwd ? { cwd: params.cwd } : {},
6464
7204
  ...params.env ? { env: params.env } : {},
7205
+ ...params.nativeWorkAssignment ? { nativeWorkAssignment: params.nativeWorkAssignment } : {},
6465
7206
  // User MCP servers (already `${PLACEHOLDER}`-resolved). Structurally the
6466
7207
  // adapter's `ResolvedMcpServers`.
6467
7208
  ...Object.keys(params.mcpServers).length > 0 ? { mcpServers: params.mcpServers } : {},
6468
7209
  // CT289: the auto-memory escape hatch, when the operator set it.
6469
7210
  ...params.claudeCode ? { claudeCode: params.claudeCode } : {}
6470
7211
  },
6471
- // Host-injected: the bridge-local summon server, under its own namespace.
6472
- extra: { mcpServers: { [BRIDGE_LOCAL_MCP_SERVER]: params.summonServer } }
7212
+ // Host-injected: the companion-local summon server (for the subprocess adapters,
7213
+ // under its own namespace) + the cabane-native turn-control handler (CT666).
7214
+ extra: {
7215
+ mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.summonServer },
7216
+ turnControl: params.turnControl
7217
+ }
6473
7218
  };
6474
7219
  }
6475
7220
  function trimSlash3(s) {
@@ -6482,18 +7227,26 @@ import { join as join9 } from "path";
6482
7227
  function dirFor(workspaceId) {
6483
7228
  return join9(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
6484
7229
  }
6485
- function pathFor3(workspaceId, conversationId) {
6486
- return join9(dirFor(workspaceId), `${encodeURIComponent(conversationId)}.json`);
7230
+ function conversationDir(workspaceId, conversationId) {
7231
+ return join9(dirFor(workspaceId), encodeURIComponent(conversationId));
6487
7232
  }
6488
- function readPrepared(workspaceId, conversationId) {
6489
- const path3 = pathFor3(workspaceId, conversationId);
7233
+ function pathFor3(workspaceId, conversationId, agentId, assignmentKey) {
7234
+ const suffix = assignmentKey ? `--${encodeURIComponent(assignmentKey)}` : "";
7235
+ return join9(
7236
+ conversationDir(workspaceId, conversationId),
7237
+ `${encodeURIComponent(agentId)}${suffix}.json`
7238
+ );
7239
+ }
7240
+ function readPrepared(workspaceId, conversationId, agentId, assignmentKey) {
7241
+ const path3 = pathFor3(workspaceId, conversationId, agentId, assignmentKey);
6490
7242
  if (!existsSync7(path3)) return null;
6491
7243
  try {
6492
7244
  const parsed = JSON.parse(readFileSync6(path3, "utf8"));
6493
7245
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
6494
7246
  return {
6495
7247
  cwd: parsed.cwd,
6496
- ...parsed.env && typeof parsed.env === "object" ? { env: parsed.env } : {}
7248
+ ...parsed.env && typeof parsed.env === "object" ? { env: parsed.env } : {},
7249
+ ...parsed.nativeWorkAssignment && typeof parsed.nativeWorkAssignment === "object" ? { nativeWorkAssignment: parsed.nativeWorkAssignment } : {}
6497
7250
  };
6498
7251
  }
6499
7252
  return null;
@@ -6501,9 +7254,13 @@ function readPrepared(workspaceId, conversationId) {
6501
7254
  return null;
6502
7255
  }
6503
7256
  }
6504
- function writePrepared(workspaceId, conversationId, result) {
6505
- mkdirSync7(dirFor(workspaceId), { recursive: true });
6506
- writeFileSync6(pathFor3(workspaceId, conversationId), JSON.stringify(result) + "\n", "utf8");
7257
+ function writePrepared(workspaceId, conversationId, agentId, result, assignmentKey) {
7258
+ mkdirSync7(conversationDir(workspaceId, conversationId), { recursive: true });
7259
+ writeFileSync6(
7260
+ pathFor3(workspaceId, conversationId, agentId, assignmentKey),
7261
+ JSON.stringify(result) + "\n",
7262
+ "utf8"
7263
+ );
6507
7264
  }
6508
7265
 
6509
7266
  // src/secrets.ts
@@ -6521,18 +7278,18 @@ function loadSecretStore() {
6521
7278
  let raw;
6522
7279
  try {
6523
7280
  raw = readFileSync7(path3, "utf8");
6524
- } catch (err) {
7281
+ } catch (err2) {
6525
7282
  throw new ConfigError(
6526
- `couldn't read ${path3}: ${err instanceof Error ? err.message : String(err)}`
7283
+ `couldn't read ${path3}: ${err2 instanceof Error ? err2.message : String(err2)}`
6527
7284
  );
6528
7285
  }
6529
7286
  if (raw.trim().length === 0) return makeStore({});
6530
7287
  let parsed;
6531
7288
  try {
6532
7289
  parsed = JSON.parse(raw);
6533
- } catch (err) {
7290
+ } catch (err2) {
6534
7291
  throw new ConfigError(
6535
- `${path3} is not valid JSON: ${err instanceof Error ? err.message : String(err)}. It must be a flat object of "NAME": "value" secret pairs.`
7292
+ `${path3} is not valid JSON: ${err2 instanceof Error ? err2.message : String(err2)}. It must be a flat object of "NAME": "value" secret pairs.`
6536
7293
  );
6537
7294
  }
6538
7295
  const result = secretStoreSchema.safeParse(parsed);
@@ -6546,8 +7303,8 @@ function loadSecretStore() {
6546
7303
  function loadSecretStoreTolerant(onWarn) {
6547
7304
  try {
6548
7305
  return loadSecretStore();
6549
- } catch (err) {
6550
- onWarn?.(err instanceof Error ? err.message : String(err));
7306
+ } catch (err2) {
7307
+ onWarn?.(err2 instanceof Error ? err2.message : String(err2));
6551
7308
  return makeStore({});
6552
7309
  }
6553
7310
  }
@@ -6618,8 +7375,8 @@ var TranscriptWriter = class {
6618
7375
  } catch {
6619
7376
  }
6620
7377
  pruneOld(dir2, RETAIN);
6621
- } catch (err) {
6622
- this.fail(err);
7378
+ } catch (err2) {
7379
+ this.fail(err2);
6623
7380
  }
6624
7381
  this.line({ type: "_meta", ...meta });
6625
7382
  }
@@ -6635,14 +7392,14 @@ var TranscriptWriter = class {
6635
7392
  if (this.broken) return;
6636
7393
  try {
6637
7394
  appendFileSync(this.path, JSON.stringify(obj) + "\n", { mode: 384 });
6638
- } catch (err) {
6639
- this.fail(err);
7395
+ } catch (err2) {
7396
+ this.fail(err2);
6640
7397
  }
6641
7398
  }
6642
- fail(err) {
7399
+ fail(err2) {
6643
7400
  if (this.broken) return;
6644
7401
  this.broken = true;
6645
- this.onWarn?.(`transcript write failed (${err instanceof Error ? err.message : String(err)})`);
7402
+ this.onWarn?.(`transcript write failed (${err2 instanceof Error ? err2.message : String(err2)})`);
6646
7403
  }
6647
7404
  };
6648
7405
  function fileName(meta) {
@@ -6680,9 +7437,9 @@ var EMPTY_FINAL_BODY = "Done \u2014 see the changes above.";
6680
7437
  var TurnCommitter = class {
6681
7438
  constructor(deps) {
6682
7439
  this.deps = deps;
6683
- this.onError = (err, hook) => {
7440
+ this.onError = (err2, hook) => {
6684
7441
  deps.log.warn(
6685
- { err: err instanceof Error ? err.message : String(err), hook },
7442
+ { err: err2 instanceof Error ? err2.message : String(err2), hook },
6686
7443
  "dispatcher: transcript callback failed"
6687
7444
  );
6688
7445
  };
@@ -6747,9 +7504,9 @@ var TurnCommitter = class {
6747
7504
  signal: deps.signal,
6748
7505
  nextSeq: deps.nextSeq,
6749
7506
  emptyFinalBody: EMPTY_FINAL_BODY,
6750
- onError: (err) => {
7507
+ onError: (err2) => {
6751
7508
  deps.log.warn(
6752
- { err: err instanceof Error ? err.message : String(err) },
7509
+ { err: err2 instanceof Error ? err2.message : String(err2) },
6753
7510
  "dispatcher: empty-final commit failed"
6754
7511
  );
6755
7512
  }
@@ -6772,8 +7529,8 @@ var TurnCommitter = class {
6772
7529
  if (event.type === "session" || event.type === "result") return;
6773
7530
  try {
6774
7531
  await this.emit(event);
6775
- } catch (err) {
6776
- this.onError(err, event.type);
7532
+ } catch (err2) {
7533
+ this.onError(err2, event.type);
6777
7534
  }
6778
7535
  }
6779
7536
  // End-of-turn empty-final promotion. The held-text flush is now the adapter's
@@ -6844,13 +7601,128 @@ var TurnCommitter = class {
6844
7601
  }
6845
7602
  };
6846
7603
 
7604
+ // src/workspace-readiness.ts
7605
+ var CLASSIC_REQUIRED = ["read", "list", "search", "write", "edit"];
7606
+ async function proveWorkspaceTools(req, runtime, opts = {}) {
7607
+ const base = {
7608
+ ok: false,
7609
+ proofType: "authenticated_mcp_tools_list",
7610
+ runtime,
7611
+ harnessFingerprint: opts.harnessFingerprint ?? runtime,
7612
+ endpoint: safeEndpoint(req.cabane.mcpUrl),
7613
+ initialized: false,
7614
+ authenticated: false,
7615
+ discoveredTools: [],
7616
+ requiredTools: [],
7617
+ acceptedNames: ["sdk", "mcp__cabane__sdk"],
7618
+ failedCapability: null,
7619
+ detail: null
7620
+ };
7621
+ if (!req.cabane.mcpUrl) return fail(base, "server_not_configured", "Cabane MCP URL absent");
7622
+ if (!req.cabane.bearer) return fail(base, "authentication_failed", "Cabane bearer absent");
7623
+ const fetchImpl = opts.fetchImpl ?? fetch;
7624
+ const headers = {
7625
+ authorization: `Bearer ${req.cabane.bearer}`,
7626
+ accept: "application/json, text/event-stream",
7627
+ "content-type": "application/json",
7628
+ "x-cabane-active-conversation": req.cabane.activeConversationId
7629
+ };
7630
+ try {
7631
+ const initialized = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
7632
+ jsonrpc: "2.0",
7633
+ id: 1,
7634
+ method: "initialize",
7635
+ params: {
7636
+ protocolVersion: "2025-03-26",
7637
+ capabilities: {},
7638
+ clientInfo: { name: "cabane-companion-readiness", version: "1" }
7639
+ }
7640
+ });
7641
+ if (initialized.status === 401 || initialized.status === 403)
7642
+ return fail(base, "authentication_failed", `initialize returned HTTP ${initialized.status}`);
7643
+ if (!initialized.ok) return fail(base, "initialization_failed", initialized.detail);
7644
+ base.initialized = true;
7645
+ base.authenticated = true;
7646
+ if (initialized.sessionId) headers["mcp-session-id"] = initialized.sessionId;
7647
+ const listed = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
7648
+ jsonrpc: "2.0",
7649
+ id: 2,
7650
+ method: "tools/list",
7651
+ params: {}
7652
+ });
7653
+ if (listed.status === 401 || listed.status === 403)
7654
+ return fail(base, "authentication_failed", `tools/list returned HTTP ${listed.status}`);
7655
+ if (!listed.ok) return fail(base, "tool_discovery_failed", listed.detail);
7656
+ const result = asRecord3(asRecord3(listed.value)?.result);
7657
+ const tools = Array.isArray(result?.tools) ? result.tools : null;
7658
+ if (!tools) return fail(base, "tool_discovery_failed", "tools/list returned no tool inventory");
7659
+ base.discoveredTools = tools.map(
7660
+ (tool2) => tool2 && typeof tool2 === "object" && typeof tool2.name === "string" ? tool2.name : null
7661
+ ).filter((name) => name !== null).sort();
7662
+ if (!req.cabane.workspaceToolSurface)
7663
+ return fail(base, "required_tool_missing", "resolved workspace tool surface absent");
7664
+ base.requiredTools = req.cabane.workspaceToolSurface === "code" ? ["sdk"] : CLASSIC_REQUIRED;
7665
+ const missing = base.requiredTools.filter((name) => !base.discoveredTools.includes(name));
7666
+ if (missing.length > 0)
7667
+ return fail(
7668
+ base,
7669
+ "required_tool_missing",
7670
+ `missing initialized tools: ${missing.join(", ")}`
7671
+ );
7672
+ base.ok = true;
7673
+ return base;
7674
+ } catch (error) {
7675
+ return fail(
7676
+ base,
7677
+ "initialization_failed",
7678
+ error instanceof Error ? error.message : String(error)
7679
+ );
7680
+ }
7681
+ }
7682
+ function fail(proof, capability, detail) {
7683
+ proof.failedCapability = capability;
7684
+ proof.detail = detail.slice(0, 300);
7685
+ return proof;
7686
+ }
7687
+ function safeEndpoint(value) {
7688
+ try {
7689
+ const url = new URL(value);
7690
+ return `${url.origin}${url.pathname}`;
7691
+ } catch {
7692
+ return null;
7693
+ }
7694
+ }
7695
+ async function rpc(fetchImpl, url, headers, body) {
7696
+ const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
7697
+ const text = await response.text();
7698
+ const value = parseRpcBody(text);
7699
+ return {
7700
+ ok: response.ok && !!value && !value.error,
7701
+ status: response.status,
7702
+ sessionId: response.headers.get("mcp-session-id"),
7703
+ value,
7704
+ detail: typeof asRecord3(value?.error)?.message === "string" ? String(asRecord3(value?.error)?.message) : `HTTP ${response.status}`
7705
+ };
7706
+ }
7707
+ function parseRpcBody(text) {
7708
+ const trimmed = text.trim();
7709
+ if (trimmed.startsWith("{")) return JSON.parse(trimmed);
7710
+ for (const line of trimmed.split("\n")) {
7711
+ if (line.startsWith("data:")) return JSON.parse(line.slice(5).trim());
7712
+ }
7713
+ return null;
7714
+ }
7715
+ function asRecord3(value) {
7716
+ return value !== null && typeof value === "object" ? value : null;
7717
+ }
7718
+
6847
7719
  // src/dispatcher.ts
6848
7720
  var PREPARING_TOOL_NAME = "preparing";
6849
7721
  var PREPARE_FAILED_PREFIX = "**Couldn't prepare your environment.** I wasn't able to provision a working directory for this conversation, so I can't run this turn. The provisioning command reported:";
6850
- var MISSING_SECRET_PREFIX = "**Missing secret on this 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:";
7722
+ var MISSING_SECRET_PREFIX = "**Missing secret on this companion.** This agent's tools need a credential this device hasn't been given, so I can't run this turn safely. Declare it in this companion\u2019s secret store (`~/.cabane/secrets.json`) and try again. Missing:";
6851
7723
  var STOPPED_MARKER_BODY = "(stopped)";
6852
- 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:";
6853
- 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`;
7724
+ 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:";
7725
+ var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
6854
7726
  var SKIPPED_MARKER_BODY = "(skipped)";
6855
7727
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
6856
7728
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
@@ -6858,23 +7730,6 @@ var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 45 * 6e4;
6858
7730
  function runKey(conversationId, agentId) {
6859
7731
  return `${conversationId}|${agentId}`;
6860
7732
  }
6861
- function describeSubAgentError(status, body) {
6862
- const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
6863
- switch (code) {
6864
- case "callout_cap_exceeded":
6865
- 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.";
6866
- case "callout_depth_exceeded":
6867
- return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
6868
- case "dispatch_agent_not_found":
6869
- return "sub_agent: no live agent in this workspace matches that `agentId`. Check `list_agents`, or omit `agentId` to spawn yourself.";
6870
- case "dispatch_return_requires_turn":
6871
- case "dispatch_return_requires_agent":
6872
- case "dispatch_return_requires_dispatch":
6873
- 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.`;
6874
- default:
6875
- return `sub_agent: the spawn failed (${code ?? `HTTP ${status}`}).`;
6876
- }
6877
- }
6878
7733
  var Dispatcher = class {
6879
7734
  constructor(opts) {
6880
7735
  this.opts = opts;
@@ -6895,9 +7750,9 @@ var Dispatcher = class {
6895
7750
  }
6896
7751
  }
6897
7752
  // CT138: shared pre-run teardown for every early-return that happens BEFORE
6898
- // the active-run flag is flipped (the `setBridgeActiveRun` working-flip below).
7753
+ // the active-run flag is flipped (the `setActiveRun` working-flip below).
6899
7754
  // The server lights the "X is replying…" indicator eagerly at dispatch
6900
- // (chat-dispatch.ts `scheduleRun`), and from that point only the bridge can
7755
+ // (chat-dispatch.ts `scheduleRun`), and from that point only the companion can
6901
7756
  // clear it — the SJ383 `finally` after the SDK loop is the one clear, and
6902
7757
  // every pre-run exit returns before reaching it. So each pre-run failure has
6903
7758
  // to clear `active_run_started_at` itself, mirroring that `finally`, or the
@@ -6915,15 +7770,15 @@ var Dispatcher = class {
6915
7770
  const body = { activeRunStartedAt: null };
6916
7771
  if (errorReason) body.errorReason = errorReason.slice(0, 200);
6917
7772
  try {
6918
- await this.opts.api.setBridgeActiveRun(
7773
+ await this.opts.api.setActiveRun(
6919
7774
  this.opts.workspaceId,
6920
7775
  payload.conversationId,
6921
7776
  payload.agentId,
6922
7777
  body
6923
7778
  );
6924
- } catch (err) {
7779
+ } catch (err2) {
6925
7780
  turnLog.warn(
6926
- { err: err instanceof Error ? err.message : String(err) },
7781
+ { err: err2 instanceof Error ? err2.message : String(err2) },
6927
7782
  "dispatcher: pre-run active-run clear failed terminally; server age-sweep is the backstop"
6928
7783
  );
6929
7784
  }
@@ -6941,19 +7796,24 @@ var Dispatcher = class {
6941
7796
  agentId: payload.agentId,
6942
7797
  messageId: payload.messageId
6943
7798
  });
7799
+ const turnId = randomUUID();
6944
7800
  let turnContext;
6945
7801
  try {
6946
- turnContext = await this.opts.api.getTurnContext(payload.conversationId, payload.messageId);
6947
- } catch (err) {
6948
- const status = err instanceof ApiError ? err.status : 0;
7802
+ turnContext = await this.opts.api.getTurnContext(
7803
+ payload.conversationId,
7804
+ payload.messageId,
7805
+ turnId
7806
+ );
7807
+ } catch (err2) {
7808
+ const status = err2 instanceof ApiError ? err2.status : 0;
6949
7809
  if (status === 404) {
6950
7810
  turnLog.warn(
6951
- { err: err instanceof Error ? err.message : String(err) },
7811
+ { err: err2 instanceof Error ? err2.message : String(err2) },
6952
7812
  "dispatcher: turn-context 404 (stale event \u2014 conversation/message/participant gone); skipping"
6953
7813
  );
6954
7814
  return this.concludeBeforeRun(payload, turnLog, startedAt, "turn_context_not_found");
6955
7815
  }
6956
- const reason = err instanceof Error ? err.message : String(err);
7816
+ const reason = err2 instanceof Error ? err2.message : String(err2);
6957
7817
  turnLog.error({ err: reason }, "dispatcher: failed to fetch turn context");
6958
7818
  const fetchReason = `fetch_failed: ${reason}`;
6959
7819
  return this.concludeBeforeRun(payload, turnLog, startedAt, fetchReason, fetchReason);
@@ -6984,7 +7844,7 @@ var Dispatcher = class {
6984
7844
  );
6985
7845
  if (missing.length > 0) {
6986
7846
  const list = missing.map((n) => `\`${n}\``).join(", ");
6987
- turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this bridge");
7847
+ turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this companion");
6988
7848
  try {
6989
7849
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
6990
7850
  body: `${MISSING_SECRET_PREFIX} ${list}`,
@@ -7005,7 +7865,6 @@ var Dispatcher = class {
7005
7865
  const localCwd = this.opts.local.cwd;
7006
7866
  const prepareHook = this.opts.local.prepareHook;
7007
7867
  const cabaneCwd = turnContext.cwd;
7008
- const turnId = randomUUID();
7009
7868
  let seqCounter = 0;
7010
7869
  const nextSeq = () => ++seqCounter;
7011
7870
  let effectiveCwd = localCwd ?? cabaneCwd;
@@ -7017,11 +7876,20 @@ var Dispatcher = class {
7017
7876
  effectiveCwd = void 0;
7018
7877
  }
7019
7878
  let hookEnv;
7879
+ let preparedNativeAssignment;
7020
7880
  if (prepareHook) {
7021
- const cached2 = readPrepared(workspaceId, payload.conversationId);
7881
+ const assignment = turnContext.conversation.nativeWorkAssignment;
7882
+ const assignmentKey = assignment ? `${assignment.executionId}:${assignment.activationEpoch}` : void 0;
7883
+ const cached2 = readPrepared(
7884
+ workspaceId,
7885
+ payload.conversationId,
7886
+ payload.agentId,
7887
+ assignmentKey
7888
+ );
7022
7889
  if (cached2) {
7023
7890
  effectiveCwd = cached2.cwd;
7024
7891
  hookEnv = cached2.env;
7892
+ preparedNativeAssignment = cached2.nativeWorkAssignment;
7025
7893
  } else {
7026
7894
  const delayMs = this.opts.preparingRowDelayMs ?? DEFAULT_PREPARING_ROW_DELAY_MS;
7027
7895
  let preparingStarted = false;
@@ -7036,9 +7904,9 @@ var Dispatcher = class {
7036
7904
  summary: "",
7037
7905
  phase,
7038
7906
  seq
7039
- }).catch((err) => {
7907
+ }).catch((err2) => {
7040
7908
  turnLog.warn(
7041
- { err: err instanceof Error ? err.message : String(err), phase },
7909
+ { err: err2 instanceof Error ? err2.message : String(err2), phase },
7042
7910
  "dispatcher: preparing-activity report failed (continuing with the hook)"
7043
7911
  );
7044
7912
  });
@@ -7055,21 +7923,30 @@ var Dispatcher = class {
7055
7923
  conversationId: payload.conversationId,
7056
7924
  agentId: payload.agentId,
7057
7925
  agentUsername: this.opts.agentUsername,
7926
+ runtime: turnContext.runtime,
7058
7927
  // CT317/CT319: the trigger message's referenced-entry paths — what the
7059
7928
  // tasker prepare hook keys its per-task env off. Defaults to `[]` for
7060
7929
  // an older API. The conversation anchor is gone (CT319).
7061
7930
  triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
7931
+ ...turnContext.conversation.nativeWorkAssignment ? { nativeWorkAssignment: turnContext.conversation.nativeWorkAssignment } : {},
7062
7932
  title: turnContext.conversation.title
7063
7933
  });
7064
7934
  clearTimeout(preparingTimer);
7065
7935
  if (preparingStarted) reportPreparing("done");
7066
- writePrepared(workspaceId, payload.conversationId, result);
7936
+ writePrepared(
7937
+ workspaceId,
7938
+ payload.conversationId,
7939
+ payload.agentId,
7940
+ result,
7941
+ assignmentKey
7942
+ );
7067
7943
  effectiveCwd = result.cwd;
7068
7944
  hookEnv = result.env;
7069
- } catch (err) {
7945
+ preparedNativeAssignment = result.nativeWorkAssignment;
7946
+ } catch (err2) {
7070
7947
  clearTimeout(preparingTimer);
7071
7948
  if (preparingStarted) reportPreparing("error");
7072
- const reason = err instanceof Error ? err.message : String(err);
7949
+ const reason = err2 instanceof Error ? err2.message : String(err2);
7073
7950
  turnLog.error({ err: reason }, "dispatcher: prepare hook failed");
7074
7951
  try {
7075
7952
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
@@ -7097,7 +7974,7 @@ ${reason}`,
7097
7974
  this.aborts.set(key, abortController);
7098
7975
  let timeoutReason = null;
7099
7976
  try {
7100
- await this.opts.api.setBridgeActiveRun(workspaceId, payload.conversationId, payload.agentId, {
7977
+ await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
7101
7978
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
7102
7979
  // CT33: hand the server this turn's id so the new-run chokepoint's
7103
7980
  // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
@@ -7106,9 +7983,9 @@ ${reason}`,
7106
7983
  // this live turn for an abandoned one and close it with a `stopped`.
7107
7984
  turnId
7108
7985
  });
7109
- } catch (err) {
7986
+ } catch (err2) {
7110
7987
  turnLog.warn(
7111
- { err: err instanceof Error ? err.message : String(err) },
7988
+ { err: err2 instanceof Error ? err2.message : String(err2) },
7112
7989
  "dispatcher: active-run flag set failed terminally; proceeding"
7113
7990
  );
7114
7991
  }
@@ -7145,18 +8022,49 @@ ${reason}`,
7145
8022
  subAgentCreate,
7146
8023
  wakeState
7147
8024
  );
7148
- const request = buildBridgeTurnRequest({
8025
+ const nativeTurnControl = {
8026
+ summon: (agentId) => {
8027
+ summonState.agentId = agentId;
8028
+ },
8029
+ skip: (reason) => {
8030
+ skipState.skipped = true;
8031
+ skipState.reason = reason;
8032
+ },
8033
+ ask: (p) => {
8034
+ askState.targetUserId = p.targetUserId;
8035
+ if (p.questions && p.questions.length > 0) {
8036
+ askState.questions = p.questions;
8037
+ askState.question = null;
8038
+ askState.headline = null;
8039
+ askState.options = null;
8040
+ } else {
8041
+ askState.question = p.question ?? null;
8042
+ askState.headline = p.headline ?? null;
8043
+ askState.options = p.options ?? null;
8044
+ askState.questions = null;
8045
+ }
8046
+ },
8047
+ wake: (p) => {
8048
+ wakeState.afterSeconds = p.afterSeconds ?? null;
8049
+ wakeState.at = p.at ?? null;
8050
+ wakeState.note = p.note;
8051
+ },
8052
+ subAgent: subAgentCreate
8053
+ };
8054
+ const request = buildCompanionTurnRequest({
7149
8055
  turnContext,
7150
8056
  baseUrl: this.opts.baseUrl,
7151
8057
  agentPat: this.opts.credential,
7152
8058
  // CT306: the per-turn OBO credential when the API minted one; falls back to
7153
- // the bridge PAT (`agentPat`) inside `buildBridgeTurnRequest` otherwise.
8059
+ // the companion PAT (`agentPat`) inside `buildCompanionTurnRequest` otherwise.
7154
8060
  ...turnContext.turnToken ? { turnToken: turnContext.turnToken } : {},
7155
8061
  // SJ524: the hook-resolved cwd overrides the static local cwd.
7156
8062
  ...effectiveCwd ? { cwd: effectiveCwd } : {},
7157
8063
  ...hookEnv ? { env: hookEnv } : {},
8064
+ ...preparedNativeAssignment ? { nativeWorkAssignment: preparedNativeAssignment } : {},
7158
8065
  mcpServers: resolvedMcpServers,
7159
8066
  summonServer,
8067
+ turnControl: nativeTurnControl,
7160
8068
  // CT238: this turn's conversation, forwarded as the active-conversation
7161
8069
  // header so a cross-thread post/spawn stamps its origin.
7162
8070
  activeConversationId: payload.conversationId,
@@ -7181,15 +8089,15 @@ ${reason}`,
7181
8089
  let adapter;
7182
8090
  try {
7183
8091
  adapter = selectAdapter(registry, turnContext.runtime);
7184
- } catch (err) {
7185
- if (!(err instanceof RuntimeUnavailableError)) throw err;
8092
+ } catch (err2) {
8093
+ if (!(err2 instanceof RuntimeUnavailableError)) throw err2;
7186
8094
  turnLog.error(
7187
- { runtime: err.runtime, available: err.available },
8095
+ { runtime: err2.runtime, available: err2.available },
7188
8096
  "dispatcher: turn runtime not available on this device"
7189
8097
  );
7190
8098
  try {
7191
8099
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7192
- body: `${RUNTIME_UNAVAILABLE_PREFIX} ${err.message}`,
8100
+ body: `${RUNTIME_UNAVAILABLE_PREFIX} ${err2.message}`,
7193
8101
  kind: "final",
7194
8102
  turnId,
7195
8103
  parentMessageId: payload.messageId
@@ -7204,8 +8112,60 @@ ${reason}`,
7204
8112
  payload,
7205
8113
  turnLog,
7206
8114
  startedAt,
7207
- `runtime_unavailable:${err.runtime}`
8115
+ `runtime_unavailable:${err2.runtime}`
8116
+ );
8117
+ }
8118
+ if (prepareHook && hookEnv?.CABANE_TASK_ID) {
8119
+ const proof = await proveWorkspaceTools(request, adapter.name, {
8120
+ ...this.opts.workspaceProofFetch ? { fetchImpl: this.opts.workspaceProofFetch } : {},
8121
+ harnessFingerprint: turnContext.runtime
8122
+ });
8123
+ turnLog[proof.ok ? "info" : "error"](
8124
+ { workspaceProof: proof, checkout: effectiveCwd ?? null },
8125
+ `dispatcher: workspace tool proof ${proof.ok ? "passed" : "failed"}`
7208
8126
  );
8127
+ if (effectiveCwd) {
8128
+ try {
8129
+ const diagnosticDir = join12(effectiveCwd, ".git", "cabane");
8130
+ mkdirSync9(diagnosticDir, { recursive: true });
8131
+ appendFileSync2(
8132
+ join12(diagnosticDir, "readiness.jsonl"),
8133
+ `${JSON.stringify({
8134
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
8135
+ taskId: hookEnv.CABANE_TASK_ID,
8136
+ binding: hookEnv.CABANE_TASK_BINDING ?? null,
8137
+ checkout: effectiveCwd,
8138
+ classification: proof.ok ? "ready" : "workspace_tools_missing",
8139
+ failedCapability: proof.failedCapability,
8140
+ workspaceTools: proof
8141
+ })}
8142
+ `,
8143
+ { mode: 384 }
8144
+ );
8145
+ } catch (error) {
8146
+ turnLog.warn(
8147
+ { err: error instanceof Error ? error.message : String(error) },
8148
+ "dispatcher: workspace-proof diagnostic write failed"
8149
+ );
8150
+ }
8151
+ }
8152
+ if (!proof.ok) {
8153
+ const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd ?? "unknown"}; runtime=${adapter.name}; recovery=restart the connector after restoring the Cabane workspace tool mount`;
8154
+ try {
8155
+ await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8156
+ body: `**Couldn't prepare your environment.** ${reason}`,
8157
+ kind: "final",
8158
+ turnId,
8159
+ parentMessageId: payload.messageId
8160
+ });
8161
+ } catch (postErr) {
8162
+ turnLog.warn(
8163
+ { err: postErr instanceof Error ? postErr.message : String(postErr) },
8164
+ "dispatcher: workspace-proof failure notice post failed"
8165
+ );
8166
+ }
8167
+ return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
8168
+ }
7209
8169
  }
7210
8170
  const transcript = this.opts.transcriptDir ? new TranscriptWriter(
7211
8171
  this.opts.transcriptDir,
@@ -7250,6 +8210,49 @@ ${reason}`,
7250
8210
  // server arms the wake schedule atomically with the reply it rode on.
7251
8211
  wakeState
7252
8212
  });
8213
+ const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
8214
+ let turnControlIntentFetched = false;
8215
+ const applyRecordedTurnControlIntent = async () => {
8216
+ if (turnControlIntentFetched || !usesHttpTurnControl || !turnContext.turnToken) return;
8217
+ turnControlIntentFetched = true;
8218
+ try {
8219
+ const intent = await this.opts.api.getTurnIntent(
8220
+ workspaceId,
8221
+ payload.conversationId,
8222
+ payload.agentId,
8223
+ turnId
8224
+ );
8225
+ if (intent.ask) {
8226
+ askState.targetUserId = intent.ask.targetUserId;
8227
+ if (intent.ask.questions && intent.ask.questions.length > 0) {
8228
+ askState.questions = intent.ask.questions;
8229
+ askState.question = null;
8230
+ askState.headline = null;
8231
+ askState.options = null;
8232
+ } else {
8233
+ askState.question = intent.ask.question ?? null;
8234
+ askState.headline = intent.ask.headline ?? null;
8235
+ askState.options = intent.ask.options ?? null;
8236
+ askState.questions = null;
8237
+ }
8238
+ }
8239
+ if (intent.wake) {
8240
+ wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
8241
+ wakeState.at = intent.wake.at ?? null;
8242
+ wakeState.note = intent.wake.note;
8243
+ }
8244
+ if (intent.summonAgentId) summonState.agentId = intent.summonAgentId;
8245
+ if (intent.skipped) {
8246
+ skipState.skipped = true;
8247
+ skipState.reason = intent.skipReason;
8248
+ }
8249
+ } catch (err2) {
8250
+ turnLog.warn(
8251
+ { err: err2 instanceof Error ? err2.message : String(err2) },
8252
+ "dispatcher: turn-control intent fetch failed; turn-control effects for this turn are dropped"
8253
+ );
8254
+ }
8255
+ };
7253
8256
  const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
7254
8257
  const totalTimeoutMs = this.opts.totalTimeoutMs ?? DEFAULT_AGENT_TOTAL_TIMEOUT_MS;
7255
8258
  const fireTimeout = (reason) => {
@@ -7285,15 +8288,15 @@ ${reason}`,
7285
8288
  if (!sessionWritten) {
7286
8289
  sessionWritten = true;
7287
8290
  try {
7288
- await this.opts.api.setBridgeActiveRun(
8291
+ await this.opts.api.setActiveRun(
7289
8292
  workspaceId,
7290
8293
  payload.conversationId,
7291
8294
  payload.agentId,
7292
8295
  { agentSessionId: event.state }
7293
8296
  );
7294
- } catch (err) {
8297
+ } catch (err2) {
7295
8298
  turnLog.warn(
7296
- { err: err instanceof Error ? err.message : String(err) },
8299
+ { err: err2 instanceof Error ? err2.message : String(err2) },
7297
8300
  "dispatcher: session-id write failed (will retry next turn)"
7298
8301
  );
7299
8302
  }
@@ -7306,6 +8309,9 @@ ${reason}`,
7306
8309
  turnResolvedConfig = event.resolvedConfig;
7307
8310
  } else if (event.type === "text" && skipState.skipped) {
7308
8311
  } else {
8312
+ if (event.type === "text" && event.terminal) {
8313
+ await applyRecordedTurnControlIntent();
8314
+ }
7309
8315
  await committer.ingestEvent(event);
7310
8316
  }
7311
8317
  }
@@ -7316,31 +8322,41 @@ ${reason}`,
7316
8322
  if (!okResult && !resultReason) {
7317
8323
  resultReason = "no_result";
7318
8324
  }
8325
+ if (!abortController.signal.aborted) {
8326
+ await applyRecordedTurnControlIntent();
8327
+ }
7319
8328
  if (!abortController.signal.aborted && skipState.skipped) {
7320
8329
  turnLog.info(
7321
8330
  { reason: skipState.reason, turnId, ok: okResult },
7322
8331
  "agent skipped turn (skip_turn)"
7323
8332
  );
8333
+ const { afterSeconds: wakeAfter, at: wakeAt, note: wakeNote } = wakeState;
8334
+ const skipWake = wakeNote && (wakeAfter !== null || wakeAt !== null) ? {
8335
+ ...wakeAfter !== null ? { afterSeconds: wakeAfter } : {},
8336
+ ...wakeAt !== null ? { at: wakeAt } : {},
8337
+ note: wakeNote
8338
+ } : void 0;
7324
8339
  try {
7325
8340
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7326
8341
  body: SKIPPED_MARKER_BODY,
7327
8342
  kind: "skipped",
7328
8343
  turnId,
7329
8344
  seq: nextSeq(),
7330
- parentMessageId: payload.messageId
8345
+ parentMessageId: payload.messageId,
8346
+ ...skipWake ? { wake: skipWake } : {}
7331
8347
  });
7332
- } catch (err) {
8348
+ } catch (err2) {
7333
8349
  turnLog.warn(
7334
- { err: err instanceof Error ? err.message : String(err) },
8350
+ { err: err2 instanceof Error ? err2.message : String(err2) },
7335
8351
  "dispatcher: skipped-marker commit failed"
7336
8352
  );
7337
8353
  }
7338
8354
  } else {
7339
8355
  await committer.finalize(okResult);
7340
8356
  }
7341
- } catch (err) {
8357
+ } catch (err2) {
7342
8358
  okResult = false;
7343
- resultReason = err instanceof Error ? err.message : String(err);
8359
+ resultReason = err2 instanceof Error ? err2.message : String(err2);
7344
8360
  turnLog.error({ err: resultReason }, "dispatcher: SDK query threw");
7345
8361
  } finally {
7346
8362
  if (idleTimer) clearTimeout(idleTimer);
@@ -7376,9 +8392,9 @@ ${reason}`,
7376
8392
  // CT113: the stopped marker is still "about" the triggering message.
7377
8393
  parentMessageId: payload.messageId
7378
8394
  });
7379
- } catch (err) {
8395
+ } catch (err2) {
7380
8396
  turnLog.warn(
7381
- { err: err instanceof Error ? err.message : String(err) },
8397
+ { err: err2 instanceof Error ? err2.message : String(err2) },
7382
8398
  "dispatcher: stopped-marker commit failed"
7383
8399
  );
7384
8400
  }
@@ -7413,15 +8429,15 @@ ${reason}`,
7413
8429
  errorReason: body.errorReason ?? null
7414
8430
  });
7415
8431
  try {
7416
- await this.opts.api.setBridgeActiveRun(
8432
+ await this.opts.api.setActiveRun(
7417
8433
  workspaceId,
7418
8434
  payload.conversationId,
7419
8435
  payload.agentId,
7420
8436
  body
7421
8437
  );
7422
- } catch (err) {
8438
+ } catch (err2) {
7423
8439
  turnLog.warn(
7424
- { err: err instanceof Error ? err.message : String(err) },
8440
+ { err: err2 instanceof Error ? err2.message : String(err2) },
7425
8441
  "dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
7426
8442
  );
7427
8443
  }
@@ -7465,7 +8481,7 @@ ${reason}`,
7465
8481
  };
7466
8482
  }
7467
8483
  // SJ383: cancel a specific (conversation, agent) run if one is in flight in
7468
- // THIS bridge process. Returns true if an in-flight run was aborted.
8484
+ // THIS companion process. Returns true if an in-flight run was aborted.
7469
8485
  cancel(conversationId, agentId) {
7470
8486
  const key = runKey(conversationId, agentId);
7471
8487
  const ac = this.aborts.get(key);
@@ -7479,27 +8495,18 @@ ${reason}`,
7479
8495
  };
7480
8496
 
7481
8497
  // src/manifest.ts
7482
- var BRIDGE_MANIFEST = {
8498
+ var DEVICE_MANIFEST = {
7483
8499
  runtimes: [{ name: "claude-code", version: null }],
7484
8500
  capabilities: { hostFs: true, browser: true, userMcp: true }
7485
8501
  };
7486
- var HOUSE_MANIFEST = {
7487
- runtimes: [{ name: "claude-code", version: null }],
7488
- capabilities: { hostFs: false, browser: false, userMcp: false }
7489
- };
7490
- function buildBridgeManifest(opts) {
7491
- if (process.env.CABANE_BRIDGE_CLASS === "house") {
7492
- const runtimes2 = [{ name: "claude-code", version: null }];
7493
- if (opts.cabaneNative) runtimes2.push({ name: "cabane-native", version: null });
7494
- return { runtimes: runtimes2, capabilities: { ...HOUSE_MANIFEST.capabilities } };
7495
- }
8502
+ function buildCompanionManifest(opts) {
7496
8503
  const v = opts.versions ?? {};
7497
8504
  const runtimes = [];
7498
8505
  if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
7499
8506
  if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
7500
8507
  if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
7501
8508
  if (opts.cabaneNative) runtimes.push({ name: "cabane-native", version: null });
7502
- return { runtimes, capabilities: { ...BRIDGE_MANIFEST.capabilities } };
8509
+ return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
7503
8510
  }
7504
8511
 
7505
8512
  // src/harness-status.ts
@@ -7510,7 +8517,7 @@ var LABELS = {
7510
8517
  };
7511
8518
  function deriveHarnessSnapshot(signals) {
7512
8519
  const advertised = new Set(
7513
- buildBridgeManifest({
8520
+ buildCompanionManifest({
7514
8521
  claudeCode: signals.claudeOnPath,
7515
8522
  opencode: signals.opencodeConfigured,
7516
8523
  codex: signals.codexEnabled
@@ -7700,14 +8707,14 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
7700
8707
  // src/outbox.ts
7701
8708
  import {
7702
8709
  existsSync as existsSync10,
7703
- mkdirSync as mkdirSync9,
8710
+ mkdirSync as mkdirSync10,
7704
8711
  readdirSync as readdirSync2,
7705
8712
  readFileSync as readFileSync8,
7706
8713
  renameSync as renameSync3,
7707
8714
  rmSync as rmSync5,
7708
8715
  writeFileSync as writeFileSync7
7709
8716
  } from "fs";
7710
- import { join as join12 } from "path";
8717
+ import { join as join13 } from "path";
7711
8718
  var MAX_ENTRIES = 2e3;
7712
8719
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
7713
8720
  var Outbox = class {
@@ -7720,30 +8727,30 @@ var Outbox = class {
7720
8727
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
7721
8728
  // cases route writes at the right tmpdir.
7722
8729
  dir() {
7723
- return join12(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
8730
+ return join13(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7724
8731
  }
7725
8732
  fileFor(turnId, seq) {
7726
- return join12(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
8733
+ return join13(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
7727
8734
  }
7728
8735
  // Persist a commit for later draining. Atomic (temp file + rename) so a
7729
8736
  // concurrent `list()` never reads a half-written entry, then enforces the
7730
8737
  // per-workspace bounds.
7731
8738
  persist(entry) {
7732
8739
  const dir2 = this.dir();
7733
- mkdirSync9(dir2, { recursive: true });
8740
+ mkdirSync10(dir2, { recursive: true });
7734
8741
  const target = this.fileFor(entry.turnId, entry.seq);
7735
8742
  const tmp = `${target}.${process.pid}.tmp`;
7736
8743
  try {
7737
8744
  writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
7738
8745
  renameSync3(tmp, target);
7739
- } catch (err) {
8746
+ } catch (err2) {
7740
8747
  try {
7741
8748
  rmSync5(tmp, { force: true });
7742
8749
  } catch {
7743
8750
  }
7744
8751
  this.log?.warn(
7745
- { workspaceId: this.workspaceId, err: err instanceof Error ? err.message : String(err) },
7746
- "bridge outbox: failed to persist entry"
8752
+ { workspaceId: this.workspaceId, err: err2 instanceof Error ? err2.message : String(err2) },
8753
+ "companion outbox: failed to persist entry"
7747
8754
  );
7748
8755
  return;
7749
8756
  }
@@ -7765,7 +8772,7 @@ var Outbox = class {
7765
8772
  const entries = [];
7766
8773
  for (const name of names) {
7767
8774
  if (!name.endsWith(".json")) continue;
7768
- const full = join12(dir2, name);
8775
+ const full = join13(dir2, name);
7769
8776
  try {
7770
8777
  const parsed = JSON.parse(readFileSync8(full, "utf8"));
7771
8778
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -7801,7 +8808,7 @@ var Outbox = class {
7801
8808
  dropCorrupt(full) {
7802
8809
  this.log?.warn(
7803
8810
  { workspaceId: this.workspaceId, file: full },
7804
- "bridge outbox: dropping unreadable entry"
8811
+ "companion outbox: dropping unreadable entry"
7805
8812
  );
7806
8813
  try {
7807
8814
  rmSync5(full, { force: true });
@@ -7818,7 +8825,7 @@ var Outbox = class {
7818
8825
  if (now - e.enqueuedAt > MAX_AGE_MS) {
7819
8826
  this.log?.warn(
7820
8827
  { workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
7821
- "bridge outbox: evicting entry past max age (undeliverable)"
8828
+ "companion outbox: evicting entry past max age (undeliverable)"
7822
8829
  );
7823
8830
  this.remove(e.turnId, e.seq);
7824
8831
  } else {
@@ -7830,7 +8837,7 @@ var Outbox = class {
7830
8837
  for (const e of survivors.slice(0, overflow)) {
7831
8838
  this.log?.warn(
7832
8839
  { workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
7833
- "bridge outbox: evicting oldest entry past max size"
8840
+ "companion outbox: evicting oldest entry past max size"
7834
8841
  );
7835
8842
  this.remove(e.turnId, e.seq);
7836
8843
  }
@@ -7917,20 +8924,20 @@ var SseSubscriber = class {
7917
8924
  try {
7918
8925
  await this.connect();
7919
8926
  backoff = 500;
7920
- } catch (err) {
8927
+ } catch (err2) {
7921
8928
  if (this.aborted) return;
7922
- if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
8929
+ if (err2 instanceof ApiError && (err2.status === 401 || err2.status === 403)) {
7923
8930
  this.opts.log.error(
7924
- { workspaceId: this.opts.workspaceId, status: err.status },
8931
+ { workspaceId: this.opts.workspaceId, status: err2.status },
7925
8932
  "SSE auth failed \u2014 tearing down this workspace subscriber"
7926
8933
  );
7927
- this.opts.onAuthFailure(err.status);
8934
+ this.opts.onAuthFailure(err2.status);
7928
8935
  return;
7929
8936
  }
7930
8937
  this.opts.log.warn(
7931
8938
  {
7932
8939
  workspaceId: this.opts.workspaceId,
7933
- err: err instanceof Error ? err.message : String(err),
8940
+ err: err2 instanceof Error ? err2.message : String(err2),
7934
8941
  backoff
7935
8942
  },
7936
8943
  "SSE disconnected; reconnecting"
@@ -7993,7 +9000,7 @@ function sleep2(ms) {
7993
9000
  // src/version.ts
7994
9001
  import { createRequire as createRequire2 } from "module";
7995
9002
  var pkg = createRequire2(import.meta.url)("../package.json");
7996
- var BRIDGE_VERSION = pkg.version;
9003
+ var COMPANION_VERSION = pkg.version;
7997
9004
 
7998
9005
  // src/supervisor.ts
7999
9006
  var HEARTBEAT_INTERVAL_MS = 3e4;
@@ -8007,7 +9014,7 @@ var ASSIGNMENTS_POLL_MS = 6e4;
8007
9014
  var DRAIN_BASE_MS = 1e3;
8008
9015
  var DRAIN_MAX_MS = 3e4;
8009
9016
  var DRAIN_IDLE_MS = 15e3;
8010
- var BridgeSupervisor = class {
9017
+ var CompanionSupervisor = class {
8011
9018
  workspaces = /* @__PURE__ */ new Map();
8012
9019
  config;
8013
9020
  log;
@@ -8031,10 +9038,12 @@ var BridgeSupervisor = class {
8031
9038
  dispatcherFactory;
8032
9039
  deviceApi = null;
8033
9040
  heartbeatTimer = null;
9041
+ inFlightHeartbeat = null;
8034
9042
  pollTimer = null;
8035
9043
  refreshing = false;
8036
9044
  stopped = false;
8037
- // CT484: latch so the bridge/server version-skew warning is logged once, not
9045
+ draining = false;
9046
+ // CT484: latch so the companion/server version-skew warning is logged once, not
8038
9047
  // on every 30s heartbeat.
8039
9048
  versionSkewWarned = false;
8040
9049
  // This device's id, captured from the heartbeat / assignments response. The SSE
@@ -8060,18 +9069,18 @@ var BridgeSupervisor = class {
8060
9069
  this.dispatcherFactory = opts.dispatcherFactory;
8061
9070
  }
8062
9071
  // Stand up the data plane: pair check, initial assignments pull, then the
8063
- // heartbeat + poll loops. A bridge with no device token (logged out) does
9072
+ // heartbeat + poll loops. A companion with no device token (logged out) does
8064
9073
  // nothing but say so.
8065
9074
  async start() {
8066
9075
  this.log.info(
8067
- { protocolVersion: TURN_PROTOCOL_VERSION, version: BRIDGE_VERSION },
8068
- "bridge: starting"
9076
+ { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
9077
+ "companion: starting"
8069
9078
  );
8070
9079
  void this.refreshHarnessStatuses();
8071
9080
  if (!this.config.deviceToken) {
8072
- this.log.warn("bridge: not paired (no device token) \u2014 run `cabane-companion pair`");
9081
+ this.log.warn("companion: not paired (no device token) \u2014 run `cabane-companion pair`");
8073
9082
  process.stdout.write(
8074
- "bridge: this device is not paired \u2014 run `cabane-companion pair` and paste the string from the cabane app.\n"
9083
+ "companion: this device is not paired \u2014 run `cabane-companion pair` and paste the string from the cabane app.\n"
8075
9084
  );
8076
9085
  return;
8077
9086
  }
@@ -8080,8 +9089,8 @@ var BridgeSupervisor = class {
8080
9089
  deviceToken: this.config.deviceToken
8081
9090
  });
8082
9091
  await this.refreshAssignments();
8083
- void this.sendHeartbeat();
8084
- this.heartbeatTimer = setInterval(() => void this.sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
9092
+ this.kickHeartbeat();
9093
+ this.heartbeatTimer = setInterval(() => this.kickHeartbeat(), HEARTBEAT_INTERVAL_MS);
8085
9094
  this.heartbeatTimer.unref?.();
8086
9095
  this.pollTimer = setInterval(() => void this.refreshAssignments(), ASSIGNMENTS_POLL_MS);
8087
9096
  this.pollTimer.unref?.();
@@ -8094,20 +9103,28 @@ var BridgeSupervisor = class {
8094
9103
  return [...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : []);
8095
9104
  }
8096
9105
  // ---- device-level loops ----
9106
+ kickHeartbeat() {
9107
+ if (this.draining || this.inFlightHeartbeat) return;
9108
+ const pending = this.sendHeartbeat();
9109
+ this.inFlightHeartbeat = pending;
9110
+ void pending.finally(() => {
9111
+ if (this.inFlightHeartbeat === pending) this.inFlightHeartbeat = null;
9112
+ });
9113
+ }
8097
9114
  async sendHeartbeat() {
8098
9115
  if (!this.deviceApi) return;
8099
9116
  await this.refreshHarnessStatuses();
8100
9117
  try {
8101
- const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "bridge: secrets"));
9118
+ const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "companion: secrets"));
8102
9119
  const connectorReports = this.connectorHealth.reports();
8103
9120
  const opencodeModels = this.config.opencode?.serverUrl ? await enumerateOpencodeModels(this.config.opencode.serverUrl) : null;
8104
9121
  const res = await this.deviceApi.heartbeat({
8105
- version: BRIDGE_VERSION,
9122
+ version: COMPANION_VERSION,
8106
9123
  exposedSecretNames: store.names(),
8107
9124
  // Report each runtime only when this device can actually run it: CT309
8108
9125
  // claude-code when `claude` is on PATH, CT270 opencode when the operator
8109
9126
  // configured an `opencode serve`.
8110
- manifest: buildBridgeManifest({
9127
+ manifest: buildCompanionManifest({
8111
9128
  // CT586: prefer the live re-probe's presence; fall back to the boot probe
8112
9129
  // until the first re-probe lands. Same exit-0 `claude --version` signal
8113
9130
  // either way, so the manifest's claude-code advertising is unchanged in
@@ -8118,17 +9135,16 @@ var BridgeSupervisor = class {
8118
9135
  // like opencode — the CLI's presence is the operator's responsibility;
8119
9136
  // a misconfigured device fails the turn loudly, never silently).
8120
9137
  codex: isCodexEnabled(this.config),
8121
- // CT598: advertise the native runtime when an OpenRouter key is set — the
8122
- // env flag that mounts "In Cabane" native turns on this device (house or
8123
- // user). Key absent → not advertised, so a native turn never routes here.
9138
+ // CT598: advertise the native runtime when an OpenRouter key is set. Key
9139
+ // absent not advertised, so a native turn never routes here.
8124
9140
  cabaneNative: isCabaneNativeEnabled(),
8125
9141
  // CT571/CT586: each runtime's `version` from the latest harness probe
8126
9142
  // (fail-soft to null). Informational only — the server matches on name.
8127
9143
  versions: this.harnessVersions
8128
9144
  }),
8129
9145
  // CT566: echo the last classified credential state per runtime, when the
8130
- // bridge has seen any. Omitted (undefined) until the first observed
8131
- // failure/heal, so a fresh bridge's beat is unchanged and the server's
9146
+ // companion has seen any. Omitted (undefined) until the first observed
9147
+ // failure/heal, so a fresh companion's beat is unchanged and the server's
8132
9148
  // manifest synthesis (status-less rows) still runs.
8133
9149
  ...connectorReports.length > 0 ? { connectors: connectorReports } : {},
8134
9150
  // CT584: include enumerated models only when the probe SUCCEEDED (non-null).
@@ -8139,25 +9155,25 @@ var BridgeSupervisor = class {
8139
9155
  this.hub.setDevice({ deviceId: res.deviceId });
8140
9156
  this.deviceId = res.deviceId;
8141
9157
  this.checkVersionSkew(res.serverVersion);
8142
- } catch (err) {
9158
+ } catch (err2) {
8143
9159
  this.log.warn(
8144
- { err: err instanceof Error ? err.message : String(err) },
8145
- "bridge: device heartbeat failed (will retry on next tick)"
9160
+ { err: err2 instanceof Error ? err2.message : String(err2) },
9161
+ "companion: device heartbeat failed (will retry on next tick)"
8146
9162
  );
8147
9163
  }
8148
9164
  }
8149
- // CT484: belt-and-suspenders beside shipping the bridge in lockstep inside the
9165
+ // CT484: belt-and-suspenders beside shipping the companion in lockstep inside the
8150
9166
  // artifact — if the server reports a build version that differs from this
8151
- // bridge's, warn loudly (once). This is exactly the skew that bit the M1 walk:
8152
- // an npm-pinned bridge lagging a from-develop server. The elimination (bundled
8153
- // bridge) makes it match by construction; this catches a bridge run out of band.
9167
+ // companion's, warn loudly (once). This is exactly the skew that bit the M1 walk:
9168
+ // an npm-pinned companion lagging a from-develop server. The elimination (bundled
9169
+ // companion) makes it match by construction; this catches a companion run out of band.
8154
9170
  checkVersionSkew(serverVersion) {
8155
9171
  if (this.versionSkewWarned) return;
8156
- if (!serverVersion || serverVersion === BRIDGE_VERSION) return;
9172
+ if (!serverVersion || serverVersion === COMPANION_VERSION) return;
8157
9173
  this.versionSkewWarned = true;
8158
9174
  this.log.warn(
8159
- { bridgeVersion: BRIDGE_VERSION, serverVersion },
8160
- "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."
9175
+ { companionVersion: COMPANION_VERSION, serverVersion },
9176
+ "companion: VERSION SKEW \u2014 this companion and its server were built from different versions. Turns may misbehave. The self-host artifact ships a matching companion; run `cabane update` so the companion matches its server."
8161
9177
  );
8162
9178
  }
8163
9179
  // Pull assignments and reconcile the live runner set against them. Re-entrancy
@@ -8177,12 +9193,12 @@ var BridgeSupervisor = class {
8177
9193
  const resp = await this.deviceApi.getAssignments();
8178
9194
  items = resp.assignments;
8179
9195
  device = resp.device;
8180
- } catch (err) {
9196
+ } catch (err2) {
8181
9197
  this.log.error(
8182
- { err: err instanceof Error ? err.message : String(err) },
8183
- "bridge: assignments pull failed \u2014 check the device is still active in the cabane app"
9198
+ { err: err2 instanceof Error ? err2.message : String(err2) },
9199
+ "companion: assignments pull failed \u2014 check the device is still active in the cabane app"
8184
9200
  );
8185
- this.hub.setDeviceError(err instanceof Error ? err.message : String(err));
9201
+ this.hub.setDeviceError(err2 instanceof Error ? err2.message : String(err2));
8186
9202
  return;
8187
9203
  }
8188
9204
  this.hub.setDevice({ deviceId: device.id, deviceLabel: device.label });
@@ -8245,14 +9261,14 @@ var BridgeSupervisor = class {
8245
9261
  const credential = it.credential ?? getCredential(it.agentId);
8246
9262
  const runConfig = parseRunConfig(
8247
9263
  it.runConfig,
8248
- (m) => this.log.warn({ agentId: it.agentId, msg: m }, "bridge: run-config")
9264
+ (m) => this.log.warn({ agentId: it.agentId, msg: m }, "companion: run-config")
8249
9265
  );
8250
9266
  const required = requiredSecretNames(runConfig.mcpServers);
8251
9267
  const missing = required.filter((n) => !exposed.has(n));
8252
9268
  if (!credential) {
8253
9269
  this.log.error(
8254
9270
  { workspaceId, agentId: it.agentId, username: it.agentUsername },
8255
- "bridge: agent assigned but no credential on this device \u2014 re-assign it in the cabane app"
9271
+ "companion: agent assigned but no credential on this device \u2014 re-assign it in the cabane app"
8256
9272
  );
8257
9273
  this.removeAgent(wr, it.agentId);
8258
9274
  this.hub.setAgent(workspaceId, {
@@ -8286,7 +9302,7 @@ var BridgeSupervisor = class {
8286
9302
  if (missing.length > 0) {
8287
9303
  this.log.warn(
8288
9304
  { workspaceId, agentId: it.agentId, missing },
8289
- "bridge: agent needs secrets this device does not expose (turns using them will fail)"
9305
+ "companion: agent needs secrets this device does not expose (turns using them will fail)"
8290
9306
  );
8291
9307
  }
8292
9308
  }
@@ -8325,7 +9341,7 @@ var BridgeSupervisor = class {
8325
9341
  drain2.kick();
8326
9342
  this.log.info(
8327
9343
  { workspaceId: it.workspaceId, agentId: it.agentId, username: it.agentUsername },
8328
- "bridge: running agent"
9344
+ "companion: running agent"
8329
9345
  );
8330
9346
  }
8331
9347
  removeAgent(wr, agentId) {
@@ -8334,7 +9350,10 @@ var BridgeSupervisor = class {
8334
9350
  runner.cancelDrain();
8335
9351
  wr.agents.delete(agentId);
8336
9352
  this.hub.removeAgent(wr.workspaceId, agentId);
8337
- this.log.info({ workspaceId: wr.workspaceId, agentId }, "bridge: stopped agent (unassigned)");
9353
+ this.log.info(
9354
+ { workspaceId: wr.workspaceId, agentId },
9355
+ "companion: stopped agent (unassigned)"
9356
+ );
8338
9357
  }
8339
9358
  buildDispatcher(ctx) {
8340
9359
  if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
@@ -8363,7 +9382,7 @@ var BridgeSupervisor = class {
8363
9382
  // CT598: register the cabane-native adapter when an OpenRouter key is set;
8364
9383
  // unset leaves a `cabane-native/…` turn to fail loudly (no silent fallback).
8365
9384
  ...cabaneNativeApiKey() ? { cabaneNativeApiKey: cabaneNativeApiKey() } : {},
8366
- // CT556: per-turn timeout watchdog windows, from the bridge's own env
9385
+ // CT556: per-turn timeout watchdog windows, from the companion's own env
8367
9386
  // (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
8368
9387
  // dispatcher's baked-in defaults (10 min idle / 45 min total).
8369
9388
  ...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
@@ -8402,14 +9421,14 @@ var BridgeSupervisor = class {
8402
9421
  this.hub.setAuthFailed(wr.workspaceId);
8403
9422
  this.log.error(
8404
9423
  { workspaceId: wr.workspaceId, status, sseAgentId: wr.sseAgentId },
8405
- "bridge: workspace stream auth failed \u2014 re-pulling assignments"
9424
+ "companion: workspace stream auth failed \u2014 re-pulling assignments"
8406
9425
  );
8407
9426
  wr.sseAgentId = null;
8408
9427
  void this.refreshAssignments();
8409
9428
  }
8410
9429
  });
8411
9430
  wr.sub.start();
8412
- this.log.info({ workspaceId: wr.workspaceId }, "bridge: subscribed");
9431
+ this.log.info({ workspaceId: wr.workspaceId }, "companion: subscribed");
8413
9432
  }
8414
9433
  async removeWorkspace(workspaceId) {
8415
9434
  const wr = this.workspaces.get(workspaceId);
@@ -8432,17 +9451,17 @@ var BridgeSupervisor = class {
8432
9451
  let wire;
8433
9452
  try {
8434
9453
  wire = JSON.parse(ev.data);
8435
- } catch (err) {
9454
+ } catch (err2) {
8436
9455
  this.log.warn(
8437
- { err: err instanceof Error ? err.message : String(err) },
9456
+ { err: err2 instanceof Error ? err2.message : String(err2) },
8438
9457
  "malformed SSE payload"
8439
9458
  );
8440
9459
  return;
8441
9460
  }
8442
9461
  if (ev.id) wr.cursor.observe(ev.id);
8443
- if (wire.type === "bridge:cancel_requested") {
9462
+ if (wire.type === "device:cancel_requested") {
8444
9463
  const payload2 = {
8445
- type: "bridge:cancel_requested",
9464
+ type: "device:cancel_requested",
8446
9465
  ...wire.payload
8447
9466
  };
8448
9467
  const agent2 = wr.agents.get(payload2.agentId);
@@ -8462,12 +9481,13 @@ var BridgeSupervisor = class {
8462
9481
  if (ev.id) wr.cursor.settle(ev.id);
8463
9482
  return;
8464
9483
  }
8465
- if (wire.type !== "bridge:dispatch_requested") {
9484
+ if (wire.type !== "device:dispatch_requested") {
8466
9485
  if (ev.id) wr.cursor.settle(ev.id);
8467
9486
  return;
8468
9487
  }
9488
+ if (this.draining) return;
8469
9489
  const payload = {
8470
- type: "bridge:dispatch_requested",
9490
+ type: "device:dispatch_requested",
8471
9491
  ...wire.payload
8472
9492
  };
8473
9493
  let agent = wr.agents.get(payload.agentId);
@@ -8480,15 +9500,15 @@ var BridgeSupervisor = class {
8480
9500
  }
8481
9501
  const chainKey = `${payload.conversationId}|${payload.agentId}`;
8482
9502
  const prev = wr.chains.get(chainKey) ?? Promise.resolve();
8483
- const tail = prev.then(() => this.runDispatch(wr, ev, payload, agent)).catch((err) => {
9503
+ const tail = prev.then(() => this.runDispatch(wr, ev, payload, agent)).catch((err2) => {
8484
9504
  this.log.warn(
8485
9505
  {
8486
9506
  workspaceId: wr.workspaceId,
8487
9507
  conversationId: payload.conversationId,
8488
9508
  agentId: payload.agentId,
8489
- err: err instanceof Error ? err.message : String(err)
9509
+ err: err2 instanceof Error ? err2.message : String(err2)
8490
9510
  },
8491
- "bridge: conversation turn handler threw"
9511
+ "companion: conversation turn handler threw"
8492
9512
  );
8493
9513
  });
8494
9514
  wr.chains.set(chainKey, tail);
@@ -8516,7 +9536,7 @@ var BridgeSupervisor = class {
8516
9536
  if (hasCompleted(workspaceId, ev.id)) {
8517
9537
  this.log.info(
8518
9538
  { workspaceId, eventId: ev.id },
8519
- "bridge: skipping already-completed event (resume after restart)"
9539
+ "companion: skipping already-completed event (resume after restart)"
8520
9540
  );
8521
9541
  wr.cursor.settle(ev.id);
8522
9542
  return;
@@ -8524,7 +9544,7 @@ var BridgeSupervisor = class {
8524
9544
  if (noResume()) {
8525
9545
  this.log.warn(
8526
9546
  { workspaceId, eventId: ev.id },
8527
- "bridge: skipping interrupted turn (CABANE_BRIDGE_NO_RESUME=1) \u2014 resume disabled for this boot"
9547
+ "companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
8528
9548
  );
8529
9549
  markCompleted(workspaceId, ev.id);
8530
9550
  wr.cursor.settle(ev.id);
@@ -8534,7 +9554,7 @@ var BridgeSupervisor = class {
8534
9554
  if (attempt > MAX_RESUME_ATTEMPTS) {
8535
9555
  this.log.error(
8536
9556
  { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
8537
- "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)"
9557
+ "companion: giving up on an interrupted turn after too many resume attempts \u2014 retiring it so boot is never wedged (run `cabane companion reset` to clear resume state)"
8538
9558
  );
8539
9559
  markCompleted(workspaceId, ev.id);
8540
9560
  wr.cursor.settle(ev.id);
@@ -8542,7 +9562,7 @@ var BridgeSupervisor = class {
8542
9562
  }
8543
9563
  this.log.info(
8544
9564
  { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
8545
- "bridge: re-dispatching interrupted turn (resume after restart)"
9565
+ "companion: re-dispatching interrupted turn (resume after restart)"
8546
9566
  );
8547
9567
  }
8548
9568
  if (ev.id) markDispatched(workspaceId, ev.id);
@@ -8578,11 +9598,11 @@ var BridgeSupervisor = class {
8578
9598
  } else {
8579
9599
  drainDelay = DRAIN_BASE_MS;
8580
9600
  }
8581
- } catch (err) {
9601
+ } catch (err2) {
8582
9602
  drainDelay = Math.min(drainDelay * 2, DRAIN_MAX_MS);
8583
9603
  this.log.warn(
8584
- { agentId, err: err instanceof Error ? err.message : String(err) },
8585
- "bridge: outbox drain pass threw (will retry with backoff)"
9604
+ { agentId, err: err2 instanceof Error ? err2.message : String(err2) },
9605
+ "companion: outbox drain pass threw (will retry with backoff)"
8586
9606
  );
8587
9607
  } finally {
8588
9608
  if (!drainStopped) {
@@ -8648,10 +9668,10 @@ var BridgeSupervisor = class {
8648
9668
  codex: signals.codexVersion
8649
9669
  };
8650
9670
  this.hub.setHarnesses(deriveHarnessSnapshot(signals).harnesses);
8651
- } catch (err) {
9671
+ } catch (err2) {
8652
9672
  this.log.warn(
8653
- { err: err instanceof Error ? err.message : String(err) },
8654
- "bridge: harness probe failed (will retry on next beat)"
9673
+ { err: err2 instanceof Error ? err2.message : String(err2) },
9674
+ "companion: harness probe failed (will retry on next beat)"
8655
9675
  );
8656
9676
  }
8657
9677
  }
@@ -8676,7 +9696,7 @@ var BridgeSupervisor = class {
8676
9696
  next = { ...this.config, codex: { enabled: true } };
8677
9697
  } else {
8678
9698
  const serverUrl = input.serverUrl.trim();
8679
- const parsed = bridgeConfigSchema.shape.opencode.safeParse({ serverUrl });
9699
+ const parsed = companionConfigSchema.shape.opencode.safeParse({ serverUrl });
8680
9700
  if (!parsed.success) {
8681
9701
  return {
8682
9702
  ok: false,
@@ -8697,7 +9717,7 @@ var BridgeSupervisor = class {
8697
9717
  saveConfig(next);
8698
9718
  this.rebuildDispatchers();
8699
9719
  await this.refreshHarnessStatuses();
8700
- void this.sendHeartbeat();
9720
+ this.kickHeartbeat();
8701
9721
  return { ok: true };
8702
9722
  }
8703
9723
  // Re-create every running agent's Dispatcher from the CURRENT config, keeping
@@ -8733,6 +9753,38 @@ var BridgeSupervisor = class {
8733
9753
  [...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
8734
9754
  );
8735
9755
  }
9756
+ async drainForRestart(graceMs) {
9757
+ this.draining = true;
9758
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
9759
+ if (this.pollTimer) clearInterval(this.pollTimer);
9760
+ if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
9761
+ if (!this.deviceApi) throw new Error("cannot establish deploy drain before pairing");
9762
+ await this.deviceApi.beginDrain();
9763
+ for (const wr of this.workspaces.values()) wr.sub?.stop();
9764
+ const turns = [...this.workspaces.values()].flatMap((wr) => [...wr.chains.values()]);
9765
+ let timedOut = false;
9766
+ if (turns.length > 0) {
9767
+ let timer;
9768
+ await Promise.race([
9769
+ Promise.allSettled(turns),
9770
+ new Promise((resolve) => {
9771
+ timer = setTimeout(() => {
9772
+ timedOut = true;
9773
+ resolve();
9774
+ }, Math.max(0, graceMs));
9775
+ timer.unref?.();
9776
+ })
9777
+ ]);
9778
+ if (timer) clearTimeout(timer);
9779
+ }
9780
+ await Promise.allSettled(
9781
+ [...this.workspaces.values()].flatMap(
9782
+ (wr) => [...wr.agents.values()].map((agent) => agent.api.drainOutbox())
9783
+ )
9784
+ );
9785
+ await this.shutdown();
9786
+ return { drained: !timedOut };
9787
+ }
8736
9788
  async requestStop() {
8737
9789
  await this.shutdown();
8738
9790
  this.exitFn(0);
@@ -8773,17 +9825,17 @@ var RECOVERABLE_CODES = /* @__PURE__ */ new Set([
8773
9825
  "ERR_STREAM_DESTROYED",
8774
9826
  "ERR_STREAM_WRITE_AFTER_END"
8775
9827
  ]);
8776
- function errorCode(err) {
8777
- if (err && typeof err === "object" && "code" in err) {
8778
- const code = err.code;
9828
+ function errorCode(err2) {
9829
+ if (err2 && typeof err2 === "object" && "code" in err2) {
9830
+ const code = err2.code;
8779
9831
  if (typeof code === "string") return code;
8780
9832
  }
8781
9833
  return void 0;
8782
9834
  }
8783
- function isRecoverableSocketError(err) {
8784
- const code = errorCode(err);
9835
+ function isRecoverableSocketError(err2) {
9836
+ const code = errorCode(err2);
8785
9837
  if (code && RECOVERABLE_CODES.has(code)) return true;
8786
- const message = err instanceof Error ? err.message : String(err);
9838
+ const message = err2 instanceof Error ? err2.message : String(err2);
8787
9839
  return /\bEPIPE\b|\bECONNRESET\b/.test(message);
8788
9840
  }
8789
9841
  function installProcessSafetyNet(log, opts = {}) {
@@ -8792,37 +9844,37 @@ function installProcessSafetyNet(log, opts = {}) {
8792
9844
  stream.on("error", () => {
8793
9845
  });
8794
9846
  }
8795
- proc.on("uncaughtException", (err) => handleUncaught(log, err, "uncaughtException"));
9847
+ proc.on("uncaughtException", (err2) => handleUncaught(log, err2, "uncaughtException"));
8796
9848
  proc.on(
8797
9849
  "unhandledRejection",
8798
9850
  (reason) => handleUncaught(log, reason, "unhandledRejection")
8799
9851
  );
8800
9852
  }
8801
- function handleUncaught(log, err, origin) {
8802
- const message = err instanceof Error ? err.message : String(err);
8803
- const code = errorCode(err);
8804
- if (isRecoverableSocketError(err)) {
9853
+ function handleUncaught(log, err2, origin) {
9854
+ const message = err2 instanceof Error ? err2.message : String(err2);
9855
+ const code = errorCode(err2);
9856
+ if (isRecoverableSocketError(err2)) {
8805
9857
  log.warn(
8806
9858
  { origin, code, err: message },
8807
- "bridge: recovered from a socket error (kept running \u2014 a broken pipe never kills the bridge)"
9859
+ "companion: recovered from a socket error (kept running \u2014 a broken pipe never kills the companion)"
8808
9860
  );
8809
9861
  return;
8810
9862
  }
8811
9863
  log.error(
8812
- { origin, code, err: message, stack: err instanceof Error ? err.stack : void 0 },
8813
- "bridge: uncaught error (kept running \u2014 see the stack above)"
9864
+ { origin, code, err: message, stack: err2 instanceof Error ? err2.stack : void 0 },
9865
+ "companion: uncaught error (kept running \u2014 see the stack above)"
8814
9866
  );
8815
9867
  }
8816
9868
 
8817
9869
  // src/crash-marker.ts
8818
- import { existsSync as existsSync11, mkdirSync as mkdirSync10, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
8819
- import { join as join13 } from "path";
9870
+ import { existsSync as existsSync11, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
9871
+ import { join as join14 } from "path";
8820
9872
  function crashMarkerPath() {
8821
- return join13(cabaneDir(), "last-error.json");
9873
+ return join14(cabaneDir(), "last-error.json");
8822
9874
  }
8823
9875
  function recordCrash(rec) {
8824
9876
  try {
8825
- mkdirSync10(cabaneDir(), { recursive: true });
9877
+ mkdirSync11(cabaneDir(), { recursive: true });
8826
9878
  writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
8827
9879
  } catch {
8828
9880
  }
@@ -8836,7 +9888,7 @@ function clearCrash() {
8836
9888
  }
8837
9889
 
8838
9890
  // src/runtime.ts
8839
- async function createBridgeRuntime(opts = {}) {
9891
+ async function createCompanionRuntime(opts = {}) {
8840
9892
  const log = getLogger();
8841
9893
  installProcessSafetyNet(log);
8842
9894
  const probeClaude = opts.probeClaude ?? claudeOnPath;
@@ -8846,14 +9898,14 @@ async function createBridgeRuntime(opts = {}) {
8846
9898
  cfg = requireConfig();
8847
9899
  claudeCode = await probeClaude();
8848
9900
  await ensureRuntimeAvailable(cfg, { probeClaude: async () => claudeCode });
8849
- } catch (err) {
9901
+ } catch (err2) {
8850
9902
  recordCrash({
8851
- reason: err instanceof Error ? err.message : String(err),
8852
- ...errorCode(err) ? { code: errorCode(err) } : {},
9903
+ reason: err2 instanceof Error ? err2.message : String(err2),
9904
+ ...errorCode(err2) ? { code: errorCode(err2) } : {},
8853
9905
  origin: "startup",
8854
9906
  at: (/* @__PURE__ */ new Date()).toISOString()
8855
9907
  });
8856
- throw err;
9908
+ throw err2;
8857
9909
  }
8858
9910
  if (cfg.logLevel) log.level = cfg.logLevel;
8859
9911
  const harnessVersions = await probeHarnessVersions({
@@ -8870,23 +9922,29 @@ async function createBridgeRuntime(opts = {}) {
8870
9922
  url: "",
8871
9923
  port: 0,
8872
9924
  startedAt,
8873
- daemon: process.env.CABANE_BRIDGE_DAEMON === "1",
9925
+ daemon: process.env.CABANE_COMPANION_DAEMON === "1",
8874
9926
  instanceId
8875
9927
  });
8876
9928
  if (!claim.acquired) {
8877
9929
  return { ok: false, reason: "already-running", existing: claim.existing ?? null };
8878
9930
  }
8879
9931
  process.on("exit", () => clearRuntimeState());
8880
- const hub = new BridgeStateHub({
9932
+ const hub = new CompanionStateHub({
8881
9933
  // CT29: one device, one base URL — the cabane instance this device is paired
8882
9934
  // with. The dashboard's connection line shows it.
8883
9935
  baseUrl: cfg.baseUrl,
8884
- bridgeVersion: BRIDGE_VERSION,
9936
+ companionVersion: COMPANION_VERSION,
8885
9937
  // SJ516 F4: surfaced on `/api/status` so `stop`/`status` can confirm the
8886
- // process behind the marker pid is this bridge (not a recycled pid).
9938
+ // process behind the marker pid is this companion (not a recycled pid).
8887
9939
  instanceId
8888
9940
  });
8889
- const supervisor = new BridgeSupervisor({ config: cfg, log, hub, claudeCode, harnessVersions });
9941
+ const supervisor = new CompanionSupervisor({
9942
+ config: cfg,
9943
+ log,
9944
+ hub,
9945
+ claudeCode,
9946
+ harnessVersions
9947
+ });
8890
9948
  await supervisor.start();
8891
9949
  const preferredPort = opts.port ?? cfg.dashboardPort;
8892
9950
  const dashboard = await startDashboard({
@@ -8901,9 +9959,9 @@ async function createBridgeRuntime(opts = {}) {
8901
9959
  port: dashboard.port,
8902
9960
  startedAt,
8903
9961
  // SJ495: the daemon launcher sets this env on the detached child, so the
8904
- // marker records whether this bridge is backgrounded (foreground start
9962
+ // marker records whether this companion is backgrounded (foreground start
8905
9963
  // leaves it unset → false).
8906
- daemon: process.env.CABANE_BRIDGE_DAEMON === "1",
9964
+ daemon: process.env.CABANE_COMPANION_DAEMON === "1",
8907
9965
  instanceId
8908
9966
  });
8909
9967
  clearCrash();
@@ -8920,9 +9978,21 @@ async function createBridgeRuntime(opts = {}) {
8920
9978
  };
8921
9979
  return {
8922
9980
  ok: true,
8923
- runtime: { url: dashboard.url, port: dashboard.port, config: cfg, stop }
9981
+ runtime: {
9982
+ url: dashboard.url,
9983
+ port: dashboard.port,
9984
+ config: cfg,
9985
+ stop,
9986
+ drainForRestart: async (graceMs) => {
9987
+ clearRuntimeState();
9988
+ const result = await supervisor.drainForRestart(graceMs);
9989
+ await dashboard.close();
9990
+ stopped = true;
9991
+ return result;
9992
+ }
9993
+ }
8924
9994
  };
8925
9995
  }
8926
9996
  export {
8927
- createBridgeRuntime
9997
+ createCompanionRuntime
8928
9998
  };