@cabane/companion 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runtime.js CHANGED
@@ -13,16 +13,16 @@ import {
13
13
  } from "fs";
14
14
  import { homedir, userInfo } from "os";
15
15
  import { dirname, join } from "path";
16
- import { z as z3 } from "zod";
16
+ import { z as z2 } 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";
@@ -46,9 +46,6 @@ var PrepareHookError = class extends BridgeError {
46
46
  };
47
47
 
48
48
  // src/pairing.ts
49
- import { z } from "zod";
50
- var PAIRING_VERSION = 1;
51
- var DEVICE_TOKEN_PREFIX = "cabdev_";
52
49
  function isAllowedBaseUrl(raw) {
53
50
  let url;
54
51
  try {
@@ -63,40 +60,24 @@ function isAllowedBaseUrl(raw) {
63
60
  }
64
61
  return false;
65
62
  }
66
- var pairingSchema = z.object({
67
- // Bumped if the wire shape changes incompatibly. We only accept v1.
68
- v: z.literal(PAIRING_VERSION),
69
- baseUrl: z.string().url().refine(isAllowedBaseUrl, {
70
- message: "baseUrl must be https (loopback http is allowed for local dev only)"
71
- }),
72
- // The `cabdev_` device token plaintext — the bridge's one durable credential.
73
- deviceToken: z.string().min(1).startsWith(DEVICE_TOKEN_PREFIX, {
74
- message: 'deviceToken must be a cabane device token (starts with "cabdev_")'
75
- }),
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
78
- // required.
79
- deviceId: z.string().min(1).optional(),
80
- deviceLabel: z.string().min(1).optional()
81
- });
82
63
 
83
64
  // src/prepare-hook.ts
84
65
  import { spawn } from "child_process";
85
- import { z as z2 } from "zod";
86
- var prepareHookSchema = z2.object({
87
- command: z2.string().min(1),
88
- args: z2.array(z2.string()).optional(),
66
+ import { z } from "zod";
67
+ var prepareHookSchema = z.object({
68
+ command: z.string().min(1),
69
+ args: z.array(z.string()).optional(),
89
70
  // Extra env handed to the hook process itself (merged over process.env).
90
- env: z2.record(z2.string(), z2.string()).optional(),
71
+ env: z.record(z.string(), z.string()).optional(),
91
72
  // Wall-clock cap for the hook. Provisioning is slow (minutes), so the
92
73
  // 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.
94
- timeoutMs: z2.number().int().positive().optional()
74
+ // fails with a clear timeout message rather than pinning the companion.
75
+ timeoutMs: z.number().int().positive().optional()
95
76
  }).strict();
96
77
  var DEFAULT_TIMEOUT_MS = 10 * 6e4;
97
- var prepareResultSchema = z2.object({
98
- cwd: z2.string().min(1),
99
- env: z2.record(z2.string(), z2.string()).optional()
78
+ var prepareResultSchema = z.object({
79
+ cwd: z.string().min(1),
80
+ env: z.record(z.string(), z.string()).optional()
100
81
  });
101
82
  function parsePrepareOutput(stdout) {
102
83
  const last = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).at(-1);
@@ -115,10 +96,13 @@ function parsePrepareOutput(stdout) {
115
96
  const r = prepareResultSchema.safeParse(parsed);
116
97
  if (!r.success) {
117
98
  throw new PrepareHookError(
118
- 'prepare hook JSON must carry a non-empty string "cwd" (and an optional string-map "env")'
99
+ 'prepare hook JSON must carry a non-empty string "cwd" (plus optional "env")'
119
100
  );
120
101
  }
121
- return { cwd: r.data.cwd, ...r.data.env ? { env: r.data.env } : {} };
102
+ return {
103
+ cwd: r.data.cwd,
104
+ ...r.data.env ? { env: r.data.env } : {}
105
+ };
122
106
  }
123
107
  return { cwd: last };
124
108
  }
@@ -143,6 +127,7 @@ var runPrepareHook = (hook, input) => {
143
127
  CABANE_CONVERSATION_ID: input.conversationId,
144
128
  CABANE_AGENT_ID: input.agentId,
145
129
  CABANE_AGENT_USERNAME: input.agentUsername,
130
+ CABANE_RUNTIME: input.runtime ?? "",
146
131
  // CT319: the conversation anchor is gone. These are kept as DEPRECATED
147
132
  // back-compat constants so an old user prepare script that still reads
148
133
  // them doesn't crash; a hook should key off CABANE_TRIGGER_ENTRY_PATHS.
@@ -217,12 +202,12 @@ function realAccountHome() {
217
202
  return realHomeCache;
218
203
  }
219
204
  function cabaneDir() {
220
- const dir2 = join(process.env.CABANE_BRIDGE_HOME || homedir(), ".cabane");
205
+ const dir2 = join(process.env.CABANE_COMPANION_HOME || homedir(), ".cabane");
221
206
  if (process.env.VITEST) {
222
207
  const real = realAccountHome();
223
208
  if (real && dir2 === join(real, ".cabane")) {
224
209
  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).`
210
+ `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
211
  );
227
212
  }
228
213
  }
@@ -231,55 +216,55 @@ function cabaneDir() {
231
216
  function configPath() {
232
217
  return join(cabaneDir(), "config.json");
233
218
  }
234
- var localAgentConfigSchema = z3.object({
235
- cwd: z3.string().optional(),
219
+ var localAgentConfigSchema = z2.object({
220
+ cwd: z2.string().optional(),
236
221
  prepareHook: prepareHookSchema.optional(),
237
222
  // 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
223
+ // by default on every companion (memory belongs in the Cabane workspace, and a
224
+ // shared companion would otherwise pool one cwd-keyed memory dir across users).
225
+ // On a companion you run yourself, set `claudeCode: { autoMemory: true }` to hand
241
226
  // auto-memory back to your own `~/.claude/settings.json` — Cabane then stops
242
227
  // injecting the off switch and your normal Claude Code memory workflow applies
243
228
  // (in coding mode, where the checkout's project settings are read).
244
- claudeCode: z3.object({ autoMemory: z3.boolean().optional() }).strict().optional()
229
+ claudeCode: z2.object({ autoMemory: z2.boolean().optional() }).strict().optional()
245
230
  }).strict();
246
- var bridgeConfigSchema = z3.object({
231
+ var companionConfigSchema = z2.object({
247
232
  // The cabane instance this device is paired with. SJ515: https-enforced
248
233
  // (loopback exempt) so a hand-edited config can't smuggle a plaintext-http
249
234
  // base URL onto the MITM-able channel the device token + prompt ride.
250
- baseUrl: z3.string().url().refine(isAllowedBaseUrl, {
235
+ baseUrl: z2.string().url().refine(isAllowedBaseUrl, {
251
236
  message: "baseUrl must be https (loopback http is allowed for local dev only)"
252
237
  }),
253
- // The `cabdev_` device token plaintext — the bridge's one durable credential,
238
+ // The `cabdev_` device token plaintext — the companion's one durable credential,
254
239
  // presented as `Authorization: Bearer <token>` on the device-facing pull +
255
240
  // heartbeat endpoints. Optional so `cabane-companion logout` can strip it (a
256
241
  // "paired but logged out" state the supervisor refuses to run) while keeping
257
242
  // the rest of the config; `pair` always writes one.
258
- deviceToken: z3.string().optional(),
259
- // Identity hints, learned from the pairing string and refreshed on the first
243
+ deviceToken: z2.string().optional(),
244
+ // Identity hints, learned from the device flow and refreshed on the first
260
245
  // assignments pull. Cosmetic — used for `status`/dashboard display only.
261
- deviceId: z3.string().optional(),
262
- deviceLabel: z3.string().optional(),
246
+ deviceId: z2.string().optional(),
247
+ deviceLabel: z2.string().optional(),
263
248
  // Optional per-agent machine-local overrides (cwd / prepareHook), keyed by
264
- // agentId / username / `slug/username`. Hand-added by the operator; the bridge
249
+ // agentId / username / `slug/username`. Hand-added by the operator; the companion
265
250
  // never writes this (it only persists credentials + the device, elsewhere).
266
- agents: z3.record(z3.string(), localAgentConfigSchema).optional(),
251
+ agents: z2.record(z2.string(), localAgentConfigSchema).optional(),
267
252
  // Dashboard settings (all optional). dashboardPort: preferred bind port (next
268
253
  // free one if taken); autoOpen: whether `start` opens the browser (the
269
- // `--no-open` flag / `BRIDGE_NO_OPEN=1` override per-run); logLevel: pino
254
+ // `--no-open` flag / `COMPANION_NO_OPEN=1` override per-run); logLevel: pino
270
255
  // level, live-editable from the dashboard settings panel.
271
- dashboardPort: z3.number().int().min(1).max(65535).optional(),
272
- autoOpen: z3.boolean().optional(),
273
- logLevel: z3.enum(["warn", "info", "debug"]).optional(),
256
+ dashboardPort: z2.number().int().min(1).max(65535).optional(),
257
+ autoOpen: z2.boolean().optional(),
258
+ logLevel: z2.enum(["warn", "info", "debug"]).optional(),
274
259
  // CT270: the opencode runtime, when the operator runs one on this machine. The
275
260
  // operator installs opencode, starts `opencode serve` (auth via opencode's own
276
- // `/connect` — Cabane never sees provider keys), and points the bridge at it
261
+ // `/connect` — Cabane never sees provider keys), and points the companion at it
277
262
  // here. Setting this makes the device advertise the `opencode` runtime on its
278
263
  // heartbeat manifest (so the server offers DeepSeek/opencode models here and
279
264
  // routes those turns to this device) AND registers the opencode adapter in the
280
265
  // dispatcher. Absent → the device is claude-code-only, exactly as before.
281
- opencode: z3.object({
282
- serverUrl: z3.string().url()
266
+ opencode: z2.object({
267
+ serverUrl: z2.string().url()
283
268
  }).strict().optional(),
284
269
  // CT481: the codex runtime, when the operator runs Codex on this machine. Unlike
285
270
  // opencode (a long-lived `opencode serve` addressed by URL), Codex is a local CLI
@@ -290,20 +275,13 @@ var bridgeConfigSchema = z3.object({
290
275
  // keeps the block but turns it off). Enabling makes the device advertise the
291
276
  // `codex` runtime on its heartbeat manifest AND registers the codex adapter in
292
277
  // the dispatcher. Absent → the device doesn't offer codex, exactly as before.
293
- codex: z3.object({
294
- enabled: z3.boolean().optional()
278
+ codex: z2.object({
279
+ enabled: z2.boolean().optional()
295
280
  }).strict().optional()
296
281
  });
297
282
  function isCodexEnabled(cfg) {
298
283
  return !!cfg.codex && cfg.codex.enabled !== false;
299
284
  }
300
- function cabaneNativeApiKey() {
301
- const key = process.env.OPENROUTER_API_KEY?.trim();
302
- return key ? key : void 0;
303
- }
304
- function isCabaneNativeEnabled() {
305
- return cabaneNativeApiKey() !== void 0;
306
- }
307
285
  function localAgentConfig(cfg, agent) {
308
286
  const map = cfg.agents ?? {};
309
287
  return map[agent.agentId] ?? map[agent.agentUsername] ?? map[`${agent.workspaceSlug}/${agent.agentUsername}`] ?? {};
@@ -328,7 +306,7 @@ function loadConfig() {
328
306
  `${path3} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
329
307
  );
330
308
  }
331
- const result = bridgeConfigSchema.safeParse(parsed);
309
+ const result = companionConfigSchema.safeParse(parsed);
332
310
  if (!result.success) {
333
311
  const agentIssue = result.error.issues.find((i) => i.path[0] === "agents");
334
312
  if (agentIssue) {
@@ -337,7 +315,7 @@ function loadConfig() {
337
315
  );
338
316
  }
339
317
  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.`
318
+ `${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
319
  );
342
320
  }
343
321
  return result.data;
@@ -369,7 +347,7 @@ function requireConfig() {
369
347
  const cfg = loadConfig();
370
348
  if (!cfg) {
371
349
  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."
350
+ "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
351
  );
374
352
  }
375
353
  return cfg;
@@ -392,8 +370,8 @@ import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
392
370
  import { dirname as dirname2, join as join2 } from "path";
393
371
  import pino from "pino";
394
372
  import pretty from "pino-pretty";
395
- function bridgeLogPath() {
396
- return join2(cabaneDir(), "bridge.log");
373
+ function companionLogPath() {
374
+ return join2(cabaneDir(), "companion.log");
397
375
  }
398
376
  var CONSOLE_IGNORE = [
399
377
  "pid",
@@ -403,7 +381,7 @@ var CONSOLE_IGNORE = [
403
381
  "agentId",
404
382
  "messageId",
405
383
  "sessionId",
406
- "bridgeId"
384
+ "companionId"
407
385
  ].join(",");
408
386
  function consoleShortId(log) {
409
387
  const id = log.conversationId ?? log.workspaceId;
@@ -417,10 +395,10 @@ function consoleMessageFormat(log, messageKey) {
417
395
  var cached = null;
418
396
  function getLogger() {
419
397
  if (cached) return cached;
420
- const path3 = bridgeLogPath();
398
+ const path3 = companionLogPath();
421
399
  mkdirSync2(dirname2(path3), { recursive: true });
422
400
  const streams = [];
423
- if (process.env.CABANE_BRIDGE_DAEMON !== "1") {
401
+ if (process.env.CABANE_COMPANION_DAEMON !== "1") {
424
402
  const consoleStream = pretty({
425
403
  colorize: true,
426
404
  ignore: CONSOLE_IGNORE,
@@ -452,7 +430,7 @@ var MAX_DISPATCHES = 50;
452
430
  function today() {
453
431
  return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
454
432
  }
455
- var BridgeStateHub = class {
433
+ var CompanionStateHub = class {
456
434
  constructor(opts) {
457
435
  this.opts = opts;
458
436
  this.emitter.setMaxListeners(0);
@@ -466,7 +444,7 @@ var BridgeStateHub = class {
466
444
  deviceLabel = null;
467
445
  deviceError = null;
468
446
  // CT586: the latest harness snapshot the supervisor probed, or null before the
469
- // first probe (see BridgeStatusJson.harnesses).
447
+ // first probe (see CompanionStatusJson.harnesses).
470
448
  harnesses = null;
471
449
  // ---- subscription (SSE) ----
472
450
  on(listener) {
@@ -658,7 +636,7 @@ var BridgeStateHub = class {
658
636
  })),
659
637
  last_event_at_overall: lastOverall,
660
638
  dashboard_url: this.dashboardUrl,
661
- bridge_version: this.opts.bridgeVersion,
639
+ companion_version: this.opts.companionVersion,
662
640
  instance_id: this.opts.instanceId ?? null,
663
641
  harnesses: this.harnesses
664
642
  };
@@ -699,7 +677,7 @@ function registerRoutes(app, deps) {
699
677
  });
700
678
  app.get("/api/logs", async (c) => {
701
679
  const lines = clampLimit(c.req.query("lines"), 200, 1e3);
702
- return c.json({ lines: tailFile(bridgeLogPath(), lines) });
680
+ return c.json({ lines: tailFile(companionLogPath(), lines) });
703
681
  });
704
682
  app.post("/api/settings", async (c) => {
705
683
  const body = await readJson(c);
@@ -840,7 +818,7 @@ function buildDashboardApp(deps) {
840
818
  const status = err.status >= 400 && err.status < 600 ? err.status : 502;
841
819
  return c.json({ error: err.message }, status);
842
820
  }
843
- if (err instanceof BridgeError) {
821
+ if (err instanceof CompanionError) {
844
822
  return c.json({ error: err.message }, 400);
845
823
  }
846
824
  return c.json({ error: err instanceof Error ? err.message : "internal error" }, 500);
@@ -872,7 +850,7 @@ async function startDashboard(opts) {
872
850
  throw err;
873
851
  }
874
852
  }
875
- throw new BridgeError(
853
+ throw new CompanionError(
876
854
  `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
855
  );
878
856
  }
@@ -1018,8 +996,8 @@ async function ensureRuntimeAvailable(cfg, deps = {}) {
1018
996
  );
1019
997
  return;
1020
998
  }
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)."
999
+ throw new CompanionError(
1000
+ "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
1001
  );
1024
1002
  }
1025
1003
 
@@ -1208,7 +1186,7 @@ var CabaneApi = class {
1208
1186
  outbox.persist({ enqueuedAt: Date.now(), turnId, seq, method: "POST", path: path3, body, kind });
1209
1187
  this.opts.log?.warn(
1210
1188
  { 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)"
1189
+ "companion: commit queued to outbox after transient failure (will drain when the API returns)"
1212
1190
  );
1213
1191
  }
1214
1192
  }
@@ -1235,7 +1213,7 @@ var CabaneApi = class {
1235
1213
  if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
1236
1214
  this.opts.log?.warn(
1237
1215
  { kind: entry.kind, turnId: entry.turnId, seq: entry.seq, status: err.status },
1238
- "bridge outbox: discarding entry on terminal 4xx (will never land)"
1216
+ "companion outbox: discarding entry on terminal 4xx (will never land)"
1239
1217
  );
1240
1218
  outbox.remove(entry.turnId, entry.seq);
1241
1219
  progressed = true;
@@ -1262,7 +1240,7 @@ var CabaneApi = class {
1262
1240
  // the machinery the `sub_agent` turn-control tool is sugar over. Two things make
1263
1241
  // it distinct from an ordinary `request` call, so it does its own `fetch`:
1264
1242
  // - 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
1243
+ // companion PAT — so the spawn carries the same authority as the agent's other
1266
1244
  // cabane calls this turn (mirrors build-options' `turnToken ?? agentPat`);
1267
1245
  // - the `x-cabane-active-conversation` header naming the caller's turn, which
1268
1246
  // the server verifies against the live run to resolve the caller pair for the
@@ -1295,15 +1273,15 @@ var CabaneApi = class {
1295
1273
  }
1296
1274
  return { status: res.status, body: parsed };
1297
1275
  }
1298
- // SJ383: bridge-only participant ops. All three authenticate with the
1276
+ // SJ383: companion-only participant ops. All three authenticate with the
1299
1277
  // workspace's agent-bound PAT (passed as `token` on this client) — never
1300
1278
  // the user PAT.
1301
- // Recovery-path read: returns the bridge's `agentSessionId` for this
1279
+ // Recovery-path read: returns the companion's `agentSessionId` for this
1302
1280
  // conversation so the dispatcher can pass it as `Options.resume`, plus
1303
- // the current `activeRunStartedAt` so a freshly-reconnected bridge can
1281
+ // the current `activeRunStartedAt` so a freshly-reconnected companion can
1304
1282
  // see whether a prior run is still flagged in-flight.
1305
1283
  // SJ383: recovery-path read for a participant row — the `agentSessionId` a
1306
- // freshly-reconnected bridge resumes on. CT262: the per-turn piggybacks this
1284
+ // freshly-reconnected companion resumes on. CT262: the per-turn piggybacks this
1307
1285
  // fetch grew (agentRules / visionBlocks / channel / members / conversationContext)
1308
1286
  // are gone — `getTurnContext` composes the whole turn now — so this is back to
1309
1287
  // the plain recovery read, with no `messageId` param.
@@ -1313,17 +1291,37 @@ var CabaneApi = class {
1313
1291
  `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}`
1314
1292
  );
1315
1293
  }
1316
- // CT262: the ONE turn-context fetch. Collapses the bridge's old four-fetch
1294
+ // CT262: the ONE turn-context fetch. Collapses the companion's old four-fetch
1317
1295
  // choreography (getConversation + getMessage + getAgentSelf + getParticipantAgent)
1318
1296
  // into a single call: the server composes the whole server portion of the
1319
1297
  // `TurnRequest` — systemPrompt, per-turn prompt + vision content, effective
1320
1298
  // run-config, `HostPolicy`, prior session, the user MCP definitions to resolve
1321
1299
  // locally, plus the anchor/title + trigger-message summary the host needs.
1322
1300
  // Agent-PAT authed; the workspace is implied by the PAT.
1323
- getTurnContext(conversationId, messageId2) {
1324
- const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}`;
1301
+ // CT714: `turnId` is the host-minted id for THIS turn, passed so the server can
1302
+ // bind the minted turn token to it — the turn-control surface then rejects a
1303
+ // token whose turn has ended. The dispatcher mints it before this call and
1304
+ // reuses the same value on its active-run PATCH, so the token's turn id and the
1305
+ // pair's `active_turn_id` agree.
1306
+ getTurnContext(conversationId, messageId2, turnId) {
1307
+ const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
1325
1308
  return this.request("GET", `/api/agent/turn-context?${q}`);
1326
1309
  }
1310
+ // CT714: read a turn's recorded turn-control intent (ask/wake/summon/skip). An
1311
+ // EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
1312
+ // `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
1313
+ // in-memory closures, so the dispatcher fetches this once at settle — by
1314
+ // `turnId` — and populates those closures, letting the unchanged settle path
1315
+ // materialize the effects identically to claude-code. Agent-PAT authed +
1316
+ // self-scoped (`:agentId` must match the PAT's agent). A turn that recorded no
1317
+ // control verb returns all-empty fields.
1318
+ getTurnIntent(workspaceId, conversationId, agentId, turnId) {
1319
+ const q = `turnId=${encodeURIComponent(turnId)}`;
1320
+ return this.request(
1321
+ "GET",
1322
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-intent?${q}`
1323
+ );
1324
+ }
1327
1325
  // Flips `active_run_started_at` and optionally captures the SDK session
1328
1326
  // id. The dispatcher hits this twice per turn (now() before the SDK
1329
1327
  // loop; null when it settles) plus once with the session id on the
@@ -1336,7 +1334,7 @@ var CabaneApi = class {
1336
1334
  // `durableActiveRunWrite`). The session-id-only write (first-frame capture) is
1337
1335
  // left best-effort: it's lower-stakes and self-heals on the next turn, so it
1338
1336
  // stays a single-shot PATCH and is deliberately out of CT93's scope.
1339
- setBridgeActiveRun(workspaceId, conversationId, agentId, body) {
1337
+ setActiveRun(workspaceId, conversationId, agentId, body) {
1340
1338
  const path3 = `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/active-run`;
1341
1339
  const touchesFlag = Object.prototype.hasOwnProperty.call(body, "activeRunStartedAt");
1342
1340
  if (touchesFlag && this.opts.outbox) {
@@ -1379,16 +1377,16 @@ var CabaneApi = class {
1379
1377
  });
1380
1378
  this.opts.log?.warn(
1381
1379
  { 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)"
1380
+ "companion: active-run write queued to outbox after transient failure (will drain when the API returns)"
1383
1381
  );
1384
1382
  }
1385
1383
  }
1386
1384
  // CT29: per-device liveness moved off the per-workspace agent PAT and onto the
1387
1385
  // device token — see `DeviceApi.heartbeat`. There is no agent-PAT heartbeat
1388
1386
  // anymore.
1389
- // SJ477: commit one row of the bridge's turn (a `progress` interim note or
1387
+ // SJ477: commit one row of the companion's turn (a `progress` interim note or
1390
1388
  // 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
1389
+ // public messages endpoint a user hits — the companion holds an agent-bound
1392
1390
  // PAT, so the server attributes the row to this agent (role `agent`) and
1393
1391
  // won't re-dispatch (the route gates re-dispatch on role `user`). `turnId`
1394
1392
  // groups every row of one turn so the chat drawer renders them as a single
@@ -1403,12 +1401,12 @@ var CabaneApi = class {
1403
1401
  // CT11: `kind` now includes `'stopped'` for the terminal marker the
1404
1402
  // dispatcher writes when a turn is cancelled — same wire shape as
1405
1403
  // `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
1404
+ // turn-group renderer treats it as a closing row. `seq` is the companion's
1407
1405
  // per-turn monotonic counter, stamped on the row so the merged timeline
1408
1406
  // orders the commit deterministically against the persisted tool/thinking
1409
- // rows. Both fields are optional on the wire — an older bridge that didn't
1407
+ // rows. Both fields are optional on the wire — an older companion that didn't
1410
1408
  // mint seq still validates (the server defaults to 0); `stopped` is only
1411
- // emitted by post-CT11 bridges.
1409
+ // emitted by post-CT11 companions.
1412
1410
  postTurnMessage(workspaceId, conversationId, body, signal) {
1413
1411
  return this.durableCommit(
1414
1412
  "message",
@@ -1449,11 +1447,11 @@ var CabaneApi = class {
1449
1447
  );
1450
1448
  }
1451
1449
  // SJ493: fetch the agent's self-view — identity + operating context. The
1452
- // bridge calls this on each dispatch to get its `systemPrompt` (composed
1450
+ // companion calls this on each dispatch to get its `systemPrompt` (composed
1453
1451
  // server-side from the bundled default + the agent's charter) rather than
1454
1452
  // baking a copy of the prompt into the download. Cabane is the control plane
1455
1453
  // 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
1454
+ // without shipping a new companion. Agent-PAT authed; the workspace is implied
1457
1455
  // by the PAT, so no workspace arg.
1458
1456
  // CT245: pass the triggering turn's `conversationId` so the server returns the
1459
1457
  // run-config RESOLVED for this conversation (agent default + that
@@ -1464,10 +1462,10 @@ var CabaneApi = class {
1464
1462
  const path3 = conversationId ? `/api/agent/me?conversationId=${encodeURIComponent(conversationId)}` : `/api/agent/me`;
1465
1463
  return this.request("GET", path3);
1466
1464
  }
1467
- // The bridge fetches the triggering message body by listing the
1465
+ // The companion fetches the triggering message body by listing the
1468
1466
  // conversation's messages and finding the one with `id === messageId`.
1469
1467
  // 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
1468
+ // the companion only reaches for the specific row immediately after the
1471
1469
  // event fires (the thread is small at that point).
1472
1470
  async getMessage(workspaceId, conversationId, messageId2) {
1473
1471
  const res = await this.request(
@@ -1546,7 +1544,10 @@ var DeviceApi = class {
1546
1544
  getAssignments() {
1547
1545
  return this.request("GET", "/api/companion/assignments");
1548
1546
  }
1549
- // Per-device liveness ping. Reports the bridge build version and the env-var
1547
+ beginDrain() {
1548
+ return this.request("POST", "/api/companion/drain", {});
1549
+ }
1550
+ // Per-device liveness ping. Reports the companion build version and the env-var
1550
1551
  // names the operator's secret store exposes (never values), so CT30's UI can
1551
1552
  // warn pre-emptively about an agent that needs a secret this device lacks.
1552
1553
  heartbeat(body) {
@@ -1573,11 +1574,11 @@ import {
1573
1574
  writeFileSync as writeFileSync3
1574
1575
  } from "fs";
1575
1576
  import { dirname as dirname4, join as join6 } from "path";
1576
- import { z as z4 } from "zod";
1577
+ import { z as z3 } from "zod";
1577
1578
  function credentialsPath() {
1578
1579
  return join6(cabaneDir(), "credentials.json");
1579
1580
  }
1580
- var credentialStoreSchema = z4.record(z4.string(), z4.string());
1581
+ var credentialStoreSchema = z3.record(z3.string(), z3.string());
1581
1582
  function load() {
1582
1583
  const path3 = credentialsPath();
1583
1584
  if (!existsSync4(path3)) return {};
@@ -1781,54 +1782,55 @@ function bumpResumeAttempt(workspaceId, eventId) {
1781
1782
  return next;
1782
1783
  }
1783
1784
  function noResume() {
1784
- return process.env.CABANE_BRIDGE_NO_RESUME === "1";
1785
+ return process.env.CABANE_COMPANION_NO_RESUME === "1";
1785
1786
  }
1786
1787
 
1787
1788
  // packages/agent-runtime/src/version.ts
1788
1789
  var TURN_PROTOCOL_VERSION = 1;
1789
1790
 
1790
1791
  // packages/agent-runtime/src/host-policy.ts
1791
- import { z as z5 } from "zod";
1792
- var hostPolicySchema = z5.object({
1792
+ import { z as z4 } from "zod";
1793
+ var hostPolicySchema = z4.object({
1793
1794
  // Host filesystem + shell: `Bash`/`Read`/`Write`/`Edit`/`Glob`/`Grep`, the
1794
1795
  // notebook read/write pair, git-worktree tools, and the `REPL` host code-exec
1795
1796
  // tool. Off under the locked assistant surface (today's `DISALLOWED_TOOLS`);
1796
1797
  // on under `coding` mode.
1797
- hostFs: z5.boolean(),
1798
+ hostFs: z4.boolean(),
1798
1799
  // Public web (today's `WebSearch` / `WebFetch`, the `DEFAULT_WEB_TOOLS`). Public
1799
1800
  // web, not host reach — granted by default today, but expressible as a grant.
1800
- web: z5.boolean(),
1801
- // Browser automation (the Playwright MCP surface). Varies by host: a bridge has
1801
+ web: z4.boolean(),
1802
+ // Browser automation (the Playwright MCP surface). Varies by host: a companion has
1802
1803
  // it, the house executor does not (CT230).
1803
- browser: z5.boolean(),
1804
+ browser: z4.boolean(),
1804
1805
  // User-configured MCP servers permitted. False for the house executor
1805
- // (CT227: Cabane agents run no user MCP servers), true for a personal bridge.
1806
- userMcp: z5.boolean(),
1806
+ // (CT227: Cabane agents run no user MCP servers), true for a personal companion.
1807
+ userMcp: z4.boolean(),
1807
1808
  // Subagent / sub-dispatch (`Task` / `Agent`). A per-host GRANT (see the CT261
1808
1809
  // amendment above): `false` on the locked assistant/house surface (banned via
1809
1810
  // `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
1811
+ // the coding allow-hook (which reaches sub-agent tool calls, CT680) or the user
1812
+ // allowlist. The subagent completes within the turn, so
1811
1813
  // it's not the turn-model invariant `scheduling` is.
1812
- subagents: z5.boolean(),
1814
+ subagents: z4.boolean(),
1813
1815
  // ── Hard platform invariants — always denied, never granted ────────────────
1814
1816
  // Deferred re-invocation / scheduling (`ScheduleWakeup`, the `Cron*` / `Task*`
1815
1817
  // families, `Monitor`, `Workflow`, …). A turn is one query that resolves when
1816
1818
  // `result` fires; a scheduled callback fires after the reply window has closed
1817
1819
  // and strands the agent (the CT155/CT156 rule).
1818
- scheduling: z5.literal("never"),
1820
+ scheduling: z4.literal("never"),
1819
1821
  // Human-facing UI prompts (`AskUserQuestion`). A Cabane conversation has no
1820
1822
  // handler to answer a structured prompt, so the call hangs the turn
1821
1823
  // (`UNSUPPORTED_TOOLS`). The agent asks in its reply instead.
1822
- uiPrompts: z5.literal("never")
1824
+ uiPrompts: z4.literal("never")
1823
1825
  });
1824
1826
 
1825
1827
  // packages/agent-runtime/src/turn-event.ts
1826
- import { z as z6 } from "zod";
1827
- var turnEventSchema = z6.discriminatedUnion("type", [
1828
+ import { z as z5 } from "zod";
1829
+ var turnEventSchema = z5.discriminatedUnion("type", [
1828
1830
  // The runtime's opaque session state, emitted when the adapter learns it (e.g.
1829
1831
  // the SDK `system/init` frame). The platform stores `state` verbatim per
1830
1832
  // (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
1833
+ // knows what it means. Today's companion captures the raw SDK session id here; a
1832
1834
  // future adapter may encode more (e.g. `{sdkSessionId, cwd}`) — still one
1833
1835
  // opaque string to the platform.
1834
1836
  //
@@ -1841,22 +1843,22 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1841
1843
  // so the mark now over-reaches an empty session. The host relays `degraded`
1842
1844
  // on settle and the server rewinds the mark, so the NEXT turn rebuilds a full
1843
1845
  // 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
1846
+ // on the companion, after the server committed the manifest). Runtime-neutral: a
1845
1847
  // plain boolean, not a runtime-specific reason string (that stays in the
1846
1848
  // adapter's `onWarn` log). Additive + optional — an old receiver ignores it.
1847
- z6.object({
1848
- type: z6.literal("session"),
1849
- state: z6.string(),
1850
- degraded: z6.boolean().optional()
1849
+ z5.object({
1850
+ type: z5.literal("session"),
1851
+ state: z5.string(),
1852
+ degraded: z5.boolean().optional()
1851
1853
  }),
1852
1854
  // One readable thinking summary. Maps `onThinking({ text })`. Transient —
1853
1855
  // surfaced live, never persisted as durable content.
1854
- z6.object({ type: z6.literal("thinking"), text: z6.string() }),
1856
+ z5.object({ type: z5.literal("thinking"), text: z5.string() }),
1855
1857
  // Assistant text. Maps `onAssistantText({ text, final })` — `text`→`body`,
1856
1858
  // `final`→`terminal`. `terminal: false` is interim narration (commits as a
1857
1859
  // `progress` row); `terminal: true` is the turn's closing reply (commits as
1858
1860
  // the `final` row).
1859
- z6.object({ type: z6.literal("text"), body: z6.string(), terminal: z6.boolean() }),
1861
+ z5.object({ type: z5.literal("text"), body: z5.string(), terminal: z5.boolean() }),
1860
1862
  // A tool-activity transition. Maps `onToolActivity` — `toolUseId`→`id`,
1861
1863
  // `toolName`→`name` (already prefix-stripped: `cabane_read`, not
1862
1864
  // `mcp__cabane__cabane_read`), `summary` is the short card label. `phase`
@@ -1871,15 +1873,15 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1871
1873
  // dropped the prefix; null for a host / built-in tool. The client tags Cabane
1872
1874
  // MCP calls (`=== 'cabane'`) with a subtle glyph. Nullable + optional so a
1873
1875
  // pre-CT496 producer that never sets it is unaffected (treated as null).
1874
- z6.object({
1875
- type: z6.literal("tool"),
1876
- id: z6.string(),
1877
- name: z6.string(),
1878
- phase: z6.enum(["start", "done", "error"]),
1879
- summary: z6.string(),
1880
- input: z6.unknown().optional(),
1881
- result: z6.unknown().optional(),
1882
- mcpServer: z6.string().nullable().optional()
1876
+ z5.object({
1877
+ type: z5.literal("tool"),
1878
+ id: z5.string(),
1879
+ name: z5.string(),
1880
+ phase: z5.enum(["start", "done", "error"]),
1881
+ summary: z5.string(),
1882
+ input: z5.unknown().optional(),
1883
+ result: z5.unknown().optional(),
1884
+ mcpServer: z5.string().nullable().optional()
1883
1885
  }),
1884
1886
  // The turn's terminal outcome. Maps the `{ ok, reason }` both loops return
1885
1887
  // inline. `ok:false` carries a machine reason (`no_session`, an error code);
@@ -1889,7 +1891,7 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1889
1891
  // dropped on the floor before. `inputTokens` is the full context the model saw
1890
1892
  // (uncached + cache-read + cache-creation input), so it doubles as the
1891
1893
  // context-window cost; `outputTokens` the generated tokens. Optional ⇒
1892
- // backward-compatible: an old bridge / adapter that never sets it, and a
1894
+ // backward-compatible: an old companion / adapter that never sets it, and a
1893
1895
  // receiver that never reads it, are unaffected (the turn's token columns stay
1894
1896
  // null → the UI shows `—`).
1895
1897
  //
@@ -1899,6 +1901,19 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1899
1901
  // the server just leaves the cache columns null. Captured now because honest
1900
1902
  // costing later prices a cache-read token far below a fresh input token.
1901
1903
  //
1904
+ // CT699: `inputTokens` (and the cache slice) is a BILLING quantity — for
1905
+ // claude-code/codex it's the runtime's CUMULATIVE total summed across every model
1906
+ // request in the agentic turn, so it grows with the tool-call count and is NOT
1907
+ // "how full is the window." `contextTokens` is the distinct CONTEXT-OCCUPANCY
1908
+ // read: the input the model saw on its FINAL request of the turn (uncached +
1909
+ // cache, since cached tokens still occupy the window) — the number the composer
1910
+ // gauge wants. `contextWindow` is the model's true window in tokens when the
1911
+ // runtime reports it (claude-code's SDK does, per model) — a real denominator so
1912
+ // the gauge can show a fraction. Both optional: a runtime that can't source a
1913
+ // clean final-request figure (codex's cumulative-only usage) omits `contextTokens`
1914
+ // and the gauge falls back to the raw count; `contextWindow` falls back to the
1915
+ // model catalog.
1916
+ //
1902
1917
  // CT601: two more optional carry-homes on the terminal result, alongside
1903
1918
  // `usage`. `resolvedModel` is the CONCRETE model the runtime actually ran —
1904
1919
  // claude-code learns it from the `system/init` frame mid-run (even for a
@@ -1908,34 +1923,36 @@ var turnEventSchema = z6.discriminatedUnion("type", [
1908
1923
  // for claude-code today). Both only known after the run streams — so they ride
1909
1924
  // the terminal event home, the host relays them on settle, and the server writes
1910
1925
  // `message_turn_metadata.resolved_model` / `resolved_config`. Optional ⇒
1911
- // backward-compatible: an old adapter/bridge omits them, a cancel has no result
1926
+ // backward-compatible: an old adapter/companion omits them, a cancel has no result
1912
1927
  // event at all, and the columns stay null → the UI shows `—`.
1913
- z6.object({
1914
- type: z6.literal("result"),
1915
- ok: z6.boolean(),
1916
- reason: z6.string().optional(),
1917
- usage: z6.object({
1918
- inputTokens: z6.number(),
1919
- outputTokens: z6.number(),
1920
- cacheReadTokens: z6.number().optional(),
1921
- cacheCreationTokens: z6.number().optional()
1928
+ z5.object({
1929
+ type: z5.literal("result"),
1930
+ ok: z5.boolean(),
1931
+ reason: z5.string().optional(),
1932
+ usage: z5.object({
1933
+ inputTokens: z5.number(),
1934
+ outputTokens: z5.number(),
1935
+ cacheReadTokens: z5.number().optional(),
1936
+ cacheCreationTokens: z5.number().optional(),
1937
+ contextTokens: z5.number().optional(),
1938
+ contextWindow: z5.number().optional()
1922
1939
  }).optional(),
1923
- resolvedModel: z6.string().optional(),
1924
- resolvedConfig: z6.object({
1925
- effort: z6.string().optional(),
1926
- thinking: z6.string().optional(),
1927
- reasoningEffort: z6.string().optional()
1940
+ resolvedModel: z5.string().optional(),
1941
+ resolvedConfig: z5.object({
1942
+ effort: z5.string().optional(),
1943
+ thinking: z5.string().optional(),
1944
+ reasoningEffort: z5.string().optional()
1928
1945
  }).optional()
1929
1946
  })
1930
1947
  ]);
1931
1948
 
1932
1949
  // packages/agent-runtime/src/failure.ts
1933
- import { z as z7 } from "zod";
1934
- var turnFailureSchema = z7.discriminatedUnion("kind", [
1935
- z7.object({ kind: z7.literal("usage_capped"), resetsAt: z7.string().optional() }),
1936
- z7.object({ kind: z7.literal("rate_limited") }),
1937
- z7.object({ kind: z7.literal("server_error") }),
1938
- z7.object({ kind: z7.literal("auth_expired") })
1950
+ import { z as z6 } from "zod";
1951
+ var turnFailureSchema = z6.discriminatedUnion("kind", [
1952
+ z6.object({ kind: z6.literal("usage_capped"), resetsAt: z6.string().optional() }),
1953
+ z6.object({ kind: z6.literal("rate_limited") }),
1954
+ z6.object({ kind: z6.literal("server_error") }),
1955
+ z6.object({ kind: z6.literal("auth_expired") })
1939
1956
  ]);
1940
1957
  var USAGE_CAPPED = "usage_capped";
1941
1958
  var RATE_LIMITED = "rate_limited";
@@ -2006,7 +2023,8 @@ function classifyErrorText(text) {
2006
2023
  const t = text.toLowerCase();
2007
2024
  if (AUTH_PATTERNS.some((re) => re.test(t))) return { kind: "auth_expired" };
2008
2025
  if (SERVER_PATTERNS.some((re) => re.test(t))) return { kind: "server_error" };
2009
- const capNoun = CAP_PATTERNS.some((re) => re.test(t));
2026
+ const withoutNegatedCap = t.replace(NEGATED_CAP, "");
2027
+ const capNoun = CAP_PATTERNS.some((re) => re.test(withoutNegatedCap));
2010
2028
  const rateToken = RATE_PATTERNS.some((re) => re.test(t));
2011
2029
  if (capNoun) return { kind: "usage_capped" };
2012
2030
  if (rateToken) return { kind: "rate_limited" };
@@ -2035,105 +2053,120 @@ var SERVER_PATTERNS = [
2035
2053
  /fetch failed/
2036
2054
  ];
2037
2055
  var CAP_PATTERNS = [/usage limit/, /weekly limit/, /session limit/, /\bquota\b/];
2056
+ var NEGATED_CAP = /not (your|a) usage limit/g;
2038
2057
  var RATE_PATTERNS = [/rate[_ ]?limit/, /\b429\b/, /too many requests/];
2039
2058
  var BARE_LIMIT = /\blimit (reached|exceeded)\b/;
2040
2059
 
2041
2060
  // packages/agent-runtime/src/turn-request.ts
2042
- import { z as z8 } from "zod";
2043
- var contentBlockSchema = z8.discriminatedUnion("type", [
2044
- z8.object({ type: z8.literal("text"), text: z8.string() }),
2045
- z8.object({
2046
- type: z8.literal("image"),
2047
- source: z8.object({ type: z8.literal("url"), url: z8.string() })
2061
+ import { z as z7 } from "zod";
2062
+ var contentBlockSchema = z7.discriminatedUnion("type", [
2063
+ z7.object({ type: z7.literal("text"), text: z7.string() }),
2064
+ z7.object({
2065
+ type: z7.literal("image"),
2066
+ source: z7.object({ type: z7.literal("url"), url: z7.string() })
2048
2067
  }),
2049
- z8.object({
2050
- type: z8.literal("document"),
2051
- source: z8.object({ type: z8.literal("url"), url: z8.string() })
2068
+ z7.object({
2069
+ type: z7.literal("document"),
2070
+ source: z7.object({ type: z7.literal("url"), url: z7.string() })
2052
2071
  })
2053
2072
  ]);
2054
- var effortLevelSchema = z8.enum(["low", "medium", "high", "xhigh", "max"]);
2055
- var resolvedRunConfigSchema = z8.object({
2056
- model: z8.string().nullable(),
2073
+ var effortLevelSchema = z7.enum(["low", "medium", "high", "xhigh", "max"]);
2074
+ var resolvedRunConfigSchema = z7.object({
2075
+ model: z7.string().nullable(),
2057
2076
  effort: effortLevelSchema.optional(),
2058
- runtimeOptions: z8.record(z8.string(), z8.unknown()).optional()
2077
+ runtimeOptions: z7.record(z7.string(), z7.unknown()).optional()
2059
2078
  });
2060
- var resolvedMcpServerSchema = z8.union([
2061
- z8.object({
2062
- type: z8.literal("stdio").optional(),
2063
- command: z8.string(),
2064
- args: z8.array(z8.string()).optional(),
2065
- env: z8.record(z8.string(), z8.string()).optional()
2079
+ var resolvedMcpServerSchema = z7.union([
2080
+ z7.object({
2081
+ type: z7.literal("stdio").optional(),
2082
+ command: z7.string(),
2083
+ args: z7.array(z7.string()).optional(),
2084
+ env: z7.record(z7.string(), z7.string()).optional()
2066
2085
  }),
2067
- z8.object({
2068
- type: z8.literal("http"),
2069
- url: z8.string(),
2070
- headers: z8.record(z8.string(), z8.string()).optional()
2086
+ z7.object({
2087
+ type: z7.literal("http"),
2088
+ url: z7.string(),
2089
+ headers: z7.record(z7.string(), z7.string()).optional()
2071
2090
  }),
2072
- z8.object({
2073
- type: z8.literal("sse"),
2074
- url: z8.string(),
2075
- headers: z8.record(z8.string(), z8.string()).optional()
2091
+ z7.object({
2092
+ type: z7.literal("sse"),
2093
+ url: z7.string(),
2094
+ headers: z7.record(z7.string(), z7.string()).optional()
2076
2095
  })
2077
2096
  ]);
2078
- var resolvedMcpServersSchema = z8.record(z8.string(), resolvedMcpServerSchema);
2079
- var hostInjectedServersSchema = z8.record(z8.string(), z8.unknown());
2080
- var turnRequestSchema = z8.object({
2097
+ var resolvedMcpServersSchema = z7.record(z7.string(), resolvedMcpServerSchema);
2098
+ var hostInjectedServersSchema = z7.record(z7.string(), z7.unknown());
2099
+ var turnRequestSchema = z7.object({
2081
2100
  // Server-composed system prompt (core + capability prose + adapter addendum +
2082
2101
  // charter). One string to the adapter.
2083
- systemPrompt: z8.string(),
2102
+ systemPrompt: z7.string(),
2084
2103
  // Server-composed per-turn user text (anchor reminder + the triggering message).
2085
- prompt: z8.string(),
2104
+ prompt: z7.string(),
2086
2105
  // The multi-block user-message body (text + vision).
2087
- content: z8.array(contentBlockSchema),
2106
+ content: z7.array(contentBlockSchema),
2088
2107
  // Portable-or-dialect run-config (above).
2089
2108
  config: resolvedRunConfigSchema,
2090
2109
  // Abstract capability grants; the adapter maps them to tool names.
2091
2110
  policy: hostPolicySchema,
2092
2111
  // Prior opaque session state, or null for a fresh session.
2093
- session: z8.string().nullable(),
2112
+ session: z7.string().nullable(),
2094
2113
  // The cabane control-plane coordinates for this turn's MCP + post-back.
2095
- cabane: z8.object({
2096
- mcpUrl: z8.string(),
2097
- bearer: z8.string(),
2098
- activeConversationId: z8.string(),
2114
+ cabane: z7.object({
2115
+ mcpUrl: z7.string(),
2116
+ bearer: z7.string(),
2117
+ activeConversationId: z7.string(),
2118
+ // CT714: the scoped TURN-CONTROL MCP endpoint (`/api/turn-control`). The
2119
+ // EXTERNAL adapters (Codex / opencode) mount it by URL under the key
2120
+ // `cabane_companion` — using the same `bearer` (the turn token) and the same
2121
+ // active-conversation header they send to the `cabane` server — so their
2122
+ // agents get `ask`/`wake_me`/`summon_agent`/`sub_agent`/`skip_turn`, the
2123
+ // verbs they can't get from the companion's in-process SDK server. Optional:
2124
+ // claude-code ignores it (it mounts the in-process instance instead), and
2125
+ // every existing `cabane`-block fixture keeps parsing unchanged; the
2126
+ // companion always populates it (`build-options.ts`).
2127
+ turnControlUrl: z7.string().optional(),
2099
2128
  // CT598: the workspace this turn runs in. The claude-code/opencode/codex
2100
2129
  // adapters never need it (they reach Cabane through the `cabane` MCP server,
2101
2130
  // which takes `workspaceId` as a per-tool arg the model supplies); the
2102
2131
  // native runtime's interim tool surface calls the workspace-scoped REST API
2103
2132
  // DIRECTLY, so it needs the id host-side rather than trusting the model to
2104
2133
  // pass it. Optional so every existing `cabane`-block constructor (the three
2105
- // adapters' conformance fixtures, tests) keeps parsing unchanged — the bridge
2134
+ // adapters' conformance fixtures, tests) keeps parsing unchanged — the companion
2106
2135
  // always populates it (`build-options.ts`), and the native adapter fails the
2107
2136
  // turn loudly when it is somehow absent rather than guessing.
2108
- workspaceId: z8.string().optional()
2137
+ workspaceId: z7.string().optional(),
2138
+ // CT752: the server-resolved workspace surface this credential exposes.
2139
+ // Readiness uses this explicit fact to require `sdk` for code mode and the
2140
+ // granular floor for classic mode; inventory contents alone cannot infer it
2141
+ // because `sdk` is intentionally also available on the classic surface.
2142
+ workspaceToolSurface: z7.enum(["code", "classic"]).optional()
2109
2143
  }),
2110
2144
  // Machine-local resolution (host-filled): the checkout cwd, extra env from a
2111
2145
  // prepare hook, and the resolved user MCP servers.
2112
- local: z8.object({
2113
- cwd: z8.string().optional(),
2114
- env: z8.record(z8.string(), z8.string()).optional(),
2146
+ local: z7.object({
2147
+ cwd: z7.string().optional(),
2148
+ env: z7.record(z7.string(), z7.string()).optional(),
2115
2149
  mcpServers: resolvedMcpServersSchema.optional(),
2116
2150
  // CT289: machine-local claude-code adapter knobs the operator sets on a
2117
- // bridge they run themselves — the auto-memory escape hatch. `autoMemory:
2151
+ // companion they run themselves — the auto-memory escape hatch. `autoMemory:
2118
2152
  // true` opts back into Claude Code's auto-memory (governed by the operator's
2119
2153
  // own `.claude/settings.json`); absent/false leaves the adapter's force-off
2120
- // default in place (see `buildClaudeCodeOptions`). The house device never
2121
- // sets it, so house stays force-off unconditionally.
2122
- claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
2154
+ // default in place (see `buildClaudeCodeOptions`).
2155
+ claudeCode: z7.object({ autoMemory: z7.boolean().optional() }).optional()
2123
2156
  }),
2124
2157
  // Host-owned injected servers (host-filled) — e.g. the summon server.
2125
- extra: z8.object({
2158
+ extra: z7.object({
2126
2159
  mcpServers: hostInjectedServersSchema
2127
2160
  })
2128
2161
  });
2129
2162
 
2130
2163
  // packages/agent-runtime/src/conformance.ts
2131
- import { z as z9 } from "zod";
2132
- var conformanceFixtureSchema = z9.object({
2133
- name: z9.string(),
2164
+ import { z as z8 } from "zod";
2165
+ var conformanceFixtureSchema = z8.object({
2166
+ name: z8.string(),
2134
2167
  request: turnRequestSchema,
2135
- nativeStream: z9.array(z9.unknown()),
2136
- expected: z9.array(turnEventSchema)
2168
+ nativeStream: z8.array(z8.unknown()),
2169
+ expected: z8.array(turnEventSchema)
2137
2170
  });
2138
2171
 
2139
2172
  // packages/agent-runtime/src/transcript.ts
@@ -2438,7 +2471,7 @@ import {
2438
2471
  var CLAUDE_CODE_ADDENDUM = "";
2439
2472
 
2440
2473
  // packages/agent-runtime/src/claude-code/policy.ts
2441
- import { z as z10 } from "zod";
2474
+ import { z as z9 } from "zod";
2442
2475
  var HOST_FS_TOOLS = [
2443
2476
  // shell + local filesystem
2444
2477
  "Bash",
@@ -2488,30 +2521,20 @@ function withThinkingSummaries(thinking) {
2488
2521
  if (thinking.type === "disabled") return thinking;
2489
2522
  return { display: "summarized", ...thinking };
2490
2523
  }
2491
- var claudeCodeDialectSchema = z10.object({
2492
- thinking: z10.discriminatedUnion("type", [
2493
- z10.object({
2494
- type: z10.literal("adaptive"),
2495
- display: z10.enum(["summarized", "omitted"]).optional()
2524
+ var claudeCodeDialectSchema = z9.object({
2525
+ thinking: z9.discriminatedUnion("type", [
2526
+ z9.object({
2527
+ type: z9.literal("adaptive"),
2528
+ display: z9.enum(["summarized", "omitted"]).optional()
2496
2529
  }),
2497
- z10.object({
2498
- type: z10.literal("enabled"),
2499
- budgetTokens: z10.number().int().positive().optional(),
2500
- display: z10.enum(["summarized", "omitted"]).optional()
2530
+ z9.object({
2531
+ type: z9.literal("enabled"),
2532
+ budgetTokens: z9.number().int().positive().optional(),
2533
+ display: z9.enum(["summarized", "omitted"]).optional()
2501
2534
  }),
2502
- z10.object({ type: z10.literal("disabled") })
2535
+ z9.object({ type: z9.literal("disabled") })
2503
2536
  ]).optional(),
2504
- allowedTools: z10.array(z10.string()).optional(),
2505
- disallowedTools: z10.array(z10.string()).optional(),
2506
- // Which claude-code harness shape to run. `coding` switches to the
2507
- // `claude_code` preset + project settings + always-allow `canUseTool`;
2508
- // `custom`/`assistant` (or absent) use a plain-string prompt + bypass. This
2509
- // is the claude-code-specific PRESET selector — kept distinct from
2510
- // `policy.hostFs` (the host-fs BLOCK), because bridge `custom` mode wants host
2511
- // fs available (via its own allowlist) WITHOUT the coding harness, and in-app
2512
- // `custom` wants host fs blocked — neither of which a single `hostFs` boolean
2513
- // can express alongside the preset choice.
2514
- mode: z10.enum(["assistant", "coding", "custom"]).optional()
2537
+ hostAccess: z9.boolean().optional()
2515
2538
  }).loose();
2516
2539
  function readThinking(runtimeOptions) {
2517
2540
  const dialect = runtimeOptions?.["claude-code"];
@@ -2555,10 +2578,18 @@ function decideResume(stored, currentCwd) {
2555
2578
  // packages/agent-runtime/src/claude-code/options.ts
2556
2579
  var CABANE_MCP_SERVER = "cabane";
2557
2580
  var ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
2558
- var allowEverything = async (_toolName, input) => ({
2559
- behavior: "allow",
2560
- updatedInput: input
2561
- });
2581
+ async function allowEverythingHook(input) {
2582
+ const toolInput = "tool_input" in input && input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
2583
+ return {
2584
+ continue: true,
2585
+ hookSpecificOutput: {
2586
+ hookEventName: "PreToolUse",
2587
+ permissionDecision: "allow",
2588
+ permissionDecisionReason: "coding mode: headless never-prompt (CT680)",
2589
+ updatedInput: toolInput
2590
+ }
2591
+ };
2592
+ }
2562
2593
  function buildClaudeCodeOptions(req, augment) {
2563
2594
  const { policy, config } = req;
2564
2595
  const cwd = req.local.cwd;
@@ -2582,18 +2613,15 @@ function buildClaudeCodeOptions(req, augment) {
2582
2613
  };
2583
2614
  }
2584
2615
  const dialect = claudeCodeDialectSchema.safeParse(config.runtimeOptions?.["claude-code"] ?? {});
2585
- const customAllowed = dialect.success ? dialect.data.allowedTools ?? [] : [];
2586
- const customDisallowed = dialect.success ? dialect.data.disallowedTools ?? [] : [];
2587
- const useCodingPreset = (dialect.success ? dialect.data.mode : void 0) === "coding";
2616
+ const useCodingPreset = dialect.success ? dialect.data.hostAccess ?? false : false;
2588
2617
  const cabaneGlob = `mcp__${CABANE_MCP_SERVER}__*`;
2589
2618
  const extraServerGlobs = Object.keys(req.extra.mcpServers).map((name) => `mcp__${name}__*`);
2590
2619
  const allowedTools = dedupe([
2591
2620
  cabaneGlob,
2592
2621
  ...extraServerGlobs,
2593
- ...policy.web ? DEFAULT_WEB_TOOLS : [],
2594
- ...customAllowed
2622
+ ...policy.web ? DEFAULT_WEB_TOOLS : []
2595
2623
  ]);
2596
- const disallowedTools = dedupe([...disallowedToolsFor(policy), ...customDisallowed]);
2624
+ const disallowedTools = dedupe([...disallowedToolsFor(policy)]);
2597
2625
  const resumeDecision = decideResume(req.session, cwd);
2598
2626
  const resume = "resume" in resumeDecision ? resumeDecision.resume : null;
2599
2627
  const freshReason = "fresh" in resumeDecision ? resumeDecision.reason : void 0;
@@ -2619,7 +2647,7 @@ function buildClaudeCodeOptions(req, augment) {
2619
2647
  ...devControlsAutoMemory ? {} : { settings: { autoMemoryEnabled: false } },
2620
2648
  mcpServers,
2621
2649
  ...cwd ? { cwd } : {},
2622
- // Extra env (a bridge prepare hook's tokens/ports; the in-app's debug flags)
2650
+ // Extra env (a companion prepare hook's tokens/ports; the in-app's debug flags)
2623
2651
  // merged OVER the inherited environment.
2624
2652
  ...req.local.env ? { env: { ...process.env, ...req.local.env } } : {},
2625
2653
  ...resume ? { resume } : {}
@@ -2632,7 +2660,7 @@ function buildClaudeCodeOptions(req, augment) {
2632
2660
  settingSources: ["project"],
2633
2661
  allowedTools,
2634
2662
  disallowedTools,
2635
- canUseTool: allowEverything
2663
+ hooks: { PreToolUse: [{ hooks: [allowEverythingHook] }] }
2636
2664
  };
2637
2665
  } else {
2638
2666
  options = {
@@ -2663,9 +2691,11 @@ async function* decodeSdkStream(iter, ctx) {
2663
2691
  let resultReason;
2664
2692
  let sawResult = false;
2665
2693
  let usage;
2694
+ let lastRequestContextTokens;
2666
2695
  let resolvedModel;
2667
2696
  let sawRejectedLimit = false;
2668
2697
  let rateLimitResetIso;
2698
+ let rateLimitType;
2669
2699
  let authError;
2670
2700
  let lastAssistantError;
2671
2701
  try {
@@ -2688,6 +2718,8 @@ async function* decodeSdkStream(iter, ctx) {
2688
2718
  if (typeof assistantErr === "string" && assistantErr.length > 0) {
2689
2719
  lastAssistantError = assistantErr;
2690
2720
  }
2721
+ const reqContext = readRequestContextTokens(msg);
2722
+ if (reqContext !== void 0) lastRequestContextTokens = reqContext;
2691
2723
  await processAssistantMessage(msg, emit, pending, buffer);
2692
2724
  yield* drain(out);
2693
2725
  } else if (msg.type === "user") {
@@ -2698,6 +2730,7 @@ async function* decodeSdkStream(iter, ctx) {
2698
2730
  if (info?.status === "rejected") {
2699
2731
  sawRejectedLimit = true;
2700
2732
  rateLimitResetIso = resetsAtToIso(info.resetsAt) ?? rateLimitResetIso;
2733
+ if (typeof info.rateLimitType === "string") rateLimitType = info.rateLimitType;
2701
2734
  }
2702
2735
  } else if (msg.type === "auth_status") {
2703
2736
  const err = msg.error;
@@ -2705,6 +2738,12 @@ async function* decodeSdkStream(iter, ctx) {
2705
2738
  } else if (msg.type === "result") {
2706
2739
  sawResult = true;
2707
2740
  usage = readSdkUsage(msg);
2741
+ if (usage) {
2742
+ if (lastRequestContextTokens !== void 0)
2743
+ usage.contextTokens = lastRequestContextTokens;
2744
+ const window = readContextWindow(msg, resolvedModel);
2745
+ if (window !== void 0) usage.contextWindow = window;
2746
+ }
2708
2747
  const isError = msg.is_error === true;
2709
2748
  if (msg.subtype === "success" && !isError) {
2710
2749
  ok = true;
@@ -2715,7 +2754,8 @@ async function* decodeSdkStream(iter, ctx) {
2715
2754
  const errorText = [resultText, ...Array.isArray(errors) ? errors.map(String) : []].join(
2716
2755
  " "
2717
2756
  );
2718
- const failure = sawRejectedLimit || terminalReason === "blocking_limit" ? {
2757
+ const rejectedIsCap = sawRejectedLimit && (rateLimitResetIso !== void 0 || isSubscriptionWindow(rateLimitType));
2758
+ const failure = rejectedIsCap || terminalReason === "blocking_limit" ? {
2719
2759
  kind: "usage_capped",
2720
2760
  ...rateLimitResetIso ? { resetsAt: rateLimitResetIso } : {}
2721
2761
  } : classifyAssistantError(lastAssistantError) ?? classifyErrorText([authError, errorText].filter(Boolean).join(" "));
@@ -2745,6 +2785,9 @@ async function* decodeSdkStream(iter, ctx) {
2745
2785
  ...resolvedModel ? { resolvedModel } : {}
2746
2786
  };
2747
2787
  }
2788
+ function isSubscriptionWindow(value) {
2789
+ return value === "five_hour" || value === "seven_day" || value === "seven_day_opus" || value === "seven_day_sonnet" || value === "overage";
2790
+ }
2748
2791
  function* drain(out) {
2749
2792
  while (out.length > 0) yield out.shift();
2750
2793
  }
@@ -2758,6 +2801,27 @@ function readSdkUsage(msg) {
2758
2801
  const outputTokens = num(usage.output_tokens);
2759
2802
  return { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens };
2760
2803
  }
2804
+ function readRequestContextTokens(msg) {
2805
+ const usage = msg.message?.usage;
2806
+ if (!usage || typeof usage !== "object") return void 0;
2807
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
2808
+ return num(usage.input_tokens) + num(usage.cache_read_input_tokens) + num(usage.cache_creation_input_tokens);
2809
+ }
2810
+ function readContextWindow(msg, resolvedModel) {
2811
+ const modelUsage = msg.modelUsage;
2812
+ if (!modelUsage || typeof modelUsage !== "object") return void 0;
2813
+ const pos = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
2814
+ if (resolvedModel) {
2815
+ const direct = pos(modelUsage[resolvedModel]?.contextWindow);
2816
+ if (direct !== void 0) return direct;
2817
+ }
2818
+ let max;
2819
+ for (const entry of Object.values(modelUsage)) {
2820
+ const w = pos(entry?.contextWindow);
2821
+ if (w !== void 0 && (max === void 0 || w > max)) max = w;
2822
+ }
2823
+ return max;
2824
+ }
2761
2825
 
2762
2826
  // packages/agent-runtime/src/claude-code/prompt-input.ts
2763
2827
  function buildQueryPrompt(req) {
@@ -2875,9 +2939,13 @@ var resultErrorFull = (subtype, extra = {}) => ({
2875
2939
  session_id: "s",
2876
2940
  ...extra
2877
2941
  });
2878
- var rateLimitEvent = (status, resetsAt) => ({
2942
+ var rateLimitEvent = (status, resetsAt, rateLimitType) => ({
2879
2943
  type: "rate_limit_event",
2880
- rate_limit_info: { status, ...resetsAt !== void 0 ? { resetsAt } : {} },
2944
+ rate_limit_info: {
2945
+ status,
2946
+ ...resetsAt !== void 0 ? { resetsAt } : {},
2947
+ ...rateLimitType !== void 0 ? { rateLimitType } : {}
2948
+ },
2881
2949
  session_id: "s"
2882
2950
  });
2883
2951
  var authStatus = (error) => ({
@@ -3041,17 +3109,16 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
3041
3109
  ]
3042
3110
  },
3043
3111
  {
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.
3112
+ // CT558/CT592/CT731: a subscription cap. The SDK emits a rejected
3113
+ // `rate_limit_event` with a named subscription window; the terminal error result
3114
+ // then classifies as the structured `usage_capped` reason. Partial narration
3115
+ // lands as `progress`. The cap event is distinct from a provider 429 throttle.
3049
3116
  name: "subscription cap \u2192 usage_capped",
3050
3117
  request: makeRequest(),
3051
3118
  nativeStream: [
3052
3119
  init("s1"),
3053
3120
  assistantText("Let me work on that."),
3054
- rateLimitEvent("rejected"),
3121
+ rateLimitEvent("rejected", void 0, "five_hour"),
3055
3122
  resultError("error_during_execution")
3056
3123
  ],
3057
3124
  expected: [
@@ -3414,7 +3481,7 @@ function sealHeld(held, terminal) {
3414
3481
  }
3415
3482
 
3416
3483
  // packages/agent-runtime/src/opencode/policy.ts
3417
- import { z as z11 } from "zod";
3484
+ import { z as z10 } from "zod";
3418
3485
  var OPENCODE_HOST_TOOLS = [
3419
3486
  "bash",
3420
3487
  "edit",
@@ -3441,8 +3508,8 @@ function opencodeToolPolicy(policy) {
3441
3508
  deny(OPENCODE_UI_PROMPT_TOOLS);
3442
3509
  return { tools, allowAllHostTools: policy.hostFs };
3443
3510
  }
3444
- var opencodeDialectSchema = z11.object({
3445
- agent: z11.string().min(1).optional()
3511
+ var opencodeDialectSchema = z10.object({
3512
+ agent: z10.string().min(1).optional()
3446
3513
  }).loose();
3447
3514
  function readOpencodeDialect(runtimeOptions) {
3448
3515
  const parsed = opencodeDialectSchema.safeParse(runtimeOptions?.["opencode"] ?? {});
@@ -3458,6 +3525,7 @@ function parseOpencodeModel(model) {
3458
3525
 
3459
3526
  // packages/agent-runtime/src/opencode/run-spec.ts
3460
3527
  var CABANE_MCP_SERVER2 = "cabane";
3528
+ var TURN_CONTROL_MCP_SERVER = "cabane_companion";
3461
3529
  var ACTIVE_CONVERSATION_HEADER3 = "x-cabane-active-conversation";
3462
3530
  function buildRunSpec(req, resumeSessionId) {
3463
3531
  const { policy, config } = req;
@@ -3519,6 +3587,17 @@ function buildMcp(req) {
3519
3587
  },
3520
3588
  enabled: true
3521
3589
  };
3590
+ if (req.cabane.turnControlUrl) {
3591
+ mcp[TURN_CONTROL_MCP_SERVER] = {
3592
+ type: "remote",
3593
+ url: req.cabane.turnControlUrl,
3594
+ headers: {
3595
+ Authorization: `Bearer ${req.cabane.bearer}`,
3596
+ [ACTIVE_CONVERSATION_HEADER3]: req.cabane.activeConversationId
3597
+ },
3598
+ enabled: true
3599
+ };
3600
+ }
3522
3601
  for (const [name, raw] of Object.entries(req.extra.mcpServers)) {
3523
3602
  const server = raw;
3524
3603
  if (typeof server.url === "string") {
@@ -3784,7 +3863,7 @@ var opencodeAdapter = createOpencodeAdapter();
3784
3863
  // packages/agent-runtime/src/opencode/conformance.ts
3785
3864
  var ABORT_SENTINEL2 = { __abortHere: true };
3786
3865
  var NEW_SESSION_ID = "sess_new";
3787
- var BRIDGE_POLICY = {
3866
+ var COMPANION_POLICY = {
3788
3867
  hostFs: false,
3789
3868
  web: true,
3790
3869
  browser: true,
@@ -3800,7 +3879,7 @@ function makeRequest2(overrides = {}) {
3800
3879
  prompt: "hi there",
3801
3880
  content: [{ type: "text", text: "hi there" }],
3802
3881
  config: { model: "deepseek/deepseek-chat" },
3803
- policy: BRIDGE_POLICY,
3882
+ policy: COMPANION_POLICY,
3804
3883
  session: null,
3805
3884
  cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
3806
3885
  local: { cwd: DIR },
@@ -4148,13 +4227,19 @@ var CODEX_ADDENDUM = [
4148
4227
  "as the turn\u2019s reply."
4149
4228
  ].join(" ");
4150
4229
  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",
4230
+ "Your Cabane workspace tool is exposed to Codex as the qualified MCP tool",
4231
+ "`mcp__cabane__sdk`. On Codex versions that defer MCP tools, locate it in the",
4232
+ "`functions.exec` deferred-tool inventory and invoke that exact qualified tool",
4233
+ "from the exec program; do not look for or call a bare top-level `sdk` tool.",
4234
+ "If discovery or an invocation fails, report the recorded tool error; never",
4235
+ "declare the SDK absent without attempting discovery and invocation. The SDK",
4236
+ "call runs a TypeScript program against the ambient `cabane` object. The",
4153
4237
  "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",
4238
+ "qualified `mcp__cabane_companion__\u2026` tools (and may be deferred too). There is",
4239
+ "no Cabane `read`/`write`/`search`/`edit` tool here \u2014 those",
4155
4240
  "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",
4241
+ "prompt with an `mcp__\u2026__` prefix, preserve that qualified name. Write your",
4242
+ "closing reply as the last thing you say in the",
4158
4243
  "turn: you can interleave narration with tool calls, but only your final message",
4159
4244
  "is recorded as the turn\u2019s reply."
4160
4245
  ].join(" ");
@@ -4181,7 +4266,7 @@ function readItemMessage(item) {
4181
4266
  function isModelMetadataError(message) {
4182
4267
  return message.includes("Defaulting to fallback metadata");
4183
4268
  }
4184
- function readToolItem(item) {
4269
+ function readToolItem(item, eventType) {
4185
4270
  const type = str(item.type);
4186
4271
  const id = str(item.id);
4187
4272
  if (!type || !id) return null;
@@ -4225,7 +4310,12 @@ function readToolItem(item) {
4225
4310
  }
4226
4311
  if (type === "web_search") {
4227
4312
  const query = str(item.query) ?? "";
4228
- return { id, name: "web_search", input: { query }, status: toStatus(item.status) };
4313
+ return {
4314
+ id,
4315
+ name: "web_search",
4316
+ input: { query },
4317
+ status: eventType === "item.completed" ? "completed" : "in_progress"
4318
+ };
4229
4319
  }
4230
4320
  return null;
4231
4321
  }
@@ -4343,7 +4433,7 @@ async function* decodeCodexStream(events, ctx) {
4343
4433
  if (text) yield { type: "thinking", text };
4344
4434
  continue;
4345
4435
  }
4346
- const tool2 = readToolItem(item);
4436
+ const tool2 = readToolItem(item, ev.type);
4347
4437
  if (!tool2) continue;
4348
4438
  yield* flushInterim();
4349
4439
  const name = prettyToolName(tool2.name);
@@ -4421,20 +4511,23 @@ function sealHeld2(held, terminal) {
4421
4511
  }
4422
4512
 
4423
4513
  // packages/agent-runtime/src/codex/policy.ts
4424
- import { z as z12 } from "zod";
4514
+ import { z as z11 } from "zod";
4425
4515
  function codexToolPolicy(policy) {
4426
- return {
4427
- sandboxMode: policy.hostFs ? "workspace-write" : "read-only",
4516
+ return policy.hostFs ? {
4517
+ permissionProfile: "cabane-coding",
4518
+ approvalPolicy: "never",
4519
+ networkAccessEnabled: policy.web
4520
+ } : {
4521
+ sandboxMode: "read-only",
4428
4522
  // Headless: the sandbox is the boundary; never pause for a human.
4429
4523
  approvalPolicy: "never",
4430
- // Only consulted under workspace-write; the public-web grant governs whether
4431
- // Codex's shell commands may reach the network.
4524
+ // Retained in the policy value for symmetry; read-only ignores it.
4432
4525
  networkAccessEnabled: policy.web
4433
4526
  };
4434
4527
  }
4435
4528
  var CODEX_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"];
4436
- var codexDialectSchema = z12.object({
4437
- modelReasoningEffort: z12.enum(CODEX_REASONING_EFFORTS).optional()
4529
+ var codexDialectSchema = z11.object({
4530
+ modelReasoningEffort: z11.enum(CODEX_REASONING_EFFORTS).optional()
4438
4531
  }).loose();
4439
4532
  function readCodexDialect(runtimeOptions) {
4440
4533
  const parsed = codexDialectSchema.safeParse(runtimeOptions?.["codex"] ?? {});
@@ -4442,7 +4535,7 @@ function readCodexDialect(runtimeOptions) {
4442
4535
  }
4443
4536
 
4444
4537
  // packages/agent-runtime/src/codex/model.ts
4445
- var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default", "codex"]);
4538
+ var CODEX_DEFAULT_MODEL_BARES = /* @__PURE__ */ new Set(["default"]);
4446
4539
  function parseCodexModel(model) {
4447
4540
  const sep = model.indexOf("/");
4448
4541
  const bare = sep === -1 || model.slice(0, sep) !== "openai" ? model : model.slice(sep + 1);
@@ -4451,6 +4544,7 @@ function parseCodexModel(model) {
4451
4544
 
4452
4545
  // packages/agent-runtime/src/codex/run-spec.ts
4453
4546
  var CABANE_MCP_SERVER3 = "cabane";
4547
+ var TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
4454
4548
  var ACTIVE_CONVERSATION_HEADER4 = "x-cabane-active-conversation";
4455
4549
  function buildRunSpec2(req, resumeThreadId) {
4456
4550
  const { policy, config } = req;
@@ -4481,13 +4575,15 @@ function buildConfig(req) {
4481
4575
  if ("url" in server) {
4482
4576
  mcp_servers[name] = {
4483
4577
  url: server.url,
4484
- ...server.headers ? { http_headers: server.headers } : {}
4578
+ ...server.headers ? { http_headers: server.headers } : {},
4579
+ default_tools_approval_mode: "approve"
4485
4580
  };
4486
4581
  } else if ("command" in server) {
4487
4582
  mcp_servers[name] = {
4488
4583
  command: server.command,
4489
4584
  ...server.args ? { args: server.args } : {},
4490
- ...server.env ? { env: server.env } : {}
4585
+ ...server.env ? { env: server.env } : {},
4586
+ default_tools_approval_mode: "approve"
4491
4587
  };
4492
4588
  }
4493
4589
  }
@@ -4496,12 +4592,14 @@ function buildConfig(req) {
4496
4592
  if (typeof server.url === "string") {
4497
4593
  mcp_servers[name] = {
4498
4594
  url: server.url,
4499
- ...isStringRecord2(server.headers) ? { http_headers: server.headers } : {}
4595
+ ...isStringRecord2(server.headers) ? { http_headers: server.headers } : {},
4596
+ default_tools_approval_mode: "approve"
4500
4597
  };
4501
4598
  } else if (typeof server.command === "string") {
4502
4599
  mcp_servers[name] = {
4503
4600
  command: server.command,
4504
- ...Array.isArray(server.args) ? { args: server.args } : {}
4601
+ ...Array.isArray(server.args) ? { args: server.args } : {},
4602
+ default_tools_approval_mode: "approve"
4505
4603
  };
4506
4604
  }
4507
4605
  }
@@ -4513,13 +4611,48 @@ function buildConfig(req) {
4513
4611
  },
4514
4612
  default_tools_approval_mode: "approve"
4515
4613
  };
4516
- return { mcp_servers, experimental_use_rmcp_client: true };
4614
+ if (req.cabane.turnControlUrl) {
4615
+ mcp_servers[TURN_CONTROL_MCP_SERVER2] = {
4616
+ url: req.cabane.turnControlUrl,
4617
+ http_headers: {
4618
+ Authorization: `Bearer ${req.cabane.bearer}`,
4619
+ [ACTIVE_CONVERSATION_HEADER4]: req.cabane.activeConversationId
4620
+ },
4621
+ default_tools_approval_mode: "approve"
4622
+ };
4623
+ }
4624
+ const policy = codexToolPolicy(req.policy);
4625
+ const tmpDir = req.local.env?.TMPDIR;
4626
+ return {
4627
+ mcp_servers,
4628
+ experimental_use_rmcp_client: true,
4629
+ ...tmpDir ? { shell_environment_policy: { set: { TMPDIR: tmpDir } } } : {},
4630
+ ...policy.permissionProfile ? {
4631
+ // CT733: named permission profiles are Codex's split-filesystem path.
4632
+ // `:root = read` preserves coding-mode host reads; the one explicit
4633
+ // workspace-root write grants the checkout, and the more-specific
4634
+ // `.git` write reopens the metadata Codex protects by default. Neither
4635
+ // rule grants an adjacent directory. Do not combine this with legacy `sandbox_mode` /
4636
+ // `sandbox_workspace_write`, which would restore the `.git` carve-out.
4637
+ approval_policy: policy.approvalPolicy,
4638
+ default_permissions: policy.permissionProfile,
4639
+ permissions: {
4640
+ [policy.permissionProfile]: {
4641
+ filesystem: {
4642
+ ":root": "read",
4643
+ ":workspace_roots": { ".": "write", ".git": "write" }
4644
+ },
4645
+ network: { enabled: policy.networkAccessEnabled, mode: "full" }
4646
+ }
4647
+ }
4648
+ } : {}
4649
+ };
4517
4650
  }
4518
4651
  function isStringRecord2(v) {
4519
4652
  return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
4520
4653
  }
4521
4654
 
4522
- // node_modules/.pnpm/@openai+codex-sdk@0.144.6/node_modules/@openai/codex-sdk/dist/index.js
4655
+ // node_modules/.pnpm/@openai+codex-sdk@0.146.0/node_modules/@openai/codex-sdk/dist/index.js
4523
4656
  import { promises as fs } from "fs";
4524
4657
  import os from "os";
4525
4658
  import path from "path";
@@ -4608,6 +4741,8 @@ var Thread = class {
4608
4741
  }
4609
4742
  if (parsed.type === "thread.started") {
4610
4743
  this._id = parsed.thread_id;
4744
+ } else if (parsed.type === "turn.completed") {
4745
+ parsed.usage.cache_write_input_tokens ??= 0;
4611
4746
  }
4612
4747
  yield parsed;
4613
4748
  }
@@ -5047,6 +5182,17 @@ var Codex = class {
5047
5182
  };
5048
5183
 
5049
5184
  // packages/agent-runtime/src/codex/transport.ts
5185
+ function buildSdkThreadOptions(spec) {
5186
+ return {
5187
+ ...spec.model ? { model: spec.model } : {},
5188
+ ...spec.policy.sandboxMode ? { sandboxMode: spec.policy.sandboxMode } : {},
5189
+ workingDirectory: spec.directory,
5190
+ skipGitRepoCheck: spec.skipGitRepoCheck,
5191
+ ...spec.policy.sandboxMode ? { approvalPolicy: spec.policy.approvalPolicy } : {},
5192
+ ...spec.policy.sandboxMode === "workspace-write" ? { networkAccessEnabled: spec.policy.networkAccessEnabled } : {},
5193
+ ...spec.modelReasoningEffort ? { modelReasoningEffort: spec.modelReasoningEffort } : {}
5194
+ };
5195
+ }
5050
5196
  function createSdkCodexTransport(opts = {}) {
5051
5197
  return {
5052
5198
  async run(spec, signal) {
@@ -5060,17 +5206,7 @@ function createSdkCodexTransport(opts = {}) {
5060
5206
  config: spec.config
5061
5207
  };
5062
5208
  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
- };
5209
+ const threadOptions = buildSdkThreadOptions(spec);
5074
5210
  const thread = spec.resumeThreadId ? codex.resumeThread(spec.resumeThreadId, threadOptions) : codex.startThread(threadOptions);
5075
5211
  const streamed = await thread.runStreamed(spec.input, { signal });
5076
5212
  return { events: streamed.events };
@@ -5127,7 +5263,7 @@ var codexAdapter = createCodexAdapter();
5127
5263
  // packages/agent-runtime/src/codex/conformance.ts
5128
5264
  var ABORT_SENTINEL3 = { __abortHere: true };
5129
5265
  var NEW_THREAD_ID = "th_new";
5130
- var BRIDGE_POLICY2 = {
5266
+ var COMPANION_POLICY2 = {
5131
5267
  hostFs: false,
5132
5268
  web: true,
5133
5269
  browser: true,
@@ -5148,7 +5284,7 @@ function makeRequest3(overrides = {}) {
5148
5284
  // emitted → their expected results stay unchanged; a dedicated capture fixture
5149
5285
  // sets a real model + effort.
5150
5286
  config: { model: null },
5151
- policy: BRIDGE_POLICY2,
5287
+ policy: COMPANION_POLICY2,
5152
5288
  session: null,
5153
5289
  cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
5154
5290
  local: { cwd: DIR2 },
@@ -5312,6 +5448,75 @@ var CODEX_CONFORMANCE_FIXTURES = [
5312
5448
  { type: "result", ok: true }
5313
5449
  ]
5314
5450
  },
5451
+ {
5452
+ // CT715: web_search LIFECYCLE. Unlike the other three tool kinds, a `web_search`
5453
+ // item carries NO `status` field — completion is signaled by the frame TYPE
5454
+ // (`item.started` → `item.completed`). The `start` fires off the first frame
5455
+ // (empty query, no output card); the `done` must resolve off `item.completed`
5456
+ // and carry the populated query the completed frame filled in. Before CT715 the
5457
+ // card was pinned at "running" forever (status derived from a missing field).
5458
+ name: "web_search lifecycle \u2014 completes off frame type, populated query on done",
5459
+ request: makeRequest3(),
5460
+ nativeStream: [
5461
+ threadStarted(NEW_THREAD_ID),
5462
+ toolFrame("item.started", { id: "ws1", type: "web_search", query: "" }),
5463
+ toolFrame("item.completed", { id: "ws1", type: "web_search", query: "best pizza in nyc" }),
5464
+ turnCompleted()
5465
+ ],
5466
+ expected: [
5467
+ sessionEvent3(NEW_THREAD_ID),
5468
+ {
5469
+ type: "tool",
5470
+ id: "ws1",
5471
+ name: "web_search",
5472
+ phase: "start",
5473
+ summary: "",
5474
+ input: { query: "" }
5475
+ },
5476
+ {
5477
+ type: "tool",
5478
+ id: "ws1",
5479
+ name: "web_search",
5480
+ phase: "done",
5481
+ summary: "best pizza in nyc",
5482
+ input: { query: "best pizza in nyc" }
5483
+ },
5484
+ { type: "result", ok: true }
5485
+ ]
5486
+ },
5487
+ {
5488
+ // CT715: a non-text web action (`action.type: "other"`) legitimately completes
5489
+ // with an EMPTY query — that's Codex's own data, not our bug. It must still
5490
+ // resolve to `done` (empty query acceptable; stuck-running is not).
5491
+ name: "web_search lifecycle \u2014 empty-query completion still resolves to done",
5492
+ request: makeRequest3(),
5493
+ nativeStream: [
5494
+ threadStarted(NEW_THREAD_ID),
5495
+ toolFrame("item.started", { id: "ws2", type: "web_search", query: "" }),
5496
+ toolFrame("item.completed", { id: "ws2", type: "web_search", query: "" }),
5497
+ turnCompleted()
5498
+ ],
5499
+ expected: [
5500
+ sessionEvent3(NEW_THREAD_ID),
5501
+ {
5502
+ type: "tool",
5503
+ id: "ws2",
5504
+ name: "web_search",
5505
+ phase: "start",
5506
+ summary: "",
5507
+ input: { query: "" }
5508
+ },
5509
+ {
5510
+ type: "tool",
5511
+ id: "ws2",
5512
+ name: "web_search",
5513
+ phase: "done",
5514
+ summary: "",
5515
+ input: { query: "" }
5516
+ },
5517
+ { type: "result", ok: true }
5518
+ ]
5519
+ },
5315
5520
  {
5316
5521
  // HIGHEST-RISK: cancel mid-stream. The abort lands after the tool result; the
5317
5522
  // adapter stops before the closing reply — no final text, no `result` event.
@@ -5603,560 +5808,6 @@ var CODEX_CONFORMANCE_FIXTURES = [
5603
5808
  }
5604
5809
  ];
5605
5810
 
5606
- // packages/agent-runtime/src/cabane-native/addendum.ts
5607
- var CABANE_NATIVE_ADDENDUM = `## Your tools (native runtime)
5608
-
5609
- You are running on Cabane's own agent runtime. You have a small, fixed set of workspace tools, all prefixed \`cabane_\`:
5610
-
5611
- - \`cabane_list\` \u2014 list a folder's files and subfolders.
5612
- - \`cabane_read\` \u2014 read one file's contents by path.
5613
- - \`cabane_search\` \u2014 substring search across file names and contents.
5614
- - \`cabane_write\` \u2014 create or overwrite a file (pass \`overwrite: true\` to replace).
5615
- - \`cabane_edit\` \u2014 find/replace inside an existing file.
5616
-
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.`;
5618
-
5619
- // packages/agent-runtime/src/cabane-native/context.ts
5620
- var DEFAULT_HISTORY_LIMIT = 20;
5621
- var DEFAULT_MAX_HISTORY_CHARS = 24e3;
5622
- async function assembleMessages(systemPrompt, content, fallbackPrompt, opts) {
5623
- const messages = [{ role: "system", content: systemPrompt }];
5624
- const history = await fetchRecentHistory(opts);
5625
- for (const m of history) messages.push(m);
5626
- messages.push({ role: "user", content: currentUserText(content, fallbackPrompt) });
5627
- return messages;
5628
- }
5629
- function currentUserText(content, fallbackPrompt) {
5630
- const text = content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
5631
- return text.length > 0 ? text : fallbackPrompt;
5632
- }
5633
- async function fetchRecentHistory(opts) {
5634
- const doFetch = opts.fetchImpl ?? fetch;
5635
- const limit = opts.historyLimit ?? DEFAULT_HISTORY_LIMIT;
5636
- const url = new URL(
5637
- `${opts.apiRoot}/workspaces/${opts.workspaceId}/conversations/${opts.conversationId}/messages`
5638
- );
5639
- url.searchParams.set("limit", String(limit));
5640
- url.searchParams.set("order", "desc");
5641
- let rows;
5642
- try {
5643
- const res = await doFetch(url.toString(), {
5644
- headers: { Authorization: `Bearer ${opts.bearer}` }
5645
- });
5646
- if (!res.ok) return [];
5647
- const body = await res.json();
5648
- rows = body.messages ?? [];
5649
- } catch {
5650
- return [];
5651
- }
5652
- const chronological = [...rows].reverse();
5653
- while (chronological.length > 0 && chronological[chronological.length - 1].role === "user") {
5654
- chronological.pop();
5655
- }
5656
- const mapped = [];
5657
- for (const r of chronological) {
5658
- const role = r.role === "agent" ? "assistant" : r.role === "user" ? "user" : null;
5659
- if (!role) continue;
5660
- const body = (r.body ?? "").trim();
5661
- if (body.length === 0) continue;
5662
- mapped.push({ role, content: body });
5663
- }
5664
- return capHistory(mapped, opts.maxHistoryChars ?? DEFAULT_MAX_HISTORY_CHARS);
5665
- }
5666
- function capHistory(messages, maxChars) {
5667
- let total = messages.reduce((n, m) => n + m.content.length, 0);
5668
- let start = 0;
5669
- while (total > maxChars && start < messages.length) {
5670
- total -= messages[start].content.length;
5671
- start += 1;
5672
- }
5673
- return messages.slice(start);
5674
- }
5675
-
5676
- // packages/agent-runtime/src/cabane-native/model.ts
5677
- var CABANE_NATIVE_MODEL_PREFIX = "cabane-native/";
5678
- function parseCabaneNativeModel(model) {
5679
- return model.startsWith(CABANE_NATIVE_MODEL_PREFIX) ? model.slice(CABANE_NATIVE_MODEL_PREFIX.length) : model;
5680
- }
5681
-
5682
- // packages/agent-runtime/src/cabane-native/tools.ts
5683
- var TOOL_RESULT_MAX_CHARS = 8e3;
5684
- var CABANE_NATIVE_TOOLS = [
5685
- {
5686
- type: "function",
5687
- function: {
5688
- name: "cabane_list",
5689
- description: "List the files and subfolders at a workspace folder path. Omit `path` for the root.",
5690
- parameters: {
5691
- type: "object",
5692
- properties: {
5693
- path: {
5694
- type: "string",
5695
- description: "Workspace folder path, e.g. /notes. Defaults to /."
5696
- }
5697
- }
5698
- }
5699
- }
5700
- },
5701
- {
5702
- type: "function",
5703
- function: {
5704
- name: "cabane_read",
5705
- description: "Read the contents of one file at a workspace path.",
5706
- parameters: {
5707
- type: "object",
5708
- properties: {
5709
- path: { type: "string", description: "Workspace file path, e.g. /notes/todo.md." }
5710
- },
5711
- required: ["path"]
5712
- }
5713
- }
5714
- },
5715
- {
5716
- type: "function",
5717
- function: {
5718
- name: "cabane_search",
5719
- description: "Case-insensitive substring search across file names and file contents. Optionally scope to a subtree with `path`.",
5720
- parameters: {
5721
- type: "object",
5722
- properties: {
5723
- q: { type: "string", description: "The search string." },
5724
- path: {
5725
- type: "string",
5726
- description: "Optional workspace subtree to scope the search to."
5727
- }
5728
- },
5729
- required: ["q"]
5730
- }
5731
- }
5732
- },
5733
- {
5734
- type: "function",
5735
- function: {
5736
- name: "cabane_write",
5737
- description: "Create a file at a workspace path (missing parent folders are created). Pass `overwrite: true` to replace an existing file instead of failing on a name conflict.",
5738
- parameters: {
5739
- type: "object",
5740
- properties: {
5741
- path: { type: "string", description: "Workspace file path, e.g. /notes/new.md." },
5742
- content: { type: "string", description: "The file contents." },
5743
- overwrite: { type: "boolean", description: "Replace an existing file (default false)." }
5744
- },
5745
- required: ["path", "content"]
5746
- }
5747
- }
5748
- },
5749
- {
5750
- type: "function",
5751
- function: {
5752
- name: "cabane_edit",
5753
- description: "Modify an existing file with a single find/replace. By default `find` must occur exactly once; set `replaceAll: true` to replace every occurrence.",
5754
- parameters: {
5755
- type: "object",
5756
- properties: {
5757
- path: { type: "string", description: "Workspace file path to edit." },
5758
- find: { type: "string", description: "The substring to find." },
5759
- replace: { type: "string", description: "The replacement." },
5760
- replaceAll: { type: "boolean", description: "Replace every occurrence (default false)." }
5761
- },
5762
- required: ["path", "find", "replace"]
5763
- }
5764
- }
5765
- }
5766
- ];
5767
- function summarizeCabaneToolArgs(name, args) {
5768
- if (name === "cabane_search") return typeof args.q === "string" ? args.q : "";
5769
- return typeof args.path === "string" ? args.path : "";
5770
- }
5771
- async function executeCabaneNativeTool(name, args, ctx, signal) {
5772
- const doFetch = ctx.fetchImpl ?? fetch;
5773
- const wsBase = `${ctx.apiRoot}/workspaces/${ctx.workspaceId}`;
5774
- const headers = { Authorization: `Bearer ${ctx.bearer}`, "Content-Type": "application/json" };
5775
- const call = async (method, path3, init2) => {
5776
- const url = new URL(`${wsBase}${path3}`);
5777
- for (const [k, v] of Object.entries(init2?.query ?? {})) {
5778
- if (v !== void 0) url.searchParams.set(k, v);
5779
- }
5780
- let res;
5781
- try {
5782
- res = await doFetch(url.toString(), {
5783
- method,
5784
- headers,
5785
- ...init2?.body !== void 0 ? { body: JSON.stringify(init2.body) } : {},
5786
- signal
5787
- });
5788
- } catch (err) {
5789
- return {
5790
- ok: false,
5791
- result: `error: request failed: ${err instanceof Error ? err.message : String(err)}`
5792
- };
5793
- }
5794
- const contentType = res.headers.get("content-type") ?? "";
5795
- const text = contentType.includes("application/json") ? JSON.stringify(await res.json().catch(() => ({}))) : await res.text().catch(() => "");
5796
- if (!res.ok) return { ok: false, result: truncate2(`error ${res.status}: ${text}`) };
5797
- return { ok: true, result: truncate2(text) };
5798
- };
5799
- switch (name) {
5800
- case "cabane_list":
5801
- return call("GET", "/files/tree", { query: { path: str2(args.path) ?? "/" } });
5802
- case "cabane_read":
5803
- return call("GET", "/files/content", { query: { path: str2(args.path) } });
5804
- case "cabane_search":
5805
- return call("GET", "/search", { query: { q: str2(args.q), path: str2(args.path) } });
5806
- case "cabane_write": {
5807
- const overwrite = args.overwrite === true;
5808
- return overwrite ? call("PUT", "/files/content", {
5809
- body: { path: str2(args.path), content: str2(args.content) }
5810
- }) : call("POST", "/files", {
5811
- body: { path: str2(args.path), content: str2(args.content), mkdirs: true }
5812
- });
5813
- }
5814
- case "cabane_edit":
5815
- return call("PATCH", "/files", {
5816
- body: {
5817
- path: str2(args.path),
5818
- find: str2(args.find),
5819
- replace: str2(args.replace) ?? "",
5820
- ...args.replaceAll === true ? { replaceAll: true } : {}
5821
- }
5822
- });
5823
- default:
5824
- return { ok: false, result: `error: unknown tool "${name}"` };
5825
- }
5826
- }
5827
- function str2(v) {
5828
- return typeof v === "string" ? v : void 0;
5829
- }
5830
- function truncate2(s) {
5831
- return s.length > TOOL_RESULT_MAX_CHARS ? `${s.slice(0, TOOL_RESULT_MAX_CHARS)}
5832
- \u2026 [truncated]` : s;
5833
- }
5834
-
5835
- // packages/agent-runtime/src/cabane-native/loop.ts
5836
- var DEFAULT_MAX_ITERATIONS = 12;
5837
- async function* runCabaneNativeTurn(req, signal, deps) {
5838
- if (!req.config.model) {
5839
- yield { type: "result", ok: false, reason: "no_model" };
5840
- return;
5841
- }
5842
- const model = parseCabaneNativeModel(req.config.model);
5843
- const toolCtx = {
5844
- apiRoot: deps.apiRoot,
5845
- workspaceId: deps.workspaceId,
5846
- bearer: deps.bearer,
5847
- ...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
5848
- };
5849
- const messages = await assembleMessages(req.systemPrompt, req.content, req.prompt, {
5850
- apiRoot: deps.apiRoot,
5851
- workspaceId: deps.workspaceId,
5852
- bearer: deps.bearer,
5853
- conversationId: deps.conversationId,
5854
- ...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {},
5855
- ...deps.historyLimit !== void 0 ? { historyLimit: deps.historyLimit } : {}
5856
- });
5857
- if (signal.aborted) return;
5858
- const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
5859
- let usage;
5860
- let resolvedModel = model;
5861
- for (let iteration = 0; iteration < maxIterations; iteration++) {
5862
- let text = "";
5863
- const toolAcc = /* @__PURE__ */ new Map();
5864
- let finishReason;
5865
- let errored2;
5866
- for await (const ev of deps.provider.stream(
5867
- { model, messages, tools: CABANE_NATIVE_TOOLS },
5868
- signal
5869
- )) {
5870
- if (signal.aborted) return;
5871
- switch (ev.type) {
5872
- case "text":
5873
- text += ev.delta;
5874
- break;
5875
- case "tool_call": {
5876
- const cur = toolAcc.get(ev.index) ?? { id: `call_${ev.index}`, name: "", args: "" };
5877
- if (ev.id) cur.id = ev.id;
5878
- if (ev.name) cur.name = ev.name;
5879
- if (ev.argumentsDelta) cur.args += ev.argumentsDelta;
5880
- toolAcc.set(ev.index, cur);
5881
- break;
5882
- }
5883
- case "usage":
5884
- usage = { inputTokens: ev.inputTokens, outputTokens: ev.outputTokens };
5885
- break;
5886
- case "model":
5887
- resolvedModel = ev.model;
5888
- break;
5889
- case "error":
5890
- errored2 = ev.message;
5891
- break;
5892
- case "done":
5893
- finishReason = ev.finishReason;
5894
- break;
5895
- }
5896
- }
5897
- if (signal.aborted) return;
5898
- if (errored2 !== void 0) {
5899
- const sealed = sealText(text, false);
5900
- if (sealed) yield sealed;
5901
- const failure = classifyErrorText(errored2);
5902
- yield {
5903
- type: "result",
5904
- ok: false,
5905
- reason: failure ? encodeFailureReason(failure) : `error:${errored2.slice(0, 200)}`,
5906
- ...usage ? { usage } : {},
5907
- ...resolvedModel ? { resolvedModel } : {}
5908
- };
5909
- return;
5910
- }
5911
- const toolCalls = [...toolAcc.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
5912
- if (toolCalls.length === 0) {
5913
- const sealed = sealText(text, true);
5914
- if (sealed) yield sealed;
5915
- yield {
5916
- type: "result",
5917
- ok: true,
5918
- ...usage ? { usage } : {},
5919
- ...resolvedModel ? { resolvedModel } : {}
5920
- };
5921
- return;
5922
- }
5923
- const sealedInterim = sealText(text, false);
5924
- if (sealedInterim) yield sealedInterim;
5925
- const assistantToolCalls = toolCalls.map((t) => ({
5926
- id: t.id,
5927
- type: "function",
5928
- function: { name: t.name, arguments: t.args || "{}" }
5929
- }));
5930
- messages.push({ role: "assistant", content: text, tool_calls: assistantToolCalls });
5931
- for (const t of toolCalls) {
5932
- if (signal.aborted) return;
5933
- const args = parseArgs(t.args);
5934
- const displayName = prettyToolName(t.name);
5935
- const summary = summarizeCabaneToolArgs(t.name, args);
5936
- yield { type: "tool", id: t.id, name: displayName, phase: "start", summary, input: args };
5937
- const result = await executeCabaneNativeTool(t.name, args, toolCtx, signal);
5938
- if (signal.aborted) return;
5939
- yield {
5940
- type: "tool",
5941
- id: t.id,
5942
- name: displayName,
5943
- phase: result.ok ? "done" : "error",
5944
- summary,
5945
- input: args,
5946
- result: result.result
5947
- };
5948
- messages.push({ role: "tool", tool_call_id: t.id, content: result.result });
5949
- }
5950
- }
5951
- deps.onWarn?.("cabane-native: turn hit the tool-iteration cap; force-settling", {
5952
- maxIterations
5953
- });
5954
- yield {
5955
- type: "text",
5956
- body: `(Stopped after ${maxIterations} tool steps without a final answer.)`,
5957
- terminal: true
5958
- };
5959
- yield {
5960
- type: "result",
5961
- ok: true,
5962
- ...usage ? { usage } : {},
5963
- ...resolvedModel ? { resolvedModel } : {}
5964
- };
5965
- }
5966
- function sealText(text, terminal) {
5967
- const body = text.trim();
5968
- if (body.length === 0) return null;
5969
- return { type: "text", body, terminal };
5970
- }
5971
- function parseArgs(raw) {
5972
- if (!raw.trim()) return {};
5973
- try {
5974
- const parsed = JSON.parse(raw);
5975
- return parsed && typeof parsed === "object" ? parsed : {};
5976
- } catch {
5977
- return {};
5978
- }
5979
- }
5980
-
5981
- // packages/agent-runtime/src/cabane-native/policy.ts
5982
- import { z as z13 } from "zod";
5983
- var cabaneNativeDialectSchema = z13.object({}).loose();
5984
-
5985
- // packages/agent-runtime/src/cabane-native/provider.ts
5986
- var DEFAULT_OPENROUTER_BASE = "https://openrouter.ai/api/v1";
5987
- function createOpenRouterProvider(opts) {
5988
- const base = (opts.baseUrl ?? DEFAULT_OPENROUTER_BASE).replace(/\/$/, "");
5989
- const doFetch = opts.fetchImpl ?? fetch;
5990
- return {
5991
- async *stream(req, signal) {
5992
- let res;
5993
- try {
5994
- res = await doFetch(`${base}/chat/completions`, {
5995
- method: "POST",
5996
- headers: {
5997
- Authorization: `Bearer ${opts.apiKey}`,
5998
- "Content-Type": "application/json",
5999
- // OpenRouter attribution headers (optional, but polite + used for
6000
- // routing/analytics on their side).
6001
- "HTTP-Referer": "https://cabane.ai",
6002
- "X-Title": "Cabane"
6003
- },
6004
- body: JSON.stringify({
6005
- model: req.model,
6006
- messages: req.messages,
6007
- ...req.tools.length > 0 ? { tools: req.tools } : {},
6008
- stream: true,
6009
- // Ask OpenRouter to append a trailing usage chunk to the stream.
6010
- stream_options: { include_usage: true }
6011
- }),
6012
- signal
6013
- });
6014
- } catch (err) {
6015
- if (signal.aborted) return;
6016
- yield { type: "error", message: `request failed: ${errText(err)}` };
6017
- return;
6018
- }
6019
- if (!res.ok || !res.body) {
6020
- const bodyText = await res.text().catch(() => "");
6021
- yield { type: "error", message: providerErrorMessage(res.status, bodyText) };
6022
- return;
6023
- }
6024
- const decoder = new TextDecoder();
6025
- const reader = res.body.getReader();
6026
- let buffer = "";
6027
- let finishReason;
6028
- let modelEmitted = false;
6029
- try {
6030
- for (; ; ) {
6031
- if (signal.aborted) return;
6032
- const { value, done } = await reader.read();
6033
- if (done) break;
6034
- buffer += decoder.decode(value, { stream: true });
6035
- let nl;
6036
- while ((nl = buffer.indexOf("\n")) !== -1) {
6037
- const line = buffer.slice(0, nl).trim();
6038
- buffer = buffer.slice(nl + 1);
6039
- if (!line || line.startsWith(":")) continue;
6040
- if (!line.startsWith("data:")) continue;
6041
- const data = line.slice("data:".length).trim();
6042
- if (data === "[DONE]") {
6043
- yield { type: "done", ...finishReason ? { finishReason } : {} };
6044
- return;
6045
- }
6046
- let chunk;
6047
- try {
6048
- chunk = JSON.parse(data);
6049
- } catch {
6050
- continue;
6051
- }
6052
- if (chunk.error) {
6053
- yield { type: "error", message: chunk.error.message ?? "provider error" };
6054
- return;
6055
- }
6056
- if (!modelEmitted && chunk.model) {
6057
- modelEmitted = true;
6058
- yield { type: "model", model: chunk.model };
6059
- }
6060
- const choice = chunk.choices?.[0];
6061
- if (choice) {
6062
- const delta = choice.delta;
6063
- if (delta?.content) yield { type: "text", delta: delta.content };
6064
- if (delta?.tool_calls) {
6065
- for (const tc of delta.tool_calls) {
6066
- yield {
6067
- type: "tool_call",
6068
- index: tc.index,
6069
- ...tc.id ? { id: tc.id } : {},
6070
- ...tc.function?.name ? { name: tc.function.name } : {},
6071
- ...tc.function?.arguments !== void 0 ? { argumentsDelta: tc.function.arguments } : {}
6072
- };
6073
- }
6074
- }
6075
- if (choice.finish_reason) finishReason = choice.finish_reason;
6076
- }
6077
- if (chunk.usage) {
6078
- yield {
6079
- type: "usage",
6080
- inputTokens: chunk.usage.prompt_tokens ?? 0,
6081
- outputTokens: chunk.usage.completion_tokens ?? 0
6082
- };
6083
- }
6084
- }
6085
- }
6086
- } catch (err) {
6087
- if (signal.aborted) return;
6088
- yield { type: "error", message: `stream read failed: ${errText(err)}` };
6089
- return;
6090
- }
6091
- yield { type: "done", ...finishReason ? { finishReason } : {} };
6092
- }
6093
- };
6094
- }
6095
- function providerErrorMessage(status, body) {
6096
- let detail = body.slice(0, 300);
6097
- try {
6098
- const parsed = JSON.parse(body);
6099
- if (parsed.error?.message) detail = parsed.error.message;
6100
- } catch {
6101
- }
6102
- return `HTTP ${status}: ${detail}`;
6103
- }
6104
- function errText(err) {
6105
- return err instanceof Error ? err.message : String(err);
6106
- }
6107
-
6108
- // packages/agent-runtime/src/cabane-native/index.ts
6109
- var CABANE_NATIVE_RUNTIME_NAME = "cabane-native";
6110
- function createCabaneNativeAdapter(deps = {}) {
6111
- const provider = deps.provider ?? (deps.apiKey ? createOpenRouterProvider({
6112
- apiKey: deps.apiKey,
6113
- ...deps.baseUrl ? { baseUrl: deps.baseUrl } : {},
6114
- ...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
6115
- }) : null);
6116
- return {
6117
- 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,
6125
- dialectSchema: cabaneNativeDialectSchema,
6126
- async *runTurn(req, signal) {
6127
- if (!provider) {
6128
- yield {
6129
- type: "result",
6130
- ok: false,
6131
- reason: "cabane_native_unavailable:no OPENROUTER_API_KEY configured on this device"
6132
- };
6133
- return;
6134
- }
6135
- const workspaceId = req.cabane.workspaceId;
6136
- if (!workspaceId) {
6137
- yield {
6138
- type: "result",
6139
- ok: false,
6140
- reason: "cabane_native_unavailable:turn carried no workspaceId"
6141
- };
6142
- return;
6143
- }
6144
- const apiRoot = req.cabane.mcpUrl.replace(/\/mcp\/?$/, "");
6145
- yield* runCabaneNativeTurn(req, signal, {
6146
- provider,
6147
- apiRoot,
6148
- workspaceId,
6149
- bearer: req.cabane.bearer,
6150
- conversationId: req.cabane.activeConversationId,
6151
- ...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {},
6152
- ...deps.onWarn ? { onWarn: deps.onWarn } : {},
6153
- ...deps.maxIterations !== void 0 ? { maxIterations: deps.maxIterations } : {}
6154
- });
6155
- }
6156
- };
6157
- }
6158
- var cabaneNativeAdapter = createCabaneNativeAdapter();
6159
-
6160
5811
  // packages/agent-runtime/src/claude-code/sdk.ts
6161
5812
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
6162
5813
 
@@ -6195,8 +5846,8 @@ var ConnectorHealthStore = class {
6195
5846
  return this.byRuntime.get(runtime);
6196
5847
  }
6197
5848
  // 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
5849
+ // companion has an observation for. Empty until the first classified failure/heal,
5850
+ // so a companion that has seen nothing sends no `connectors[]` and the server's
6200
5851
  // manifest synthesis (status-less rows) is unaffected.
6201
5852
  reports() {
6202
5853
  return [...this.byRuntime.entries()].map(([runtime, h]) => ({
@@ -6210,22 +5861,23 @@ var ConnectorHealthStore = class {
6210
5861
 
6211
5862
  // src/dispatcher.ts
6212
5863
  import { randomUUID } from "crypto";
6213
- import { existsSync as existsSync9 } from "fs";
5864
+ import { appendFileSync as appendFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync9 } from "fs";
5865
+ import { join as join12 } from "path";
6214
5866
 
6215
5867
  // src/summon.ts
6216
- import { z as z14 } from "zod";
6217
- var BRIDGE_LOCAL_MCP_SERVER = "cabane_bridge";
5868
+ import { z as z12 } from "zod";
5869
+ var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
6218
5870
  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}__*`;
5871
+ var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
5872
+ var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
6221
5873
  var SKIP_TURN_TOOL = "skip_turn";
6222
- var SKIP_TURN_TOOL_NAME = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__${SKIP_TURN_TOOL}`;
5874
+ var SKIP_TURN_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SKIP_TURN_TOOL}`;
6223
5875
  var ASK_TOOL = "ask";
6224
- var ASK_TOOL_NAME = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
5876
+ var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
6225
5877
  var SUB_AGENT_TOOL = "sub_agent";
6226
- var SUB_AGENT_TOOL_NAME = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
5878
+ var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
6227
5879
  var WAKE_ME_TOOL = "wake_me";
6228
- var WAKE_ME_TOOL_NAME = `mcp__${BRIDGE_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
5880
+ var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
6229
5881
  function createSummonState() {
6230
5882
  return { agentId: null };
6231
5883
  }
@@ -6240,14 +5892,14 @@ function createWakeState() {
6240
5892
  }
6241
5893
  function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
6242
5894
  return createSdkMcpServer({
6243
- name: BRIDGE_LOCAL_MCP_SERVER,
5895
+ name: COMPANION_LOCAL_MCP_SERVER,
6244
5896
  version: "0.0.0",
6245
5897
  tools: [
6246
5898
  tool(
6247
5899
  SUMMON_AGENT_TOOL,
6248
5900
  "Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Use it to hand part of the work to a teammate, or pull in an expert, without leaving the conversation. Pass the peer's `agentId` (discover handles + ids via `list_agents`). The peer is dispatched on your turn's final reply, so write the context/ask into that reply first \u2014 it receives your message + this conversation to work from. Writing `@handle` in your prose does NOT summon anyone (agent prose never dispatches); this tool is the only in-thread lever. Single target \u2014 the last call wins. Summoning yourself is a no-op. Reach for it when the human wants the peer's answer right HERE, in front of them \u2014 the reply lands in this thread, so there's no return to wire (a return is for work YOU consume, never a courtesy notification). A handoff to a DIFFERENT conversation is `create_conversation` / `post_message` with their `dispatch` field instead.",
6249
5901
  {
6250
- agentId: z14.string().uuid().describe("The peer agent to summon \u2014 a workspace agent id from `list_agents`.")
5902
+ agentId: z12.string().uuid().describe("The peer agent to summon \u2014 a workspace agent id from `list_agents`.")
6251
5903
  },
6252
5904
  async (args) => {
6253
5905
  summonState.agentId = args.agentId;
@@ -6262,7 +5914,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6262
5914
  SKIP_TURN_TOOL,
6263
5915
  `End your current turn WITHOUT posting a reply. Call this when you've been dispatched but the message genuinely doesn't need a response from you \u2014 a thanks/aside, a question already answered, chatter outside your lane, or a pile-on where someone else has it. Your turn ends silently: no message bubble is posted. The \`reason\` is a short free-text note for telemetry (e.g. "already answered by cabane", "thanks, nothing to add"). Prefer this over posting a low-value "ok!"/"got it" reply. Don't also write a reply when you skip \u2014 skipping IS the whole turn.`,
6264
5916
  {
6265
- reason: z14.string().min(1).max(500).describe("Short reason you are declining \u2014 used for telemetry/debugging.")
5917
+ reason: z12.string().min(1).max(500).describe("Short reason you are declining \u2014 used for telemetry/debugging.")
6266
5918
  },
6267
5919
  async (args) => {
6268
5920
  skipState.skipped = true;
@@ -6279,21 +5931,21 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6279
5931
  ASK_TOOL,
6280
5932
  "Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact, a go/no-go). Pass `targetUserId` (a workspace member's user id \u2014 get it from `mcp__cabane__list_members`). Two forms: a SINGLE question \u2014 a `headline` (the actual question as one clear, capitalized sentence ending in `?`, \"Do we go to prod?\") plus a short `question` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once (\"three calls before I build: A? B? C?\"), a `questions` array of 1\u20135 items, each `{ headline, body?, options? }`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence `headline`, a short `body` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted lists or headings), and 2\u20134 `options` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. An option can be a short button label OR a sentence that carries its own context; short/binary sets render as inline buttons, long ones stack full-width. Provide EITHER `question` (single) or `questions` (array), never both. The ask is recorded as a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use summon/dispatch instead.",
6281
5933
  {
6282
- targetUserId: z14.string().uuid().describe("The workspace member (human) to ask \u2014 a user id from `list_members`."),
6283
- question: z14.string().min(1).max(400).optional().describe(
5934
+ targetUserId: z12.string().uuid().describe("The workspace member (human) to ask \u2014 a user id from `list_members`."),
5935
+ question: z12.string().min(1).max(400).optional().describe(
6284
5936
  "SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (capped, inline markdown only). Provide EITHER this or `questions`, not both. Put the crisp one-sentence question in `headline`."
6285
5937
  ),
6286
- headline: z14.string().min(1).max(120).optional().describe(
5938
+ headline: z12.string().min(1).max(120).optional().describe(
6287
5939
  'SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in `?` ("Do we go to prod?"). What the human reads first in the inbox and the chip \u2014 one scannable question, no elaboration (that goes in `question`). Strongly encouraged.'
6288
5940
  ),
6289
- options: z14.array(z14.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
6290
- questions: z14.array(
6291
- z14.object({
6292
- headline: z14.string().min(1).max(120).describe(
5941
+ options: z12.array(z12.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
5942
+ questions: z12.array(
5943
+ z12.object({
5944
+ headline: z12.string().min(1).max(120).describe(
6293
5945
  'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
6294
5946
  ),
6295
- body: z14.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
6296
- options: z14.array(z14.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
5947
+ body: z12.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
5948
+ options: z12.array(z12.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
6297
5949
  })
6298
5950
  ).min(1).max(5).optional().describe(
6299
5951
  "MULTI-question form: 1\u20135 questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or `question`/`headline`/`options`, not both."
@@ -6350,13 +6002,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6350
6002
  SUB_AGENT_TOOL,
6351
6003
  "Spawn a sub-agent \u2014 hand a piece of work to a private worker with a fresh context window, whose result comes back to you automatically. Modeled on the Task tool, with ONE deliberate difference: it does NOT return the result inline. A callee's turn can run for minutes and no turn may hold an unbounded wait, so the shape is spawn-now, results-on-wake \u2014 this returns immediately with the child's `conversationId`, and the outcome lands LATER as a message in THIS conversation; you're woken once every sub-agent you have out in this conversation has returned. So DON'T wait for it: after spawning, finish whatever else this turn can do and end your turn (never poll the child with reads in a loop \u2014 the wake is automatic). Parallel fan-out = call this N times in one turn (they run concurrently; ONE wake when all are in); series = one call per turn. `prompt` is the child's opening instruction \u2014 make it self-contained (the sub-agent starts fresh, with only this prompt + the thread it lands in). `agentId` (optional) dispatches a PEER instead of yourself \u2014 same mechanics, a different mind (use for capability/context you lack); default (self) is the pure sub-worker with a clean context window. `title` (optional) names the child thread (results link it, so a legible title helps). A single sub-agent has no wall-clock advantage (you idle either way) \u2014 it pays when the callee has capability/context you lack, or to isolate a big read from your own session; the real win is fan-out. Don't spawn one for a lookup you can do in-turn with your own tools. The result returns to YOU to act on \u2014 reach for it when you're the consumer of the output, not as a way to notify a human: if a person just wants to read the result, dispatch a plain (no-return) conversation and link it instead of spawning a sub-agent.",
6352
6004
  {
6353
- prompt: z14.string().min(1).max(65536).describe(
6005
+ prompt: z12.string().min(1).max(65536).describe(
6354
6006
  "The sub-agent's opening instruction \u2014 self-contained (it starts with a fresh context window; only this prompt + the thread it lands in)."
6355
6007
  ),
6356
- agentId: z14.string().uuid().optional().describe(
6008
+ agentId: z12.string().uuid().optional().describe(
6357
6009
  "Optional peer to run the sub-agent as (a workspace agent id from `list_agents`); omit to spawn yourself with a fresh context window."
6358
6010
  ),
6359
- title: z14.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
6011
+ title: z12.string().max(200).optional().describe("Optional title for the child thread (result chips link it).")
6360
6012
  },
6361
6013
  async (args) => {
6362
6014
  const result = await subAgentCreate(args);
@@ -6386,13 +6038,13 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6386
6038
  WAKE_ME_TOOL,
6387
6039
  'Wake yourself later \u2014 end this turn now and be re-dispatched at a time you pick, with a note you write to yourself. Use it for "wait until X": when the thing you need hasn\'t happened yet (a conversation isn\'t done, a PR isn\'t merged, a human hasn\'t answered), arm a wake, end your turn, and you\'re woken later to CHECK \u2014 read the workspace, and either act or re-arm. This is the loop behind "check back in five minutes", "keep checking until {condition}", and a scheduled re-try ("re-send once that agent\'s limit resets"). Ground the delay before you arm it. Almost every wake is short \u2014 seconds to a couple of hours \u2014 waiting on a condition you can name: a session limit resetting, a PR merging, a deploy landing, a throttled dispatch to re-send. Reach past a few hours only when (a) a human asked for that timing, or (b) the wait is pinned to a real external event you can name \u2014 a report that only runs Mondays, a scheduled appointment, a known reset time. A speculative far-future check-in you invented yourself \u2014 "in two weeks I\'ll see whether this feature is used" \u2014 is the one thing not to arm: if no one asked and you can\'t name both what clears the wait and why it takes that long, don\'t arm it \u2014 finish now, or raise an `ask`. Pass EXACTLY ONE of `afterSeconds` (a relative delay \u2014 `300` for five minutes) or `at` (an absolute ISO-8601 timestamp WITH a zone, e.g. `2026-07-16T09:00:00-07:00` \u2014 YOU compute it from a phrase like "tomorrow morning"; the system never parses natural-language time). `note` is a message to your future self \u2014 it becomes the body of the wake message that re-dispatches you, so write the condition to re-check ("check whether CT441 merged yet"). The wake is armed when your turn SETTLES, not now, so the delay counts from the turn ending; one wake per turn (last call wins). This is the sanctioned way to schedule your own continuation \u2014 the ONLY one; never reach for a host cron/scheduler. Guardrails: at least 60s out, at most 14 days; widen the interval as a loop ages (5m \u2192 15m \u2192 1h\u2026) rather than hammering; after many consecutive re-arms with no other activity you\'ll be steered to raise an `ask` to the human instead. If a wake can\'t be armed you\'re re-dispatched with a note explaining why \u2014 never a silent drop.',
6388
6040
  {
6389
- afterSeconds: z14.number().int().positive().optional().describe(
6041
+ afterSeconds: z12.number().int().positive().optional().describe(
6390
6042
  "Relative delay in seconds from when this turn ends (e.g. 300 = five minutes). Provide EITHER this or `at`, not both. Floor 60s, horizon 14 days \u2014 enforced server-side."
6391
6043
  ),
6392
- at: z14.string().datetime({ offset: true }).optional().describe(
6044
+ at: z12.string().datetime({ offset: true }).optional().describe(
6393
6045
  "Absolute ISO-8601 timestamp WITH a zone (`Z` or `\xB1HH:MM`), e.g. `2026-07-16T09:00:00-07:00`. YOU compute it from a natural-language phrase using the current datetime in your turn context. Provide EITHER this or `afterSeconds`, not both."
6394
6046
  ),
6395
- note: z14.string().min(1).max(2e3).describe(
6047
+ note: z12.string().min(1).max(2e3).describe(
6396
6048
  'A note to your future self \u2014 becomes the body of the wake message that re-dispatches you. Write the condition to re-check ("check whether the PR merged").'
6397
6049
  )
6398
6050
  },
@@ -6442,7 +6094,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6442
6094
  function cabaneMcpUrl(baseUrl) {
6443
6095
  return `${trimSlash3(baseUrl)}/api/mcp`;
6444
6096
  }
6445
- function buildBridgeTurnRequest(params) {
6097
+ function turnControlMcpUrl(baseUrl) {
6098
+ return `${trimSlash3(baseUrl)}/api/turn-control`;
6099
+ }
6100
+ function buildCompanionTurnRequest(params) {
6446
6101
  const { turnContext: t } = params;
6447
6102
  return {
6448
6103
  systemPrompt: t.systemPrompt,
@@ -6453,11 +6108,18 @@ function buildBridgeTurnRequest(params) {
6453
6108
  session: t.session,
6454
6109
  cabane: {
6455
6110
  mcpUrl: cabaneMcpUrl(params.baseUrl),
6456
- // CT306: prefer the per-turn OBO credential; fall back to the bridge PAT
6111
+ // CT306: prefer the per-turn OBO credential; fall back to the companion PAT
6457
6112
  // when the API didn't mint one (older API / unresolvable delegation).
6458
6113
  bearer: params.turnToken ?? params.agentPat,
6459
6114
  activeConversationId: params.activeConversationId,
6460
- workspaceId: params.workspaceId
6115
+ workspaceId: params.workspaceId,
6116
+ ...t.workspaceToolSurface ? { workspaceToolSurface: t.workspaceToolSurface } : {},
6117
+ // CT714: mount the turn-control surface ONLY when a real turn token backs
6118
+ // this turn — the surface admits `turn_token` auth exclusively, so a
6119
+ // PAT-fallback bearer (older API / unresolved delegation) would be rejected
6120
+ // there. Absent it, external adapters simply don't mount it that turn (the
6121
+ // same graceful degrade as the rest of the OBO path).
6122
+ ...params.turnToken ? { turnControlUrl: turnControlMcpUrl(params.baseUrl) } : {}
6461
6123
  },
6462
6124
  local: {
6463
6125
  ...params.cwd ? { cwd: params.cwd } : {},
@@ -6468,8 +6130,11 @@ function buildBridgeTurnRequest(params) {
6468
6130
  // CT289: the auto-memory escape hatch, when the operator set it.
6469
6131
  ...params.claudeCode ? { claudeCode: params.claudeCode } : {}
6470
6132
  },
6471
- // Host-injected: the bridge-local summon server, under its own namespace.
6472
- extra: { mcpServers: { [BRIDGE_LOCAL_MCP_SERVER]: params.summonServer } }
6133
+ // Host-injected: the companion-local summon server (for the subprocess adapters,
6134
+ // under its own namespace).
6135
+ extra: {
6136
+ mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.summonServer }
6137
+ }
6473
6138
  };
6474
6139
  }
6475
6140
  function trimSlash3(s) {
@@ -6482,11 +6147,14 @@ import { join as join9 } from "path";
6482
6147
  function dirFor(workspaceId) {
6483
6148
  return join9(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
6484
6149
  }
6485
- function pathFor3(workspaceId, conversationId) {
6486
- return join9(dirFor(workspaceId), `${encodeURIComponent(conversationId)}.json`);
6150
+ function conversationDir(workspaceId, conversationId) {
6151
+ return join9(dirFor(workspaceId), encodeURIComponent(conversationId));
6487
6152
  }
6488
- function readPrepared(workspaceId, conversationId) {
6489
- const path3 = pathFor3(workspaceId, conversationId);
6153
+ function pathFor3(workspaceId, conversationId, agentId) {
6154
+ return join9(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
6155
+ }
6156
+ function readPrepared(workspaceId, conversationId, agentId) {
6157
+ const path3 = pathFor3(workspaceId, conversationId, agentId);
6490
6158
  if (!existsSync7(path3)) return null;
6491
6159
  try {
6492
6160
  const parsed = JSON.parse(readFileSync6(path3, "utf8"));
@@ -6501,20 +6169,24 @@ function readPrepared(workspaceId, conversationId) {
6501
6169
  return null;
6502
6170
  }
6503
6171
  }
6504
- function writePrepared(workspaceId, conversationId, result) {
6505
- mkdirSync7(dirFor(workspaceId), { recursive: true });
6506
- writeFileSync6(pathFor3(workspaceId, conversationId), JSON.stringify(result) + "\n", "utf8");
6172
+ function writePrepared(workspaceId, conversationId, agentId, result) {
6173
+ mkdirSync7(conversationDir(workspaceId, conversationId), { recursive: true });
6174
+ writeFileSync6(
6175
+ pathFor3(workspaceId, conversationId, agentId),
6176
+ JSON.stringify(result) + "\n",
6177
+ "utf8"
6178
+ );
6507
6179
  }
6508
6180
 
6509
6181
  // src/secrets.ts
6510
6182
  import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
6511
6183
  import { join as join10 } from "path";
6512
- import { z as z15 } from "zod";
6184
+ import { z as z13 } from "zod";
6513
6185
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
6514
6186
  function secretsPath() {
6515
6187
  return join10(cabaneDir(), "secrets.json");
6516
6188
  }
6517
- var secretStoreSchema = z15.record(z15.string(), z15.string());
6189
+ var secretStoreSchema = z13.record(z13.string(), z13.string());
6518
6190
  function loadSecretStore() {
6519
6191
  const path3 = secretsPath();
6520
6192
  if (!existsSync8(path3)) return makeStore({});
@@ -6844,17 +6516,132 @@ var TurnCommitter = class {
6844
6516
  }
6845
6517
  };
6846
6518
 
6519
+ // src/workspace-readiness.ts
6520
+ var CLASSIC_REQUIRED = ["read", "list", "search", "write", "edit"];
6521
+ async function proveWorkspaceTools(req, runtime, opts = {}) {
6522
+ const base = {
6523
+ ok: false,
6524
+ proofType: "authenticated_mcp_tools_list",
6525
+ runtime,
6526
+ harnessFingerprint: opts.harnessFingerprint ?? runtime,
6527
+ endpoint: safeEndpoint(req.cabane.mcpUrl),
6528
+ initialized: false,
6529
+ authenticated: false,
6530
+ discoveredTools: [],
6531
+ requiredTools: [],
6532
+ acceptedNames: ["sdk", "mcp__cabane__sdk"],
6533
+ failedCapability: null,
6534
+ detail: null
6535
+ };
6536
+ if (!req.cabane.mcpUrl) return fail(base, "server_not_configured", "Cabane MCP URL absent");
6537
+ if (!req.cabane.bearer) return fail(base, "authentication_failed", "Cabane bearer absent");
6538
+ const fetchImpl = opts.fetchImpl ?? fetch;
6539
+ const headers = {
6540
+ authorization: `Bearer ${req.cabane.bearer}`,
6541
+ accept: "application/json, text/event-stream",
6542
+ "content-type": "application/json",
6543
+ "x-cabane-active-conversation": req.cabane.activeConversationId
6544
+ };
6545
+ try {
6546
+ const initialized = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
6547
+ jsonrpc: "2.0",
6548
+ id: 1,
6549
+ method: "initialize",
6550
+ params: {
6551
+ protocolVersion: "2025-03-26",
6552
+ capabilities: {},
6553
+ clientInfo: { name: "cabane-companion-readiness", version: "1" }
6554
+ }
6555
+ });
6556
+ if (initialized.status === 401 || initialized.status === 403)
6557
+ return fail(base, "authentication_failed", `initialize returned HTTP ${initialized.status}`);
6558
+ if (!initialized.ok) return fail(base, "initialization_failed", initialized.detail);
6559
+ base.initialized = true;
6560
+ base.authenticated = true;
6561
+ if (initialized.sessionId) headers["mcp-session-id"] = initialized.sessionId;
6562
+ const listed = await rpc(fetchImpl, req.cabane.mcpUrl, headers, {
6563
+ jsonrpc: "2.0",
6564
+ id: 2,
6565
+ method: "tools/list",
6566
+ params: {}
6567
+ });
6568
+ if (listed.status === 401 || listed.status === 403)
6569
+ return fail(base, "authentication_failed", `tools/list returned HTTP ${listed.status}`);
6570
+ if (!listed.ok) return fail(base, "tool_discovery_failed", listed.detail);
6571
+ const result = asRecord3(asRecord3(listed.value)?.result);
6572
+ const tools = Array.isArray(result?.tools) ? result.tools : null;
6573
+ if (!tools) return fail(base, "tool_discovery_failed", "tools/list returned no tool inventory");
6574
+ base.discoveredTools = tools.map(
6575
+ (tool2) => tool2 && typeof tool2 === "object" && typeof tool2.name === "string" ? tool2.name : null
6576
+ ).filter((name) => name !== null).sort();
6577
+ if (!req.cabane.workspaceToolSurface)
6578
+ return fail(base, "required_tool_missing", "resolved workspace tool surface absent");
6579
+ base.requiredTools = req.cabane.workspaceToolSurface === "code" ? ["sdk"] : CLASSIC_REQUIRED;
6580
+ const missing = base.requiredTools.filter((name) => !base.discoveredTools.includes(name));
6581
+ if (missing.length > 0)
6582
+ return fail(
6583
+ base,
6584
+ "required_tool_missing",
6585
+ `missing initialized tools: ${missing.join(", ")}`
6586
+ );
6587
+ base.ok = true;
6588
+ return base;
6589
+ } catch (error) {
6590
+ return fail(
6591
+ base,
6592
+ "initialization_failed",
6593
+ error instanceof Error ? error.message : String(error)
6594
+ );
6595
+ }
6596
+ }
6597
+ function fail(proof, capability, detail) {
6598
+ proof.failedCapability = capability;
6599
+ proof.detail = detail.slice(0, 300);
6600
+ return proof;
6601
+ }
6602
+ function safeEndpoint(value) {
6603
+ try {
6604
+ const url = new URL(value);
6605
+ return `${url.origin}${url.pathname}`;
6606
+ } catch {
6607
+ return null;
6608
+ }
6609
+ }
6610
+ async function rpc(fetchImpl, url, headers, body) {
6611
+ const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
6612
+ const text = await response.text();
6613
+ const value = parseRpcBody(text);
6614
+ return {
6615
+ ok: response.ok && !!value && !value.error,
6616
+ status: response.status,
6617
+ sessionId: response.headers.get("mcp-session-id"),
6618
+ value,
6619
+ detail: typeof asRecord3(value?.error)?.message === "string" ? String(asRecord3(value?.error)?.message) : `HTTP ${response.status}`
6620
+ };
6621
+ }
6622
+ function parseRpcBody(text) {
6623
+ const trimmed = text.trim();
6624
+ if (trimmed.startsWith("{")) return JSON.parse(trimmed);
6625
+ for (const line of trimmed.split("\n")) {
6626
+ if (line.startsWith("data:")) return JSON.parse(line.slice(5).trim());
6627
+ }
6628
+ return null;
6629
+ }
6630
+ function asRecord3(value) {
6631
+ return value !== null && typeof value === "object" ? value : null;
6632
+ }
6633
+
6847
6634
  // src/dispatcher.ts
6848
6635
  var PREPARING_TOOL_NAME = "preparing";
6849
6636
  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:";
6637
+ 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
6638
  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`;
6639
+ 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:";
6640
+ 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
6641
  var SKIPPED_MARKER_BODY = "(skipped)";
6855
6642
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
6856
6643
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
6857
- var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 45 * 6e4;
6644
+ var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
6858
6645
  function runKey(conversationId, agentId) {
6859
6646
  return `${conversationId}|${agentId}`;
6860
6647
  }
@@ -6895,13 +6682,13 @@ var Dispatcher = class {
6895
6682
  }
6896
6683
  }
6897
6684
  // CT138: shared pre-run teardown for every early-return that happens BEFORE
6898
- // the active-run flag is flipped (the `setBridgeActiveRun` working-flip below).
6685
+ // the active-run flag is flipped (the `setActiveRun` working-flip below).
6899
6686
  // The server lights the "X is replying…" indicator eagerly at dispatch
6900
- // (chat-dispatch.ts `scheduleRun`), and from that point only the bridge can
6687
+ // (chat-dispatch.ts `scheduleRun`), and from that point only the companion can
6901
6688
  // clear it — the SJ383 `finally` after the SDK loop is the one clear, and
6902
6689
  // every pre-run exit returns before reaching it. So each pre-run failure has
6903
6690
  // to clear `active_run_started_at` itself, mirroring that `finally`, or the
6904
- // indicator strands until the 90-min age sweep.
6691
+ // indicator strands until the 12h age sweep.
6905
6692
  //
6906
6693
  // `errorReason` controls the server's duplicate-notice rule (the active-run
6907
6694
  // PATCH handler in conversations.ts): a clear carrying `errorReason` makes the
@@ -6915,7 +6702,7 @@ var Dispatcher = class {
6915
6702
  const body = { activeRunStartedAt: null };
6916
6703
  if (errorReason) body.errorReason = errorReason.slice(0, 200);
6917
6704
  try {
6918
- await this.opts.api.setBridgeActiveRun(
6705
+ await this.opts.api.setActiveRun(
6919
6706
  this.opts.workspaceId,
6920
6707
  payload.conversationId,
6921
6708
  payload.agentId,
@@ -6941,9 +6728,14 @@ var Dispatcher = class {
6941
6728
  agentId: payload.agentId,
6942
6729
  messageId: payload.messageId
6943
6730
  });
6731
+ const turnId = randomUUID();
6944
6732
  let turnContext;
6945
6733
  try {
6946
- turnContext = await this.opts.api.getTurnContext(payload.conversationId, payload.messageId);
6734
+ turnContext = await this.opts.api.getTurnContext(
6735
+ payload.conversationId,
6736
+ payload.messageId,
6737
+ turnId
6738
+ );
6947
6739
  } catch (err) {
6948
6740
  const status = err instanceof ApiError ? err.status : 0;
6949
6741
  if (status === 404) {
@@ -6984,7 +6776,7 @@ var Dispatcher = class {
6984
6776
  );
6985
6777
  if (missing.length > 0) {
6986
6778
  const list = missing.map((n) => `\`${n}\``).join(", ");
6987
- turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this bridge");
6779
+ turnLog.error({ missing }, "dispatcher: turn needs secrets not declared on this companion");
6988
6780
  try {
6989
6781
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
6990
6782
  body: `${MISSING_SECRET_PREFIX} ${list}`,
@@ -7005,7 +6797,6 @@ var Dispatcher = class {
7005
6797
  const localCwd = this.opts.local.cwd;
7006
6798
  const prepareHook = this.opts.local.prepareHook;
7007
6799
  const cabaneCwd = turnContext.cwd;
7008
- const turnId = randomUUID();
7009
6800
  let seqCounter = 0;
7010
6801
  const nextSeq = () => ++seqCounter;
7011
6802
  let effectiveCwd = localCwd ?? cabaneCwd;
@@ -7018,7 +6809,7 @@ var Dispatcher = class {
7018
6809
  }
7019
6810
  let hookEnv;
7020
6811
  if (prepareHook) {
7021
- const cached2 = readPrepared(workspaceId, payload.conversationId);
6812
+ const cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
7022
6813
  if (cached2) {
7023
6814
  effectiveCwd = cached2.cwd;
7024
6815
  hookEnv = cached2.env;
@@ -7055,6 +6846,7 @@ var Dispatcher = class {
7055
6846
  conversationId: payload.conversationId,
7056
6847
  agentId: payload.agentId,
7057
6848
  agentUsername: this.opts.agentUsername,
6849
+ runtime: turnContext.runtime,
7058
6850
  // CT317/CT319: the trigger message's referenced-entry paths — what the
7059
6851
  // tasker prepare hook keys its per-task env off. Defaults to `[]` for
7060
6852
  // an older API. The conversation anchor is gone (CT319).
@@ -7063,7 +6855,7 @@ var Dispatcher = class {
7063
6855
  });
7064
6856
  clearTimeout(preparingTimer);
7065
6857
  if (preparingStarted) reportPreparing("done");
7066
- writePrepared(workspaceId, payload.conversationId, result);
6858
+ writePrepared(workspaceId, payload.conversationId, payload.agentId, result);
7067
6859
  effectiveCwd = result.cwd;
7068
6860
  hookEnv = result.env;
7069
6861
  } catch (err) {
@@ -7092,12 +6884,25 @@ ${reason}`,
7092
6884
  }
7093
6885
  }
7094
6886
  }
6887
+ let turnEnv = hookEnv;
6888
+ if (effectiveCwd && turnContext.runtime === "codex") {
6889
+ const tmpDir = join12(effectiveCwd, "node_modules", ".cache", "cabane-tmp", turnId);
6890
+ try {
6891
+ mkdirSync9(tmpDir, { recursive: true });
6892
+ turnEnv = { ...hookEnv, TMPDIR: tmpDir };
6893
+ } catch (err) {
6894
+ turnLog.warn(
6895
+ { err: err instanceof Error ? err.message : String(err), tmpDir },
6896
+ "dispatcher: failed to create per-turn TMPDIR \u2014 proceeding with the inherited temp dir"
6897
+ );
6898
+ }
6899
+ }
7095
6900
  const key = runKey(payload.conversationId, payload.agentId);
7096
6901
  const abortController = new AbortController();
7097
6902
  this.aborts.set(key, abortController);
7098
6903
  let timeoutReason = null;
7099
6904
  try {
7100
- await this.opts.api.setBridgeActiveRun(workspaceId, payload.conversationId, payload.agentId, {
6905
+ await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
7101
6906
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
7102
6907
  // CT33: hand the server this turn's id so the new-run chokepoint's
7103
6908
  // `closeAbandonedTurns` sweep excludes it. The prepare hook may have
@@ -7145,16 +6950,18 @@ ${reason}`,
7145
6950
  subAgentCreate,
7146
6951
  wakeState
7147
6952
  );
7148
- const request = buildBridgeTurnRequest({
6953
+ const request = buildCompanionTurnRequest({
7149
6954
  turnContext,
7150
6955
  baseUrl: this.opts.baseUrl,
7151
6956
  agentPat: this.opts.credential,
7152
6957
  // CT306: the per-turn OBO credential when the API minted one; falls back to
7153
- // the bridge PAT (`agentPat`) inside `buildBridgeTurnRequest` otherwise.
6958
+ // the companion PAT (`agentPat`) inside `buildCompanionTurnRequest` otherwise.
7154
6959
  ...turnContext.turnToken ? { turnToken: turnContext.turnToken } : {},
7155
6960
  // SJ524: the hook-resolved cwd overrides the static local cwd.
7156
6961
  ...effectiveCwd ? { cwd: effectiveCwd } : {},
7157
- ...hookEnv ? { env: hookEnv } : {},
6962
+ // CT804: `turnEnv` = the prepare-hook env plus the per-turn checkout-local
6963
+ // TMPDIR (falls back to `hookEnv` when no cwd was resolved).
6964
+ ...turnEnv ? { env: turnEnv } : {},
7158
6965
  mcpServers: resolvedMcpServers,
7159
6966
  summonServer,
7160
6967
  // CT238: this turn's conversation, forwarded as the active-conversation
@@ -7174,9 +6981,6 @@ ${reason}`,
7174
6981
  if (this.opts.codexEnabled) {
7175
6982
  adapters.push(createCodexAdapter({ enabled: true, onWarn }));
7176
6983
  }
7177
- if (this.opts.cabaneNativeApiKey) {
7178
- adapters.push(createCabaneNativeAdapter({ apiKey: this.opts.cabaneNativeApiKey, onWarn }));
7179
- }
7180
6984
  const registry = createAdapterRegistry(adapters);
7181
6985
  let adapter;
7182
6986
  try {
@@ -7207,6 +7011,58 @@ ${reason}`,
7207
7011
  `runtime_unavailable:${err.runtime}`
7208
7012
  );
7209
7013
  }
7014
+ if (prepareHook && hookEnv?.CABANE_TASK_ID) {
7015
+ const proof = await proveWorkspaceTools(request, adapter.name, {
7016
+ ...this.opts.workspaceProofFetch ? { fetchImpl: this.opts.workspaceProofFetch } : {},
7017
+ harnessFingerprint: turnContext.runtime
7018
+ });
7019
+ turnLog[proof.ok ? "info" : "error"](
7020
+ { workspaceProof: proof, checkout: effectiveCwd ?? null },
7021
+ `dispatcher: workspace tool proof ${proof.ok ? "passed" : "failed"}`
7022
+ );
7023
+ if (effectiveCwd) {
7024
+ try {
7025
+ const diagnosticDir = join12(effectiveCwd, ".git", "cabane");
7026
+ mkdirSync9(diagnosticDir, { recursive: true });
7027
+ appendFileSync2(
7028
+ join12(diagnosticDir, "readiness.jsonl"),
7029
+ `${JSON.stringify({
7030
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7031
+ taskId: hookEnv.CABANE_TASK_ID,
7032
+ binding: hookEnv.CABANE_TASK_BINDING ?? null,
7033
+ checkout: effectiveCwd,
7034
+ classification: proof.ok ? "ready" : "workspace_tools_missing",
7035
+ failedCapability: proof.failedCapability,
7036
+ workspaceTools: proof
7037
+ })}
7038
+ `,
7039
+ { mode: 384 }
7040
+ );
7041
+ } catch (error) {
7042
+ turnLog.warn(
7043
+ { err: error instanceof Error ? error.message : String(error) },
7044
+ "dispatcher: workspace-proof diagnostic write failed"
7045
+ );
7046
+ }
7047
+ }
7048
+ if (!proof.ok) {
7049
+ const reason = `workspace_tools_missing: ${proof.failedCapability}; checkout=${effectiveCwd ?? "unknown"}; runtime=${adapter.name}; recovery=restart the connector after restoring the Cabane workspace tool mount`;
7050
+ try {
7051
+ await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7052
+ body: `**Couldn't prepare your environment.** ${reason}`,
7053
+ kind: "final",
7054
+ turnId,
7055
+ parentMessageId: payload.messageId
7056
+ });
7057
+ } catch (postErr) {
7058
+ turnLog.warn(
7059
+ { err: postErr instanceof Error ? postErr.message : String(postErr) },
7060
+ "dispatcher: workspace-proof failure notice post failed"
7061
+ );
7062
+ }
7063
+ return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
7064
+ }
7065
+ }
7210
7066
  const transcript = this.opts.transcriptDir ? new TranscriptWriter(
7211
7067
  this.opts.transcriptDir,
7212
7068
  {
@@ -7250,6 +7106,49 @@ ${reason}`,
7250
7106
  // server arms the wake schedule atomically with the reply it rode on.
7251
7107
  wakeState
7252
7108
  });
7109
+ const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
7110
+ let turnControlIntentFetched = false;
7111
+ const applyRecordedTurnControlIntent = async () => {
7112
+ if (turnControlIntentFetched || !usesHttpTurnControl || !turnContext.turnToken) return;
7113
+ turnControlIntentFetched = true;
7114
+ try {
7115
+ const intent = await this.opts.api.getTurnIntent(
7116
+ workspaceId,
7117
+ payload.conversationId,
7118
+ payload.agentId,
7119
+ turnId
7120
+ );
7121
+ if (intent.ask) {
7122
+ askState.targetUserId = intent.ask.targetUserId;
7123
+ if (intent.ask.questions && intent.ask.questions.length > 0) {
7124
+ askState.questions = intent.ask.questions;
7125
+ askState.question = null;
7126
+ askState.headline = null;
7127
+ askState.options = null;
7128
+ } else {
7129
+ askState.question = intent.ask.question ?? null;
7130
+ askState.headline = intent.ask.headline ?? null;
7131
+ askState.options = intent.ask.options ?? null;
7132
+ askState.questions = null;
7133
+ }
7134
+ }
7135
+ if (intent.wake) {
7136
+ wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
7137
+ wakeState.at = intent.wake.at ?? null;
7138
+ wakeState.note = intent.wake.note;
7139
+ }
7140
+ if (intent.summonAgentId) summonState.agentId = intent.summonAgentId;
7141
+ if (intent.skipped) {
7142
+ skipState.skipped = true;
7143
+ skipState.reason = intent.skipReason;
7144
+ }
7145
+ } catch (err) {
7146
+ turnLog.warn(
7147
+ { err: err instanceof Error ? err.message : String(err) },
7148
+ "dispatcher: turn-control intent fetch failed; turn-control effects for this turn are dropped"
7149
+ );
7150
+ }
7151
+ };
7253
7152
  const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
7254
7153
  const totalTimeoutMs = this.opts.totalTimeoutMs ?? DEFAULT_AGENT_TOTAL_TIMEOUT_MS;
7255
7154
  const fireTimeout = (reason) => {
@@ -7285,7 +7184,7 @@ ${reason}`,
7285
7184
  if (!sessionWritten) {
7286
7185
  sessionWritten = true;
7287
7186
  try {
7288
- await this.opts.api.setBridgeActiveRun(
7187
+ await this.opts.api.setActiveRun(
7289
7188
  workspaceId,
7290
7189
  payload.conversationId,
7291
7190
  payload.agentId,
@@ -7306,6 +7205,9 @@ ${reason}`,
7306
7205
  turnResolvedConfig = event.resolvedConfig;
7307
7206
  } else if (event.type === "text" && skipState.skipped) {
7308
7207
  } else {
7208
+ if (event.type === "text" && event.terminal) {
7209
+ await applyRecordedTurnControlIntent();
7210
+ }
7309
7211
  await committer.ingestEvent(event);
7310
7212
  }
7311
7213
  }
@@ -7316,18 +7218,28 @@ ${reason}`,
7316
7218
  if (!okResult && !resultReason) {
7317
7219
  resultReason = "no_result";
7318
7220
  }
7221
+ if (!abortController.signal.aborted) {
7222
+ await applyRecordedTurnControlIntent();
7223
+ }
7319
7224
  if (!abortController.signal.aborted && skipState.skipped) {
7320
7225
  turnLog.info(
7321
7226
  { reason: skipState.reason, turnId, ok: okResult },
7322
7227
  "agent skipped turn (skip_turn)"
7323
7228
  );
7229
+ const { afterSeconds: wakeAfter, at: wakeAt, note: wakeNote } = wakeState;
7230
+ const skipWake = wakeNote && (wakeAfter !== null || wakeAt !== null) ? {
7231
+ ...wakeAfter !== null ? { afterSeconds: wakeAfter } : {},
7232
+ ...wakeAt !== null ? { at: wakeAt } : {},
7233
+ note: wakeNote
7234
+ } : void 0;
7324
7235
  try {
7325
7236
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7326
7237
  body: SKIPPED_MARKER_BODY,
7327
7238
  kind: "skipped",
7328
7239
  turnId,
7329
7240
  seq: nextSeq(),
7330
- parentMessageId: payload.messageId
7241
+ parentMessageId: payload.messageId,
7242
+ ...skipWake ? { wake: skipWake } : {}
7331
7243
  });
7332
7244
  } catch (err) {
7333
7245
  turnLog.warn(
@@ -7413,7 +7325,7 @@ ${reason}`,
7413
7325
  errorReason: body.errorReason ?? null
7414
7326
  });
7415
7327
  try {
7416
- await this.opts.api.setBridgeActiveRun(
7328
+ await this.opts.api.setActiveRun(
7417
7329
  workspaceId,
7418
7330
  payload.conversationId,
7419
7331
  payload.agentId,
@@ -7465,7 +7377,7 @@ ${reason}`,
7465
7377
  };
7466
7378
  }
7467
7379
  // 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.
7380
+ // THIS companion process. Returns true if an in-flight run was aborted.
7469
7381
  cancel(conversationId, agentId) {
7470
7382
  const key = runKey(conversationId, agentId);
7471
7383
  const ac = this.aborts.get(key);
@@ -7479,27 +7391,17 @@ ${reason}`,
7479
7391
  };
7480
7392
 
7481
7393
  // src/manifest.ts
7482
- var BRIDGE_MANIFEST = {
7394
+ var DEVICE_MANIFEST = {
7483
7395
  runtimes: [{ name: "claude-code", version: null }],
7484
7396
  capabilities: { hostFs: true, browser: true, userMcp: true }
7485
7397
  };
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
- }
7398
+ function buildCompanionManifest(opts) {
7496
7399
  const v = opts.versions ?? {};
7497
7400
  const runtimes = [];
7498
7401
  if (opts.claudeCode) runtimes.push({ name: "claude-code", version: v.claudeCode ?? null });
7499
7402
  if (opts.opencode) runtimes.push({ name: "opencode", version: v.opencode ?? null });
7500
7403
  if (opts.codex) runtimes.push({ name: "codex", version: v.codex ?? null });
7501
- if (opts.cabaneNative) runtimes.push({ name: "cabane-native", version: null });
7502
- return { runtimes, capabilities: { ...BRIDGE_MANIFEST.capabilities } };
7404
+ return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
7503
7405
  }
7504
7406
 
7505
7407
  // src/harness-status.ts
@@ -7510,7 +7412,7 @@ var LABELS = {
7510
7412
  };
7511
7413
  function deriveHarnessSnapshot(signals) {
7512
7414
  const advertised = new Set(
7513
- buildBridgeManifest({
7415
+ buildCompanionManifest({
7514
7416
  claudeCode: signals.claudeOnPath,
7515
7417
  opencode: signals.opencodeConfigured,
7516
7418
  codex: signals.codexEnabled
@@ -7700,14 +7602,14 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
7700
7602
  // src/outbox.ts
7701
7603
  import {
7702
7604
  existsSync as existsSync10,
7703
- mkdirSync as mkdirSync9,
7605
+ mkdirSync as mkdirSync10,
7704
7606
  readdirSync as readdirSync2,
7705
7607
  readFileSync as readFileSync8,
7706
7608
  renameSync as renameSync3,
7707
7609
  rmSync as rmSync5,
7708
7610
  writeFileSync as writeFileSync7
7709
7611
  } from "fs";
7710
- import { join as join12 } from "path";
7612
+ import { join as join13 } from "path";
7711
7613
  var MAX_ENTRIES = 2e3;
7712
7614
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
7713
7615
  var Outbox = class {
@@ -7720,17 +7622,17 @@ var Outbox = class {
7720
7622
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
7721
7623
  // cases route writes at the right tmpdir.
7722
7624
  dir() {
7723
- return join12(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7625
+ return join13(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7724
7626
  }
7725
7627
  fileFor(turnId, seq) {
7726
- return join12(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
7628
+ return join13(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
7727
7629
  }
7728
7630
  // Persist a commit for later draining. Atomic (temp file + rename) so a
7729
7631
  // concurrent `list()` never reads a half-written entry, then enforces the
7730
7632
  // per-workspace bounds.
7731
7633
  persist(entry) {
7732
7634
  const dir2 = this.dir();
7733
- mkdirSync9(dir2, { recursive: true });
7635
+ mkdirSync10(dir2, { recursive: true });
7734
7636
  const target = this.fileFor(entry.turnId, entry.seq);
7735
7637
  const tmp = `${target}.${process.pid}.tmp`;
7736
7638
  try {
@@ -7743,7 +7645,7 @@ var Outbox = class {
7743
7645
  }
7744
7646
  this.log?.warn(
7745
7647
  { workspaceId: this.workspaceId, err: err instanceof Error ? err.message : String(err) },
7746
- "bridge outbox: failed to persist entry"
7648
+ "companion outbox: failed to persist entry"
7747
7649
  );
7748
7650
  return;
7749
7651
  }
@@ -7765,7 +7667,7 @@ var Outbox = class {
7765
7667
  const entries = [];
7766
7668
  for (const name of names) {
7767
7669
  if (!name.endsWith(".json")) continue;
7768
- const full = join12(dir2, name);
7670
+ const full = join13(dir2, name);
7769
7671
  try {
7770
7672
  const parsed = JSON.parse(readFileSync8(full, "utf8"));
7771
7673
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -7801,7 +7703,7 @@ var Outbox = class {
7801
7703
  dropCorrupt(full) {
7802
7704
  this.log?.warn(
7803
7705
  { workspaceId: this.workspaceId, file: full },
7804
- "bridge outbox: dropping unreadable entry"
7706
+ "companion outbox: dropping unreadable entry"
7805
7707
  );
7806
7708
  try {
7807
7709
  rmSync5(full, { force: true });
@@ -7818,7 +7720,7 @@ var Outbox = class {
7818
7720
  if (now - e.enqueuedAt > MAX_AGE_MS) {
7819
7721
  this.log?.warn(
7820
7722
  { workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
7821
- "bridge outbox: evicting entry past max age (undeliverable)"
7723
+ "companion outbox: evicting entry past max age (undeliverable)"
7822
7724
  );
7823
7725
  this.remove(e.turnId, e.seq);
7824
7726
  } else {
@@ -7830,7 +7732,7 @@ var Outbox = class {
7830
7732
  for (const e of survivors.slice(0, overflow)) {
7831
7733
  this.log?.warn(
7832
7734
  { workspaceId: this.workspaceId, turnId: e.turnId, seq: e.seq, kind: e.kind },
7833
- "bridge outbox: evicting oldest entry past max size"
7735
+ "companion outbox: evicting oldest entry past max size"
7834
7736
  );
7835
7737
  this.remove(e.turnId, e.seq);
7836
7738
  }
@@ -7839,40 +7741,43 @@ var Outbox = class {
7839
7741
  };
7840
7742
 
7841
7743
  // src/run-config.ts
7842
- import { z as z16 } from "zod";
7843
- var mcpStdioServerSchema = z16.object({
7844
- type: z16.literal("stdio").optional(),
7845
- command: z16.string().min(1),
7846
- args: z16.array(z16.string()).optional(),
7847
- env: z16.record(z16.string(), z16.string()).optional()
7744
+ import { z as z14 } from "zod";
7745
+ var mcpStdioServerSchema = z14.object({
7746
+ type: z14.literal("stdio").optional(),
7747
+ command: z14.string().min(1),
7748
+ args: z14.array(z14.string()).optional(),
7749
+ env: z14.record(z14.string(), z14.string()).optional()
7848
7750
  });
7849
- var mcpHttpServerSchema = z16.object({
7850
- type: z16.literal("http"),
7851
- url: z16.string().url(),
7852
- headers: z16.record(z16.string(), z16.string()).optional()
7751
+ var mcpHttpServerSchema = z14.object({
7752
+ type: z14.literal("http"),
7753
+ url: z14.string().url(),
7754
+ headers: z14.record(z14.string(), z14.string()).optional()
7853
7755
  });
7854
- var mcpSseServerSchema = z16.object({
7855
- type: z16.literal("sse"),
7856
- url: z16.string().url(),
7857
- headers: z16.record(z16.string(), z16.string()).optional()
7756
+ var mcpSseServerSchema = z14.object({
7757
+ type: z14.literal("sse"),
7758
+ url: z14.string().url(),
7759
+ headers: z14.record(z14.string(), z14.string()).optional()
7858
7760
  });
7859
- var mcpServerDefSchema = z16.union([
7761
+ var mcpServerDefSchema = z14.union([
7860
7762
  mcpHttpServerSchema,
7861
7763
  mcpSseServerSchema,
7862
7764
  mcpStdioServerSchema
7863
7765
  ]);
7864
- var thinkingConfigSchema = z16.discriminatedUnion("type", [
7865
- z16.object({ type: z16.literal("adaptive") }),
7866
- z16.object({ type: z16.literal("enabled"), budgetTokens: z16.number().int().positive().optional() }),
7867
- z16.object({ type: z16.literal("disabled") })
7766
+ var thinkingConfigSchema = z14.discriminatedUnion("type", [
7767
+ z14.object({ type: z14.literal("adaptive") }),
7768
+ z14.object({ type: z14.literal("enabled"), budgetTokens: z14.number().int().positive().optional() }),
7769
+ z14.object({ type: z14.literal("disabled") })
7868
7770
  ]);
7869
- var effortSchema = z16.enum(["low", "medium", "high", "xhigh", "max"]);
7870
- var runConfigSchema = z16.object({
7871
- mode: z16.enum(["assistant", "coding", "custom"]).optional(),
7872
- allowedTools: z16.array(z16.string()).optional(),
7873
- disallowedTools: z16.array(z16.string()).optional(),
7874
- mcpServers: z16.record(z16.string(), mcpServerDefSchema).optional(),
7875
- model: z16.string().min(1).optional(),
7771
+ var effortSchema = z14.enum(["low", "medium", "high", "xhigh", "max"]);
7772
+ var runConfigSchema = z14.object({
7773
+ // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
7774
+ // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
7775
+ // is the locked surface. Kept in lockstep with `@cabane/shared`'s
7776
+ // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
7777
+ // stripped, so an older companion riding a newer server never rejects the config).
7778
+ hostAccess: z14.boolean().optional(),
7779
+ mcpServers: z14.record(z14.string(), mcpServerDefSchema).optional(),
7780
+ model: z14.string().min(1).optional(),
7876
7781
  thinking: thinkingConfigSchema.optional(),
7877
7782
  effort: effortSchema.optional()
7878
7783
  });
@@ -7993,7 +7898,7 @@ function sleep2(ms) {
7993
7898
  // src/version.ts
7994
7899
  import { createRequire as createRequire2 } from "module";
7995
7900
  var pkg = createRequire2(import.meta.url)("../package.json");
7996
- var BRIDGE_VERSION = pkg.version;
7901
+ var COMPANION_VERSION = pkg.version;
7997
7902
 
7998
7903
  // src/supervisor.ts
7999
7904
  var HEARTBEAT_INTERVAL_MS = 3e4;
@@ -8007,7 +7912,7 @@ var ASSIGNMENTS_POLL_MS = 6e4;
8007
7912
  var DRAIN_BASE_MS = 1e3;
8008
7913
  var DRAIN_MAX_MS = 3e4;
8009
7914
  var DRAIN_IDLE_MS = 15e3;
8010
- var BridgeSupervisor = class {
7915
+ var CompanionSupervisor = class {
8011
7916
  workspaces = /* @__PURE__ */ new Map();
8012
7917
  config;
8013
7918
  log;
@@ -8031,10 +7936,12 @@ var BridgeSupervisor = class {
8031
7936
  dispatcherFactory;
8032
7937
  deviceApi = null;
8033
7938
  heartbeatTimer = null;
7939
+ inFlightHeartbeat = null;
8034
7940
  pollTimer = null;
8035
7941
  refreshing = false;
8036
7942
  stopped = false;
8037
- // CT484: latch so the bridge/server version-skew warning is logged once, not
7943
+ draining = false;
7944
+ // CT484: latch so the companion/server version-skew warning is logged once, not
8038
7945
  // on every 30s heartbeat.
8039
7946
  versionSkewWarned = false;
8040
7947
  // This device's id, captured from the heartbeat / assignments response. The SSE
@@ -8060,18 +7967,18 @@ var BridgeSupervisor = class {
8060
7967
  this.dispatcherFactory = opts.dispatcherFactory;
8061
7968
  }
8062
7969
  // 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
7970
+ // heartbeat + poll loops. A companion with no device token (logged out) does
8064
7971
  // nothing but say so.
8065
7972
  async start() {
8066
7973
  this.log.info(
8067
- { protocolVersion: TURN_PROTOCOL_VERSION, version: BRIDGE_VERSION },
8068
- "bridge: starting"
7974
+ { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
7975
+ "companion: starting"
8069
7976
  );
8070
7977
  void this.refreshHarnessStatuses();
8071
7978
  if (!this.config.deviceToken) {
8072
- this.log.warn("bridge: not paired (no device token) \u2014 run `cabane-companion pair`");
7979
+ this.log.warn("companion: not paired (no device token) \u2014 run `cabane-companion pair`");
8073
7980
  process.stdout.write(
8074
- "bridge: this device is not paired \u2014 run `cabane-companion pair` and paste the string from the cabane app.\n"
7981
+ "companion: this device is not paired \u2014 run `cabane-companion pair`, then confirm the short code in Settings \u2192 Connectors.\n"
8075
7982
  );
8076
7983
  return;
8077
7984
  }
@@ -8080,8 +7987,8 @@ var BridgeSupervisor = class {
8080
7987
  deviceToken: this.config.deviceToken
8081
7988
  });
8082
7989
  await this.refreshAssignments();
8083
- void this.sendHeartbeat();
8084
- this.heartbeatTimer = setInterval(() => void this.sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
7990
+ this.kickHeartbeat();
7991
+ this.heartbeatTimer = setInterval(() => this.kickHeartbeat(), HEARTBEAT_INTERVAL_MS);
8085
7992
  this.heartbeatTimer.unref?.();
8086
7993
  this.pollTimer = setInterval(() => void this.refreshAssignments(), ASSIGNMENTS_POLL_MS);
8087
7994
  this.pollTimer.unref?.();
@@ -8094,20 +8001,28 @@ var BridgeSupervisor = class {
8094
8001
  return [...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : []);
8095
8002
  }
8096
8003
  // ---- device-level loops ----
8004
+ kickHeartbeat() {
8005
+ if (this.draining || this.inFlightHeartbeat) return;
8006
+ const pending = this.sendHeartbeat();
8007
+ this.inFlightHeartbeat = pending;
8008
+ void pending.finally(() => {
8009
+ if (this.inFlightHeartbeat === pending) this.inFlightHeartbeat = null;
8010
+ });
8011
+ }
8097
8012
  async sendHeartbeat() {
8098
8013
  if (!this.deviceApi) return;
8099
8014
  await this.refreshHarnessStatuses();
8100
8015
  try {
8101
- const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "bridge: secrets"));
8016
+ const store = loadSecretStoreTolerant((m) => this.log.warn({ msg: m }, "companion: secrets"));
8102
8017
  const connectorReports = this.connectorHealth.reports();
8103
8018
  const opencodeModels = this.config.opencode?.serverUrl ? await enumerateOpencodeModels(this.config.opencode.serverUrl) : null;
8104
8019
  const res = await this.deviceApi.heartbeat({
8105
- version: BRIDGE_VERSION,
8020
+ version: COMPANION_VERSION,
8106
8021
  exposedSecretNames: store.names(),
8107
8022
  // Report each runtime only when this device can actually run it: CT309
8108
8023
  // claude-code when `claude` is on PATH, CT270 opencode when the operator
8109
8024
  // configured an `opencode serve`.
8110
- manifest: buildBridgeManifest({
8025
+ manifest: buildCompanionManifest({
8111
8026
  // CT586: prefer the live re-probe's presence; fall back to the boot probe
8112
8027
  // until the first re-probe lands. Same exit-0 `claude --version` signal
8113
8028
  // either way, so the manifest's claude-code advertising is unchanged in
@@ -8118,17 +8033,13 @@ var BridgeSupervisor = class {
8118
8033
  // like opencode — the CLI's presence is the operator's responsibility;
8119
8034
  // a misconfigured device fails the turn loudly, never silently).
8120
8035
  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.
8124
- cabaneNative: isCabaneNativeEnabled(),
8125
8036
  // CT571/CT586: each runtime's `version` from the latest harness probe
8126
8037
  // (fail-soft to null). Informational only — the server matches on name.
8127
8038
  versions: this.harnessVersions
8128
8039
  }),
8129
8040
  // 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
8041
+ // companion has seen any. Omitted (undefined) until the first observed
8042
+ // failure/heal, so a fresh companion's beat is unchanged and the server's
8132
8043
  // manifest synthesis (status-less rows) still runs.
8133
8044
  ...connectorReports.length > 0 ? { connectors: connectorReports } : {},
8134
8045
  // CT584: include enumerated models only when the probe SUCCEEDED (non-null).
@@ -8142,22 +8053,22 @@ var BridgeSupervisor = class {
8142
8053
  } catch (err) {
8143
8054
  this.log.warn(
8144
8055
  { err: err instanceof Error ? err.message : String(err) },
8145
- "bridge: device heartbeat failed (will retry on next tick)"
8056
+ "companion: device heartbeat failed (will retry on next tick)"
8146
8057
  );
8147
8058
  }
8148
8059
  }
8149
- // CT484: belt-and-suspenders beside shipping the bridge in lockstep inside the
8060
+ // CT484: belt-and-suspenders beside shipping the companion in lockstep inside the
8150
8061
  // 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.
8062
+ // companion's, warn loudly (once). This is exactly the skew that bit the M1 walk:
8063
+ // an npm-pinned companion lagging a from-develop server. The elimination (bundled
8064
+ // companion) makes it match by construction; this catches a companion run out of band.
8154
8065
  checkVersionSkew(serverVersion) {
8155
8066
  if (this.versionSkewWarned) return;
8156
- if (!serverVersion || serverVersion === BRIDGE_VERSION) return;
8067
+ if (!serverVersion || serverVersion === COMPANION_VERSION) return;
8157
8068
  this.versionSkewWarned = true;
8158
8069
  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."
8070
+ { companionVersion: COMPANION_VERSION, serverVersion },
8071
+ "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
8072
  );
8162
8073
  }
8163
8074
  // Pull assignments and reconcile the live runner set against them. Re-entrancy
@@ -8180,7 +8091,7 @@ var BridgeSupervisor = class {
8180
8091
  } catch (err) {
8181
8092
  this.log.error(
8182
8093
  { err: err instanceof Error ? err.message : String(err) },
8183
- "bridge: assignments pull failed \u2014 check the device is still active in the cabane app"
8094
+ "companion: assignments pull failed \u2014 check the device is still active in the cabane app"
8184
8095
  );
8185
8096
  this.hub.setDeviceError(err instanceof Error ? err.message : String(err));
8186
8097
  return;
@@ -8245,21 +8156,21 @@ var BridgeSupervisor = class {
8245
8156
  const credential = it.credential ?? getCredential(it.agentId);
8246
8157
  const runConfig = parseRunConfig(
8247
8158
  it.runConfig,
8248
- (m) => this.log.warn({ agentId: it.agentId, msg: m }, "bridge: run-config")
8159
+ (m) => this.log.warn({ agentId: it.agentId, msg: m }, "companion: run-config")
8249
8160
  );
8250
8161
  const required = requiredSecretNames(runConfig.mcpServers);
8251
8162
  const missing = required.filter((n) => !exposed.has(n));
8252
8163
  if (!credential) {
8253
8164
  this.log.error(
8254
8165
  { 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"
8166
+ "companion: agent assigned but no credential on this device \u2014 re-assign it in the cabane app"
8256
8167
  );
8257
8168
  this.removeAgent(wr, it.agentId);
8258
8169
  this.hub.setAgent(workspaceId, {
8259
8170
  agentId: it.agentId,
8260
8171
  username: it.agentUsername,
8261
8172
  displayName: it.agentDisplayName,
8262
- mode: runConfig.mode ?? "assistant",
8173
+ mode: runConfig.hostAccess ? "full" : "none",
8263
8174
  hasCredential: false,
8264
8175
  missingSecrets: missing
8265
8176
  });
@@ -8279,14 +8190,14 @@ var BridgeSupervisor = class {
8279
8190
  agentId: it.agentId,
8280
8191
  username: it.agentUsername,
8281
8192
  displayName: it.agentDisplayName,
8282
- mode: runConfig.mode ?? "assistant",
8193
+ mode: runConfig.hostAccess ? "full" : "none",
8283
8194
  hasCredential: true,
8284
8195
  missingSecrets: missing
8285
8196
  });
8286
8197
  if (missing.length > 0) {
8287
8198
  this.log.warn(
8288
8199
  { workspaceId, agentId: it.agentId, missing },
8289
- "bridge: agent needs secrets this device does not expose (turns using them will fail)"
8200
+ "companion: agent needs secrets this device does not expose (turns using them will fail)"
8290
8201
  );
8291
8202
  }
8292
8203
  }
@@ -8325,7 +8236,7 @@ var BridgeSupervisor = class {
8325
8236
  drain2.kick();
8326
8237
  this.log.info(
8327
8238
  { workspaceId: it.workspaceId, agentId: it.agentId, username: it.agentUsername },
8328
- "bridge: running agent"
8239
+ "companion: running agent"
8329
8240
  );
8330
8241
  }
8331
8242
  removeAgent(wr, agentId) {
@@ -8334,7 +8245,10 @@ var BridgeSupervisor = class {
8334
8245
  runner.cancelDrain();
8335
8246
  wr.agents.delete(agentId);
8336
8247
  this.hub.removeAgent(wr.workspaceId, agentId);
8337
- this.log.info({ workspaceId: wr.workspaceId, agentId }, "bridge: stopped agent (unassigned)");
8248
+ this.log.info(
8249
+ { workspaceId: wr.workspaceId, agentId },
8250
+ "companion: stopped agent (unassigned)"
8251
+ );
8338
8252
  }
8339
8253
  buildDispatcher(ctx) {
8340
8254
  if (this.dispatcherFactory) return this.dispatcherFactory(ctx);
@@ -8360,12 +8274,9 @@ var BridgeSupervisor = class {
8360
8274
  // CT481: register the codex adapter when this device offers codex; unset
8361
8275
  // leaves an `openai/…` turn to fail loudly (no silent claude-code fallback).
8362
8276
  ...isCodexEnabled(this.config) ? { codexEnabled: true } : {},
8363
- // CT598: register the cabane-native adapter when an OpenRouter key is set;
8364
- // unset leaves a `cabane-native/…` turn to fail loudly (no silent fallback).
8365
- ...cabaneNativeApiKey() ? { cabaneNativeApiKey: cabaneNativeApiKey() } : {},
8366
- // CT556: per-turn timeout watchdog windows, from the bridge's own env
8277
+ // CT556: per-turn timeout watchdog windows, from the companion's own env
8367
8278
  // (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
8368
- // dispatcher's baked-in defaults (10 min idle / 45 min total).
8279
+ // dispatcher's baked-in defaults (10 min idle / 6h total).
8369
8280
  ...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
8370
8281
  ...positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") !== void 0 ? { totalTimeoutMs: positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") } : {},
8371
8282
  observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
@@ -8402,14 +8313,14 @@ var BridgeSupervisor = class {
8402
8313
  this.hub.setAuthFailed(wr.workspaceId);
8403
8314
  this.log.error(
8404
8315
  { workspaceId: wr.workspaceId, status, sseAgentId: wr.sseAgentId },
8405
- "bridge: workspace stream auth failed \u2014 re-pulling assignments"
8316
+ "companion: workspace stream auth failed \u2014 re-pulling assignments"
8406
8317
  );
8407
8318
  wr.sseAgentId = null;
8408
8319
  void this.refreshAssignments();
8409
8320
  }
8410
8321
  });
8411
8322
  wr.sub.start();
8412
- this.log.info({ workspaceId: wr.workspaceId }, "bridge: subscribed");
8323
+ this.log.info({ workspaceId: wr.workspaceId }, "companion: subscribed");
8413
8324
  }
8414
8325
  async removeWorkspace(workspaceId) {
8415
8326
  const wr = this.workspaces.get(workspaceId);
@@ -8440,9 +8351,9 @@ var BridgeSupervisor = class {
8440
8351
  return;
8441
8352
  }
8442
8353
  if (ev.id) wr.cursor.observe(ev.id);
8443
- if (wire.type === "bridge:cancel_requested") {
8354
+ if (wire.type === "device:cancel_requested") {
8444
8355
  const payload2 = {
8445
- type: "bridge:cancel_requested",
8356
+ type: "device:cancel_requested",
8446
8357
  ...wire.payload
8447
8358
  };
8448
8359
  const agent2 = wr.agents.get(payload2.agentId);
@@ -8462,12 +8373,13 @@ var BridgeSupervisor = class {
8462
8373
  if (ev.id) wr.cursor.settle(ev.id);
8463
8374
  return;
8464
8375
  }
8465
- if (wire.type !== "bridge:dispatch_requested") {
8376
+ if (wire.type !== "device:dispatch_requested") {
8466
8377
  if (ev.id) wr.cursor.settle(ev.id);
8467
8378
  return;
8468
8379
  }
8380
+ if (this.draining) return;
8469
8381
  const payload = {
8470
- type: "bridge:dispatch_requested",
8382
+ type: "device:dispatch_requested",
8471
8383
  ...wire.payload
8472
8384
  };
8473
8385
  let agent = wr.agents.get(payload.agentId);
@@ -8488,7 +8400,7 @@ var BridgeSupervisor = class {
8488
8400
  agentId: payload.agentId,
8489
8401
  err: err instanceof Error ? err.message : String(err)
8490
8402
  },
8491
- "bridge: conversation turn handler threw"
8403
+ "companion: conversation turn handler threw"
8492
8404
  );
8493
8405
  });
8494
8406
  wr.chains.set(chainKey, tail);
@@ -8516,7 +8428,7 @@ var BridgeSupervisor = class {
8516
8428
  if (hasCompleted(workspaceId, ev.id)) {
8517
8429
  this.log.info(
8518
8430
  { workspaceId, eventId: ev.id },
8519
- "bridge: skipping already-completed event (resume after restart)"
8431
+ "companion: skipping already-completed event (resume after restart)"
8520
8432
  );
8521
8433
  wr.cursor.settle(ev.id);
8522
8434
  return;
@@ -8524,7 +8436,7 @@ var BridgeSupervisor = class {
8524
8436
  if (noResume()) {
8525
8437
  this.log.warn(
8526
8438
  { workspaceId, eventId: ev.id },
8527
- "bridge: skipping interrupted turn (CABANE_BRIDGE_NO_RESUME=1) \u2014 resume disabled for this boot"
8439
+ "companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
8528
8440
  );
8529
8441
  markCompleted(workspaceId, ev.id);
8530
8442
  wr.cursor.settle(ev.id);
@@ -8534,7 +8446,7 @@ var BridgeSupervisor = class {
8534
8446
  if (attempt > MAX_RESUME_ATTEMPTS) {
8535
8447
  this.log.error(
8536
8448
  { 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)"
8449
+ "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
8450
  );
8539
8451
  markCompleted(workspaceId, ev.id);
8540
8452
  wr.cursor.settle(ev.id);
@@ -8542,7 +8454,7 @@ var BridgeSupervisor = class {
8542
8454
  }
8543
8455
  this.log.info(
8544
8456
  { workspaceId, eventId: ev.id, attempt, cap: MAX_RESUME_ATTEMPTS },
8545
- "bridge: re-dispatching interrupted turn (resume after restart)"
8457
+ "companion: re-dispatching interrupted turn (resume after restart)"
8546
8458
  );
8547
8459
  }
8548
8460
  if (ev.id) markDispatched(workspaceId, ev.id);
@@ -8582,7 +8494,7 @@ var BridgeSupervisor = class {
8582
8494
  drainDelay = Math.min(drainDelay * 2, DRAIN_MAX_MS);
8583
8495
  this.log.warn(
8584
8496
  { agentId, err: err instanceof Error ? err.message : String(err) },
8585
- "bridge: outbox drain pass threw (will retry with backoff)"
8497
+ "companion: outbox drain pass threw (will retry with backoff)"
8586
8498
  );
8587
8499
  } finally {
8588
8500
  if (!drainStopped) {
@@ -8651,7 +8563,7 @@ var BridgeSupervisor = class {
8651
8563
  } catch (err) {
8652
8564
  this.log.warn(
8653
8565
  { err: err instanceof Error ? err.message : String(err) },
8654
- "bridge: harness probe failed (will retry on next beat)"
8566
+ "companion: harness probe failed (will retry on next beat)"
8655
8567
  );
8656
8568
  }
8657
8569
  }
@@ -8676,7 +8588,7 @@ var BridgeSupervisor = class {
8676
8588
  next = { ...this.config, codex: { enabled: true } };
8677
8589
  } else {
8678
8590
  const serverUrl = input.serverUrl.trim();
8679
- const parsed = bridgeConfigSchema.shape.opencode.safeParse({ serverUrl });
8591
+ const parsed = companionConfigSchema.shape.opencode.safeParse({ serverUrl });
8680
8592
  if (!parsed.success) {
8681
8593
  return {
8682
8594
  ok: false,
@@ -8697,7 +8609,7 @@ var BridgeSupervisor = class {
8697
8609
  saveConfig(next);
8698
8610
  this.rebuildDispatchers();
8699
8611
  await this.refreshHarnessStatuses();
8700
- void this.sendHeartbeat();
8612
+ this.kickHeartbeat();
8701
8613
  return { ok: true };
8702
8614
  }
8703
8615
  // Re-create every running agent's Dispatcher from the CURRENT config, keeping
@@ -8733,6 +8645,41 @@ var BridgeSupervisor = class {
8733
8645
  [...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
8734
8646
  );
8735
8647
  }
8648
+ async drainForRestart(graceMs) {
8649
+ this.draining = true;
8650
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
8651
+ if (this.pollTimer) clearInterval(this.pollTimer);
8652
+ if (this.inFlightHeartbeat) await this.inFlightHeartbeat;
8653
+ if (!this.deviceApi) throw new Error("cannot establish deploy drain before pairing");
8654
+ await this.deviceApi.beginDrain();
8655
+ for (const wr of this.workspaces.values()) wr.sub?.stop();
8656
+ const turns = [...this.workspaces.values()].flatMap((wr) => [...wr.chains.values()]);
8657
+ let timedOut = false;
8658
+ if (turns.length > 0) {
8659
+ let timer;
8660
+ await Promise.race([
8661
+ Promise.allSettled(turns),
8662
+ new Promise((resolve) => {
8663
+ timer = setTimeout(
8664
+ () => {
8665
+ timedOut = true;
8666
+ resolve();
8667
+ },
8668
+ Math.max(0, graceMs)
8669
+ );
8670
+ timer.unref?.();
8671
+ })
8672
+ ]);
8673
+ if (timer) clearTimeout(timer);
8674
+ }
8675
+ await Promise.allSettled(
8676
+ [...this.workspaces.values()].flatMap(
8677
+ (wr) => [...wr.agents.values()].map((agent) => agent.api.drainOutbox())
8678
+ )
8679
+ );
8680
+ await this.shutdown();
8681
+ return { drained: !timedOut };
8682
+ }
8736
8683
  async requestStop() {
8737
8684
  await this.shutdown();
8738
8685
  this.exitFn(0);
@@ -8804,25 +8751,25 @@ function handleUncaught(log, err, origin) {
8804
8751
  if (isRecoverableSocketError(err)) {
8805
8752
  log.warn(
8806
8753
  { origin, code, err: message },
8807
- "bridge: recovered from a socket error (kept running \u2014 a broken pipe never kills the bridge)"
8754
+ "companion: recovered from a socket error (kept running \u2014 a broken pipe never kills the companion)"
8808
8755
  );
8809
8756
  return;
8810
8757
  }
8811
8758
  log.error(
8812
8759
  { origin, code, err: message, stack: err instanceof Error ? err.stack : void 0 },
8813
- "bridge: uncaught error (kept running \u2014 see the stack above)"
8760
+ "companion: uncaught error (kept running \u2014 see the stack above)"
8814
8761
  );
8815
8762
  }
8816
8763
 
8817
8764
  // 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";
8765
+ import { existsSync as existsSync11, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "fs";
8766
+ import { join as join14 } from "path";
8820
8767
  function crashMarkerPath() {
8821
- return join13(cabaneDir(), "last-error.json");
8768
+ return join14(cabaneDir(), "last-error.json");
8822
8769
  }
8823
8770
  function recordCrash(rec) {
8824
8771
  try {
8825
- mkdirSync10(cabaneDir(), { recursive: true });
8772
+ mkdirSync11(cabaneDir(), { recursive: true });
8826
8773
  writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
8827
8774
  } catch {
8828
8775
  }
@@ -8836,7 +8783,7 @@ function clearCrash() {
8836
8783
  }
8837
8784
 
8838
8785
  // src/runtime.ts
8839
- async function createBridgeRuntime(opts = {}) {
8786
+ async function createCompanionRuntime(opts = {}) {
8840
8787
  const log = getLogger();
8841
8788
  installProcessSafetyNet(log);
8842
8789
  const probeClaude = opts.probeClaude ?? claudeOnPath;
@@ -8870,23 +8817,29 @@ async function createBridgeRuntime(opts = {}) {
8870
8817
  url: "",
8871
8818
  port: 0,
8872
8819
  startedAt,
8873
- daemon: process.env.CABANE_BRIDGE_DAEMON === "1",
8820
+ daemon: process.env.CABANE_COMPANION_DAEMON === "1",
8874
8821
  instanceId
8875
8822
  });
8876
8823
  if (!claim.acquired) {
8877
8824
  return { ok: false, reason: "already-running", existing: claim.existing ?? null };
8878
8825
  }
8879
8826
  process.on("exit", () => clearRuntimeState());
8880
- const hub = new BridgeStateHub({
8827
+ const hub = new CompanionStateHub({
8881
8828
  // CT29: one device, one base URL — the cabane instance this device is paired
8882
8829
  // with. The dashboard's connection line shows it.
8883
8830
  baseUrl: cfg.baseUrl,
8884
- bridgeVersion: BRIDGE_VERSION,
8831
+ companionVersion: COMPANION_VERSION,
8885
8832
  // 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).
8833
+ // process behind the marker pid is this companion (not a recycled pid).
8887
8834
  instanceId
8888
8835
  });
8889
- const supervisor = new BridgeSupervisor({ config: cfg, log, hub, claudeCode, harnessVersions });
8836
+ const supervisor = new CompanionSupervisor({
8837
+ config: cfg,
8838
+ log,
8839
+ hub,
8840
+ claudeCode,
8841
+ harnessVersions
8842
+ });
8890
8843
  await supervisor.start();
8891
8844
  const preferredPort = opts.port ?? cfg.dashboardPort;
8892
8845
  const dashboard = await startDashboard({
@@ -8901,9 +8854,9 @@ async function createBridgeRuntime(opts = {}) {
8901
8854
  port: dashboard.port,
8902
8855
  startedAt,
8903
8856
  // SJ495: the daemon launcher sets this env on the detached child, so the
8904
- // marker records whether this bridge is backgrounded (foreground start
8857
+ // marker records whether this companion is backgrounded (foreground start
8905
8858
  // leaves it unset → false).
8906
- daemon: process.env.CABANE_BRIDGE_DAEMON === "1",
8859
+ daemon: process.env.CABANE_COMPANION_DAEMON === "1",
8907
8860
  instanceId
8908
8861
  });
8909
8862
  clearCrash();
@@ -8920,9 +8873,21 @@ async function createBridgeRuntime(opts = {}) {
8920
8873
  };
8921
8874
  return {
8922
8875
  ok: true,
8923
- runtime: { url: dashboard.url, port: dashboard.port, config: cfg, stop }
8876
+ runtime: {
8877
+ url: dashboard.url,
8878
+ port: dashboard.port,
8879
+ config: cfg,
8880
+ stop,
8881
+ drainForRestart: async (graceMs) => {
8882
+ clearRuntimeState();
8883
+ const result = await supervisor.drainForRestart(graceMs);
8884
+ await dashboard.close();
8885
+ stopped = true;
8886
+ return result;
8887
+ }
8888
+ }
8924
8889
  };
8925
8890
  }
8926
8891
  export {
8927
- createBridgeRuntime
8892
+ createCompanionRuntime
8928
8893
  };