@cabane/companion 0.6.216 → 0.6.217

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
@@ -1,6 +1,6 @@
1
1
  // src/runtime.ts
2
2
  import { randomUUID as randomUUID7 } from "crypto";
3
- import { fileURLToPath as fileURLToPath6 } from "url";
3
+ import { fileURLToPath as fileURLToPath5 } from "url";
4
4
 
5
5
  // src/cabane-command.ts
6
6
  import { accessSync, constants, realpathSync as realpathSync2 } from "fs";
@@ -254,9 +254,9 @@ var companionConfigSchema = z2.object({
254
254
  // silent roster: an agent nobody listed gets no hook and no error, and starts
255
255
  // in the operator's `$HOME`.
256
256
  prepareHook: prepareHookSchema.optional(),
257
- // `dashboardPort` and `autoOpen` are IGNORED by the CLI (no port, no browser)
258
- // but stay in the schema so an existing file still parses; the Electron shell
259
- // reads them. `logLevel` is pino's level, live-editable.
257
+ // The CLI ignores both dashboard settings. Desktop uses `dashboardPort`;
258
+ // `autoOpen` remains parseable for older config files but no longer controls
259
+ // startup. `logLevel` is pino's level, live-editable.
260
260
  dashboardPort: z2.number().int().min(1).max(65535).optional(),
261
261
  autoOpen: z2.boolean().optional(),
262
262
  logLevel: z2.enum(["warn", "info", "debug"]).optional(),
@@ -459,31 +459,199 @@ function requireConfig() {
459
459
  return cfg;
460
460
  }
461
461
 
462
- // src/dashboard/server.ts
463
- import { dirname as dirname5, join as join7 } from "path";
464
- import { fileURLToPath as fileURLToPath3 } from "url";
465
- import { serve } from "@hono/node-server";
466
- import { Hono } from "hono";
467
-
468
- // src/dashboard/routes.ts
469
- import { openSync as openSync2, readSync, closeSync, fstatSync, existsSync as existsSync4 } from "fs";
470
- import { readFile } from "fs/promises";
471
- import { extname, join as join6, normalize } from "path";
472
- import { streamSSE } from "hono/streaming";
462
+ // src/control-socket.ts
463
+ import { createHash } from "crypto";
464
+ import { chmodSync as chmodSync2, existsSync as existsSync4, rmSync as rmSync2, mkdirSync as mkdirSync2 } from "fs";
465
+ import { createServer, connect } from "net";
466
+ import { join as join5 } from "path";
467
+ var CONTROL_TIMEOUT_MS = 1e3;
468
+ var MAX_UNIX_SOCKET_PATH_BYTES = 103;
469
+ function controlSocketPath() {
470
+ const dir2 = cabaneDir();
471
+ const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
472
+ if (process.platform === "win32") {
473
+ return `\\\\.\\pipe\\cabane-companion-${key}`;
474
+ }
475
+ const besideRuntime = join5(dir2, "companion.sock");
476
+ if (Buffer.byteLength(besideRuntime) <= MAX_UNIX_SOCKET_PATH_BYTES) return besideRuntime;
477
+ const filename = `cabane-companion-${key}.sock`;
478
+ const xdgPath = process.env.XDG_RUNTIME_DIR && existsSync4(process.env.XDG_RUNTIME_DIR) ? join5(process.env.XDG_RUNTIME_DIR, filename) : null;
479
+ return xdgPath && Buffer.byteLength(xdgPath) <= MAX_UNIX_SOCKET_PATH_BYTES ? xdgPath : join5("/tmp", filename);
480
+ }
481
+ async function startControlServer(handlers) {
482
+ const path = controlSocketPath();
483
+ mkdirSync2(cabaneDir(), { recursive: true });
484
+ if (process.platform !== "win32" && existsSync4(path)) {
485
+ const alive = await ping(path);
486
+ if (alive) throw new Error(`another companion is already listening on ${path}`);
487
+ rmSync2(path, { force: true });
488
+ }
489
+ const server = createServer((socket) => {
490
+ void serveConnection(socket, handlers);
491
+ });
492
+ server.unref();
493
+ await new Promise((resolve, reject) => {
494
+ server.once("error", reject);
495
+ server.listen(path, () => {
496
+ server.removeListener("error", reject);
497
+ resolve();
498
+ });
499
+ });
500
+ if (process.platform !== "win32") {
501
+ try {
502
+ chmodSync2(path, 384);
503
+ } catch (err) {
504
+ await new Promise((resolve) => server.close(() => resolve()));
505
+ try {
506
+ rmSync2(path, { force: true });
507
+ } catch {
508
+ }
509
+ throw err;
510
+ }
511
+ }
512
+ server.on("error", () => {
513
+ });
514
+ return {
515
+ path,
516
+ close: () => new Promise((resolve) => {
517
+ server.close(() => {
518
+ if (process.platform !== "win32") rmSync2(path, { force: true });
519
+ resolve();
520
+ });
521
+ })
522
+ };
523
+ }
524
+ async function serveConnection(socket, handlers) {
525
+ socket.on("error", () => socket.destroy());
526
+ const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
527
+ if (line === null) {
528
+ socket.destroy();
529
+ return;
530
+ }
531
+ let req;
532
+ try {
533
+ req = JSON.parse(line);
534
+ } catch {
535
+ reply(socket, { error: "malformed request" });
536
+ return;
537
+ }
538
+ try {
539
+ if (req.cmd === "status") {
540
+ reply(socket, handlers.status());
541
+ return;
542
+ }
543
+ if (req.cmd === "detect") {
544
+ const result = { detection: await handlers.detect() };
545
+ reply(socket, result);
546
+ return;
547
+ }
548
+ if (req.cmd === "allow") {
549
+ const result = { detection: await handlers.allow(req.runtime) };
550
+ reply(socket, result);
551
+ return;
552
+ }
553
+ if (req.cmd === "update") {
554
+ reply(socket, await handlers.update(req.auto));
555
+ return;
556
+ }
557
+ if (req.cmd === "stop") {
558
+ reply(socket, { ok: true });
559
+ setTimeout(() => handlers.stop(), 50).unref?.();
560
+ return;
561
+ }
562
+ reply(socket, { error: `unknown command "${String(req.cmd)}"` });
563
+ } catch (err) {
564
+ reply(socket, { error: err instanceof Error ? err.message : String(err) });
565
+ }
566
+ }
567
+ function reply(socket, body) {
568
+ try {
569
+ socket.end(`${JSON.stringify(body)}
570
+ `);
571
+ } catch {
572
+ socket.destroy();
573
+ }
574
+ }
575
+ async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
576
+ const socket = connect(path);
577
+ try {
578
+ await new Promise((resolve, reject) => {
579
+ const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
580
+ timer.unref?.();
581
+ socket.once("connect", () => {
582
+ clearTimeout(timer);
583
+ resolve();
584
+ });
585
+ socket.once("error", (err) => {
586
+ clearTimeout(timer);
587
+ reject(err);
588
+ });
589
+ });
590
+ socket.write(`${JSON.stringify(req)}
591
+ `);
592
+ const line = await readLine(socket, timeoutMs);
593
+ if (line === null) throw new ControlTimeout();
594
+ return JSON.parse(line);
595
+ } finally {
596
+ socket.destroy();
597
+ }
598
+ }
599
+ var ControlTimeout = class extends Error {
600
+ constructor() {
601
+ super("the companion did not answer its control socket in time");
602
+ this.name = "ControlTimeout";
603
+ }
604
+ };
605
+ function isNotListening(err) {
606
+ const code = err?.code;
607
+ return code === "ENOENT" || code === "ECONNREFUSED";
608
+ }
609
+ function readLine(socket, timeoutMs) {
610
+ return new Promise((resolve) => {
611
+ let buf = "";
612
+ let settled = false;
613
+ const done = (v) => {
614
+ if (settled) return;
615
+ settled = true;
616
+ clearTimeout(timer);
617
+ socket.removeListener("data", onData);
618
+ resolve(v);
619
+ };
620
+ const timer = setTimeout(() => done(null), timeoutMs);
621
+ timer.unref?.();
622
+ const onData = (chunk) => {
623
+ buf += chunk.toString("utf8");
624
+ const nl = buf.indexOf("\n");
625
+ if (nl >= 0) done(buf.slice(0, nl));
626
+ else if (buf.length > 1e6) done(null);
627
+ };
628
+ socket.on("data", onData);
629
+ socket.once("close", () => done(null));
630
+ socket.once("error", () => done(null));
631
+ });
632
+ }
633
+ async function ping(path) {
634
+ try {
635
+ await controlRequest(path, { cmd: "status" });
636
+ return true;
637
+ } catch (err) {
638
+ return !isNotListening(err);
639
+ }
640
+ }
473
641
 
474
642
  // src/logger.ts
475
643
  import {
476
644
  createWriteStream,
477
- mkdirSync as mkdirSync2,
645
+ mkdirSync as mkdirSync3,
478
646
  openSync,
479
647
  renameSync as renameSync2,
480
648
  statSync
481
649
  } from "fs";
482
- import { dirname as dirname4, join as join5 } from "path";
650
+ import { dirname as dirname4, join as join6 } from "path";
483
651
  import { stripVTControlCharacters } from "util";
484
652
  import pino from "pino";
485
653
  function companionLogPath() {
486
- return join5(cabaneDir(), "companion.log");
654
+ return join6(cabaneDir(), "companion.log");
487
655
  }
488
656
  function humanText(value) {
489
657
  return stripVTControlCharacters(String(value ?? "")).replace(
@@ -591,7 +759,7 @@ function configureLogger(log, config) {
591
759
  }
592
760
  function createLogger(destinations = {}, options = {}) {
593
761
  const path = companionLogPath();
594
- if (!destinations.file) mkdirSync2(dirname4(path), { recursive: true });
762
+ if (!destinations.file) mkdirSync3(dirname4(path), { recursive: true });
595
763
  const file = destinations.file ?? new RotatingLog(path);
596
764
  const state = { format: options.logFormat ?? "human" };
597
765
  const streams = [];
@@ -637,671 +805,46 @@ function getLogger() {
637
805
  return cached;
638
806
  }
639
807
 
640
- // src/state.ts
641
- import { EventEmitter } from "events";
642
- function dispatchToWire(r) {
643
- return {
644
- id: r.id,
645
- timestamp: r.timestamp,
646
- workspace_slug: r.workspaceSlug,
647
- ...r.agentUsername ? { agent_username: r.agentUsername } : {},
648
- message: r.message,
649
- status: r.status,
650
- ...r.durationMs !== void 0 ? { duration_ms: r.durationMs } : {},
651
- ...r.error ? { error: r.error } : {},
652
- ...r.reply ? { reply: r.reply } : {}
653
- };
654
- }
655
- var MAX_DISPATCHES = 50;
656
- function today() {
657
- return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
658
- }
659
- var CompanionStateHub = class {
660
- constructor(opts) {
661
- this.opts = opts;
662
- this.emitter.setMaxListeners(0);
808
+ // src/bundled-binaries.ts
809
+ import { realpathSync as realpathSync3, statSync as statSync2 } from "fs";
810
+ import { createRequire } from "module";
811
+ import { dirname as dirname5, join as join7 } from "path";
812
+ import { fileURLToPath as fileURLToPath3 } from "url";
813
+ function nodeResolveSelf(specifier) {
814
+ const here = fileURLToPath3(import.meta.url);
815
+ if (typeof import.meta.resolve === "function") {
816
+ try {
817
+ return fileURLToPath3(import.meta.resolve(specifier));
818
+ } catch {
819
+ }
663
820
  }
664
- opts;
665
- emitter = new EventEmitter();
666
- workspaces = /* @__PURE__ */ new Map();
667
- dispatches = [];
668
- dashboardUrl = null;
669
- deviceId = null;
670
- deviceLabel = null;
671
- deviceError = null;
672
- // The latest harness snapshot the supervisor probed, or null before the
673
- // first probe (see CompanionStatusJson.harnesses).
674
- harnesses = null;
675
- // ---- subscription (SSE) ----
676
- on(listener) {
677
- this.emitter.on("event", listener);
678
- return () => this.emitter.off("event", listener);
821
+ try {
822
+ return createRequire(here).resolve(specifier);
823
+ } catch {
679
824
  }
680
- emit(type, data) {
681
- this.emitter.emit("event", { type, data });
825
+ return walkNodeModules(specifier, here);
826
+ }
827
+ function walkNodeModules(pkg2, fromPath) {
828
+ let dir2 = dirname5(fromPath);
829
+ for (; ; ) {
830
+ const manifest = join7(dir2, "node_modules", pkg2, "package.json");
831
+ if (nodeIsFile(manifest)) {
832
+ try {
833
+ return realpathSync3(manifest);
834
+ } catch {
835
+ return manifest;
836
+ }
837
+ }
838
+ const parent = dirname5(dir2);
839
+ if (parent === dir2) return null;
840
+ dir2 = parent;
682
841
  }
683
- // ---- device ----
684
- setDevice(info) {
685
- if (info.deviceId !== void 0) this.deviceId = info.deviceId;
686
- if (info.deviceLabel !== void 0) this.deviceLabel = info.deviceLabel;
687
- this.emitStatus();
688
- }
689
- setDeviceError(message) {
690
- if (this.deviceError === message) return;
691
- this.deviceError = message;
692
- this.emitStatus();
693
- }
694
- clearDeviceError() {
695
- if (this.deviceError === null) return;
696
- this.deviceError = null;
697
- this.emitStatus();
698
- }
699
- // ---- harnesses ----
700
- // Replace the harness snapshot and push a status change (so the SSE feed flips
701
- // the Companion's Harnesses surface live). The supervisor calls this on each
702
- // heartbeat-cadence probe and on an on-demand recheck.
703
- setHarnesses(harnesses) {
704
- this.harnesses = harnesses;
705
- this.emitStatus();
706
- }
707
- // ---- workspace lifecycle ----
708
- registerWorkspace(ws) {
709
- const existing = this.workspaces.get(ws.workspaceId);
710
- if (existing) {
711
- existing.slug = ws.slug;
712
- existing.name = ws.name;
713
- return;
714
- }
715
- this.workspaces.set(ws.workspaceId, {
716
- workspaceId: ws.workspaceId,
717
- slug: ws.slug,
718
- name: ws.name,
719
- connected: false,
720
- authFailed: false,
721
- lastEventAt: null,
722
- dispatchCountToday: 0,
723
- countDate: today(),
724
- agents: /* @__PURE__ */ new Map()
725
- });
726
- this.emitStatus();
727
- }
728
- removeWorkspace(workspaceId) {
729
- const ws = this.workspaces.get(workspaceId);
730
- if (!ws) return;
731
- this.workspaces.delete(workspaceId);
732
- this.emit("workspace:disconnected", { slug: ws.slug });
733
- this.emitStatus();
734
- }
735
- setConnected(workspaceId, connected) {
736
- const ws = this.workspaces.get(workspaceId);
737
- if (!ws || ws.connected === connected) return;
738
- ws.connected = connected;
739
- if (connected) ws.authFailed = false;
740
- this.emitStatus();
741
- }
742
- setAuthFailed(workspaceId) {
743
- const ws = this.workspaces.get(workspaceId);
744
- if (!ws) return;
745
- if (ws.authFailed && !ws.connected) return;
746
- ws.authFailed = true;
747
- ws.connected = false;
748
- this.emitStatus();
749
- }
750
- recordEvent(workspaceId) {
751
- const ws = this.workspaces.get(workspaceId);
752
- if (!ws) return;
753
- ws.lastEventAt = (/* @__PURE__ */ new Date()).toISOString();
754
- }
755
- // ---- agents ----
756
- // Upsert one assigned agent's observable state (called on each reconcile).
757
- setAgent(workspaceId, agent) {
758
- const ws = this.workspaces.get(workspaceId);
759
- if (!ws) return;
760
- ws.agents.set(agent.agentId, agent);
761
- this.emitStatus();
762
- }
763
- removeAgent(workspaceId, agentId) {
764
- const ws = this.workspaces.get(workspaceId);
765
- if (!ws) return;
766
- if (ws.agents.delete(agentId)) this.emitStatus();
767
- }
768
- // ---- dispatch feed ----
769
- // A DispatchObserver bound to one (workspace, agent), handed to that agent's
770
- // Dispatcher by the supervisor.
771
- observerFor(workspaceId, agentId, slug) {
772
- const username = () => this.workspaces.get(workspaceId)?.agents.get(agentId)?.username ?? "";
773
- return {
774
- onStart: (info) => this.dispatchStarted(workspaceId, slug, username(), info),
775
- onEnd: (info) => this.dispatchEnded(slug, username(), info)
776
- };
777
- }
778
- bumpCount(workspaceId) {
779
- const ws = this.workspaces.get(workspaceId);
780
- if (!ws) return;
781
- const day = today();
782
- if (ws.countDate !== day) {
783
- ws.countDate = day;
784
- ws.dispatchCountToday = 0;
785
- }
786
- ws.dispatchCountToday += 1;
787
- }
788
- dispatchStarted(workspaceId, slug, agentUsername, info) {
789
- this.bumpCount(workspaceId);
790
- const record = {
791
- id: info.id,
792
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
793
- workspaceSlug: slug,
794
- ...agentUsername ? { agentUsername } : {},
795
- message: info.message,
796
- status: "running"
797
- };
798
- this.upsertDispatch(record);
799
- this.emit("dispatch:started", dispatchToWire(record));
800
- this.emitStatus();
801
- }
802
- dispatchEnded(slug, agentUsername, info) {
803
- const existing = this.dispatches.find((d) => d.id === info.id);
804
- const record = {
805
- id: info.id,
806
- timestamp: existing?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
807
- workspaceSlug: existing?.workspaceSlug ?? slug,
808
- ...existing?.agentUsername ?? agentUsername ? { agentUsername: existing?.agentUsername ?? agentUsername } : {},
809
- message: existing?.message ?? "",
810
- status: info.ok ? "replied" : "error",
811
- durationMs: info.durationMs,
812
- ...info.reason ? { error: info.reason } : {},
813
- ...info.reply ? { reply: info.reply } : {}
814
- };
815
- this.upsertDispatch(record);
816
- this.emit(info.ok ? "dispatch:completed" : "dispatch:error", dispatchToWire(record));
817
- }
818
- upsertDispatch(record) {
819
- const idx = this.dispatches.findIndex((d) => d.id === record.id);
820
- if (idx >= 0) {
821
- this.dispatches[idx] = record;
822
- return;
823
- }
824
- this.dispatches.unshift(record);
825
- if (this.dispatches.length > MAX_DISPATCHES) this.dispatches.length = MAX_DISPATCHES;
826
- }
827
- // ---- snapshots for the HTTP routes ----
828
- setDashboardUrl(url) {
829
- this.dashboardUrl = url;
830
- }
831
- recentDispatches(limit = 20) {
832
- return this.dispatches.slice(0, Math.max(0, limit));
833
- }
834
- statusJson() {
835
- const list = [...this.workspaces.values()].sort((a, b) => a.name.localeCompare(b.name));
836
- const lastOverall = list.reduce((acc, ws) => {
837
- if (!ws.lastEventAt) return acc;
838
- if (!acc || ws.lastEventAt > acc) return ws.lastEventAt;
839
- return acc;
840
- }, null);
841
- return {
842
- cabane_url: this.opts.baseUrl,
843
- device_id: this.deviceId,
844
- device_label: this.deviceLabel,
845
- device_error: this.deviceError,
846
- connected: list.some((ws) => ws.connected),
847
- workspaces: list.map((ws) => ({
848
- slug: ws.slug,
849
- name: ws.name,
850
- last_event_at: ws.lastEventAt,
851
- dispatch_count_today: ws.dispatchCountToday,
852
- connected: ws.connected,
853
- auth_failed: ws.authFailed,
854
- agents: [...ws.agents.values()].sort((a, b) => a.username.localeCompare(b.username)).map((a) => ({
855
- agent_id: a.agentId,
856
- username: a.username,
857
- display_name: a.displayName,
858
- has_credential: a.hasCredential,
859
- missing_secrets: a.missingSecrets
860
- }))
861
- })),
862
- last_event_at_overall: lastOverall,
863
- dashboard_url: this.dashboardUrl,
864
- companion_version: this.opts.companionVersion,
865
- instance_id: this.opts.instanceId ?? null,
866
- harnesses: this.harnesses
867
- };
868
- }
869
- emitStatus() {
870
- this.emit("status:changed", this.statusJson());
871
- }
872
- };
873
-
874
- // src/dashboard/routes.ts
875
- var CONTENT_TYPES = {
876
- ".html": "text/html; charset=utf-8",
877
- ".css": "text/css; charset=utf-8",
878
- ".js": "text/javascript; charset=utf-8",
879
- ".svg": "image/svg+xml",
880
- ".ico": "image/x-icon"
881
- };
882
- function registerRoutes(app, deps) {
883
- const { supervisor, hub, staticDir } = deps;
884
- app.get("/", async (c) => {
885
- const html = await readFile(join6(staticDir, "index.html"), "utf8");
886
- return c.html(html);
887
- });
888
- app.get("/static/:file", async (c) => {
889
- const file = c.req.param("file");
890
- const safe = normalize(file).replace(/^(\.\.[/\\])+/, "");
891
- const full = join6(staticDir, safe);
892
- if (!full.startsWith(staticDir) || !existsSync4(full)) return c.notFound();
893
- const body = await readFile(full);
894
- const type = CONTENT_TYPES[extname(full).toLowerCase()] ?? "application/octet-stream";
895
- c.header("content-type", type);
896
- return c.body(body);
897
- });
898
- app.get("/api/status", (c) => c.json(hub.statusJson()));
899
- app.get("/api/dispatches", (c) => {
900
- const limit = clampLimit(c.req.query("limit"), 20);
901
- return c.json({ dispatches: hub.recentDispatches(limit).map(dispatchToWire) });
902
- });
903
- app.get("/api/logs", async (c) => {
904
- const lines2 = clampLimit(c.req.query("lines"), 200, 1e3);
905
- return c.json({ lines: tailFile(companionLogPath(), lines2) });
906
- });
907
- app.post("/api/settings", async (c) => {
908
- const body = await readJson(c);
909
- const patch = {};
910
- if (body.logLevel === "warn" || body.logLevel === "info" || body.logLevel === "debug") {
911
- patch.logLevel = body.logLevel;
912
- }
913
- if (typeof body.autoOpen === "boolean") patch.autoOpen = body.autoOpen;
914
- supervisor.applySettings(patch);
915
- return c.json({ ok: true, status: hub.statusJson() });
916
- });
917
- app.post("/api/control", async (c) => {
918
- const body = await readJson(c);
919
- const action = body.action;
920
- if (action === "stop") {
921
- setTimeout(() => void supervisor.requestStop(), 50);
922
- return c.json({ ok: true, action: "stop" });
923
- }
924
- if (action === "restart") {
925
- setTimeout(() => supervisor.requestRestart(), 50);
926
- return c.json({ ok: true, action: "restart" });
927
- }
928
- if (action === "reload-config") {
929
- supervisor.reloadConfig();
930
- return c.json({ ok: true, action: "reload-config" });
931
- }
932
- if (action === "refresh") {
933
- supervisor.refresh();
934
- return c.json({ ok: true, action: "refresh" });
935
- }
936
- return c.json({ error: `unknown action "${String(action)}"` }, 400);
937
- });
938
- app.post("/api/harnesses/recheck", async (c) => {
939
- await supervisor.redetect();
940
- return c.json({ ok: true, status: hub.statusJson() });
941
- });
942
- app.get(
943
- "/api/events",
944
- (c) => streamSSE(c, async (stream) => {
945
- await stream.writeSSE({ event: "status:changed", data: JSON.stringify(hub.statusJson()) });
946
- const queue = [];
947
- let wake = null;
948
- const unsubscribe = hub.on((ev) => {
949
- queue.push(ev);
950
- wake?.();
951
- });
952
- stream.onAbort(() => {
953
- unsubscribe();
954
- wake?.();
955
- });
956
- while (!stream.aborted) {
957
- if (queue.length === 0) {
958
- await Promise.race([
959
- new Promise((resolve) => {
960
- wake = resolve;
961
- }),
962
- stream.sleep(25e3)
963
- ]);
964
- wake = null;
965
- }
966
- if (stream.aborted) break;
967
- if (queue.length === 0) {
968
- await stream.writeSSE({ event: "ping", data: "" });
969
- continue;
970
- }
971
- const ev = queue.shift();
972
- await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev.data) });
973
- }
974
- unsubscribe();
975
- })
976
- );
977
- }
978
- async function readJson(c) {
979
- try {
980
- const parsed = await c.req.json();
981
- return parsed && typeof parsed === "object" ? parsed : {};
982
- } catch {
983
- return {};
984
- }
985
- }
986
- function clampLimit(raw, fallback, max = 200) {
987
- if (!raw) return fallback;
988
- const n = Number(raw);
989
- if (!Number.isFinite(n) || n <= 0) return fallback;
990
- return Math.min(Math.floor(n), max);
991
- }
992
- function tailFile(path, lines2) {
993
- if (!existsSync4(path)) return [];
994
- const MAX_BYTES = 256 * 1024;
995
- let fd;
996
- try {
997
- fd = openSync2(path, "r");
998
- const size = fstatSync(fd).size;
999
- const start = Math.max(0, size - MAX_BYTES);
1000
- const len = size - start;
1001
- if (len <= 0) return [];
1002
- const buf = Buffer.alloc(len);
1003
- readSync(fd, buf, 0, len, start);
1004
- const text = buf.toString("utf8");
1005
- const all = text.split("\n");
1006
- if (start > 0 && all.length > 0) all.shift();
1007
- return all.filter((l) => l.length > 0).slice(-lines2);
1008
- } catch {
1009
- return [];
1010
- } finally {
1011
- if (fd !== void 0) closeSync(fd);
1012
- }
1013
- }
1014
-
1015
- // src/dashboard/server.ts
1016
- var DEFAULT_PORT = 7474;
1017
- var PORT_FALLBACK_SPAN = 10;
1018
- function buildDashboardApp(deps) {
1019
- const app = new Hono();
1020
- app.onError((err, c) => {
1021
- if (err instanceof ApiError) {
1022
- const status = err.status >= 400 && err.status < 600 ? err.status : 502;
1023
- return c.json({ error: err.message }, status);
1024
- }
1025
- if (err instanceof CompanionError) {
1026
- return c.json({ error: err.message }, 400);
1027
- }
1028
- return c.json({ error: err instanceof Error ? err.message : "internal error" }, 500);
1029
- });
1030
- registerRoutes(app, { ...deps, staticDir: resolveStaticDir() });
1031
- return app;
1032
- }
1033
- async function startDashboard(opts) {
1034
- const app = buildDashboardApp({ supervisor: opts.supervisor, hub: opts.hub });
1035
- const preferred = opts.port ?? DEFAULT_PORT;
1036
- let lastErr;
1037
- for (let port = preferred; port < preferred + PORT_FALLBACK_SPAN; port++) {
1038
- try {
1039
- const server = await listen(app, port);
1040
- const url = `http://127.0.0.1:${port}`;
1041
- return {
1042
- url,
1043
- port,
1044
- close: () => new Promise((resolve) => {
1045
- server.close(() => resolve());
1046
- server.closeAllConnections?.();
1047
- })
1048
- };
1049
- } catch (err) {
1050
- if (isAddrInUse(err)) {
1051
- lastErr = err;
1052
- continue;
1053
- }
1054
- throw err;
1055
- }
1056
- }
1057
- throw new CompanionError(
1058
- `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)})`
1059
- );
1060
- }
1061
- function listen(app, port) {
1062
- return new Promise((resolve, reject) => {
1063
- let settled = false;
1064
- const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, () => {
1065
- if (!settled) {
1066
- settled = true;
1067
- resolve(server);
1068
- }
1069
- });
1070
- server.on("error", (err) => {
1071
- if (!settled) {
1072
- settled = true;
1073
- reject(err);
1074
- }
1075
- });
1076
- });
1077
- }
1078
- function isAddrInUse(err) {
1079
- return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
1080
- }
1081
- function resolveStaticDir() {
1082
- return join7(dirname5(fileURLToPath3(import.meta.url)), "static");
1083
- }
1084
-
1085
- // src/control-socket.ts
1086
- import { createHash } from "crypto";
1087
- import { chmodSync as chmodSync2, existsSync as existsSync5, rmSync as rmSync2, mkdirSync as mkdirSync3 } from "fs";
1088
- import { createServer, connect } from "net";
1089
- import { join as join8 } from "path";
1090
- var CONTROL_TIMEOUT_MS = 1e3;
1091
- var MAX_UNIX_SOCKET_PATH_BYTES = 103;
1092
- function controlSocketPath() {
1093
- const dir2 = cabaneDir();
1094
- const key = createHash("sha256").update(dir2).digest("hex").slice(0, 16);
1095
- if (process.platform === "win32") {
1096
- return `\\\\.\\pipe\\cabane-companion-${key}`;
1097
- }
1098
- const besideRuntime = join8(dir2, "companion.sock");
1099
- if (Buffer.byteLength(besideRuntime) <= MAX_UNIX_SOCKET_PATH_BYTES) return besideRuntime;
1100
- const filename = `cabane-companion-${key}.sock`;
1101
- const xdgPath = process.env.XDG_RUNTIME_DIR && existsSync5(process.env.XDG_RUNTIME_DIR) ? join8(process.env.XDG_RUNTIME_DIR, filename) : null;
1102
- return xdgPath && Buffer.byteLength(xdgPath) <= MAX_UNIX_SOCKET_PATH_BYTES ? xdgPath : join8("/tmp", filename);
1103
- }
1104
- async function startControlServer(handlers) {
1105
- const path = controlSocketPath();
1106
- mkdirSync3(cabaneDir(), { recursive: true });
1107
- if (process.platform !== "win32" && existsSync5(path)) {
1108
- const alive = await ping(path);
1109
- if (alive) throw new Error(`another companion is already listening on ${path}`);
1110
- rmSync2(path, { force: true });
1111
- }
1112
- const server = createServer((socket) => {
1113
- void serveConnection(socket, handlers);
1114
- });
1115
- server.unref();
1116
- await new Promise((resolve, reject) => {
1117
- server.once("error", reject);
1118
- server.listen(path, () => {
1119
- server.removeListener("error", reject);
1120
- resolve();
1121
- });
1122
- });
1123
- if (process.platform !== "win32") {
1124
- try {
1125
- chmodSync2(path, 384);
1126
- } catch (err) {
1127
- await new Promise((resolve) => server.close(() => resolve()));
1128
- try {
1129
- rmSync2(path, { force: true });
1130
- } catch {
1131
- }
1132
- throw err;
1133
- }
1134
- }
1135
- server.on("error", () => {
1136
- });
1137
- return {
1138
- path,
1139
- close: () => new Promise((resolve) => {
1140
- server.close(() => {
1141
- if (process.platform !== "win32") rmSync2(path, { force: true });
1142
- resolve();
1143
- });
1144
- })
1145
- };
1146
- }
1147
- async function serveConnection(socket, handlers) {
1148
- socket.on("error", () => socket.destroy());
1149
- const line = await readLine(socket, CONTROL_TIMEOUT_MS * 5);
1150
- if (line === null) {
1151
- socket.destroy();
1152
- return;
1153
- }
1154
- let req;
1155
- try {
1156
- req = JSON.parse(line);
1157
- } catch {
1158
- reply(socket, { error: "malformed request" });
1159
- return;
1160
- }
1161
- try {
1162
- if (req.cmd === "status") {
1163
- reply(socket, handlers.status());
1164
- return;
1165
- }
1166
- if (req.cmd === "detect") {
1167
- const result = { detection: await handlers.detect() };
1168
- reply(socket, result);
1169
- return;
1170
- }
1171
- if (req.cmd === "allow") {
1172
- const result = { detection: await handlers.allow(req.runtime) };
1173
- reply(socket, result);
1174
- return;
1175
- }
1176
- if (req.cmd === "update") {
1177
- reply(socket, await handlers.update(req.auto));
1178
- return;
1179
- }
1180
- if (req.cmd === "stop") {
1181
- reply(socket, { ok: true });
1182
- setTimeout(() => handlers.stop(), 50).unref?.();
1183
- return;
1184
- }
1185
- reply(socket, { error: `unknown command "${String(req.cmd)}"` });
1186
- } catch (err) {
1187
- reply(socket, { error: err instanceof Error ? err.message : String(err) });
1188
- }
1189
- }
1190
- function reply(socket, body) {
1191
- try {
1192
- socket.end(`${JSON.stringify(body)}
1193
- `);
1194
- } catch {
1195
- socket.destroy();
1196
- }
1197
- }
1198
- async function controlRequest(path, req, timeoutMs = CONTROL_TIMEOUT_MS) {
1199
- const socket = connect(path);
1200
- try {
1201
- await new Promise((resolve, reject) => {
1202
- const timer = setTimeout(() => reject(new ControlTimeout()), timeoutMs);
1203
- timer.unref?.();
1204
- socket.once("connect", () => {
1205
- clearTimeout(timer);
1206
- resolve();
1207
- });
1208
- socket.once("error", (err) => {
1209
- clearTimeout(timer);
1210
- reject(err);
1211
- });
1212
- });
1213
- socket.write(`${JSON.stringify(req)}
1214
- `);
1215
- const line = await readLine(socket, timeoutMs);
1216
- if (line === null) throw new ControlTimeout();
1217
- return JSON.parse(line);
1218
- } finally {
1219
- socket.destroy();
1220
- }
1221
- }
1222
- var ControlTimeout = class extends Error {
1223
- constructor() {
1224
- super("the companion did not answer its control socket in time");
1225
- this.name = "ControlTimeout";
1226
- }
1227
- };
1228
- function isNotListening(err) {
1229
- const code = err?.code;
1230
- return code === "ENOENT" || code === "ECONNREFUSED";
1231
- }
1232
- function readLine(socket, timeoutMs) {
1233
- return new Promise((resolve) => {
1234
- let buf = "";
1235
- let settled = false;
1236
- const done = (v) => {
1237
- if (settled) return;
1238
- settled = true;
1239
- clearTimeout(timer);
1240
- socket.removeListener("data", onData);
1241
- resolve(v);
1242
- };
1243
- const timer = setTimeout(() => done(null), timeoutMs);
1244
- timer.unref?.();
1245
- const onData = (chunk) => {
1246
- buf += chunk.toString("utf8");
1247
- const nl = buf.indexOf("\n");
1248
- if (nl >= 0) done(buf.slice(0, nl));
1249
- else if (buf.length > 1e6) done(null);
1250
- };
1251
- socket.on("data", onData);
1252
- socket.once("close", () => done(null));
1253
- socket.once("error", () => done(null));
1254
- });
1255
- }
1256
- async function ping(path) {
1257
- try {
1258
- await controlRequest(path, { cmd: "status" });
1259
- return true;
1260
- } catch (err) {
1261
- return !isNotListening(err);
1262
- }
1263
- }
1264
-
1265
- // src/bundled-binaries.ts
1266
- import { realpathSync as realpathSync3, statSync as statSync2 } from "fs";
1267
- import { createRequire } from "module";
1268
- import { dirname as dirname6, join as join9 } from "path";
1269
- import { fileURLToPath as fileURLToPath4 } from "url";
1270
- function nodeResolveSelf(specifier) {
1271
- const here = fileURLToPath4(import.meta.url);
1272
- if (typeof import.meta.resolve === "function") {
1273
- try {
1274
- return fileURLToPath4(import.meta.resolve(specifier));
1275
- } catch {
1276
- }
1277
- }
1278
- try {
1279
- return createRequire(here).resolve(specifier);
1280
- } catch {
1281
- }
1282
- return walkNodeModules(specifier, here);
1283
- }
1284
- function walkNodeModules(pkg2, fromPath) {
1285
- let dir2 = dirname6(fromPath);
1286
- for (; ; ) {
1287
- const manifest = join9(dir2, "node_modules", pkg2, "package.json");
1288
- if (nodeIsFile(manifest)) {
1289
- try {
1290
- return realpathSync3(manifest);
1291
- } catch {
1292
- return manifest;
1293
- }
1294
- }
1295
- const parent = dirname6(dir2);
1296
- if (parent === dir2) return null;
1297
- dir2 = parent;
1298
- }
1299
- }
1300
- function nodeResolve(specifier, fromPath) {
1301
- try {
1302
- return createRequire(fromPath).resolve(specifier);
1303
- } catch {
1304
- return null;
842
+ }
843
+ function nodeResolve(specifier, fromPath) {
844
+ try {
845
+ return createRequire(fromPath).resolve(specifier);
846
+ } catch {
847
+ return null;
1305
848
  }
1306
849
  }
1307
850
  function nodeIsFile(path) {
@@ -1354,7 +897,7 @@ function resolveClaude(env) {
1354
897
  const manifest = env.resolve(`${pkg2}/package.json`, sdkEntry);
1355
898
  if (!manifest) continue;
1356
899
  sawPackage ??= pkg2;
1357
- const exe = join9(dirname6(manifest), env.platform === "win32" ? "claude.exe" : "claude");
900
+ const exe = join7(dirname5(manifest), env.platform === "win32" ? "claude.exe" : "claude");
1358
901
  if (env.isFile(exe)) {
1359
902
  return { ...base, packageName: pkg2, status: "present", path: exe };
1360
903
  }
@@ -1405,13 +948,13 @@ function resolveCodex(env) {
1405
948
  if (!cliManifest) return { ...base, status: "platform_package_missing", path: null };
1406
949
  const platformManifest = env.resolve(`${pkg2}/package.json`, cliManifest);
1407
950
  if (!platformManifest) return { ...base, status: "platform_package_missing", path: null };
1408
- const packageRoot = join9(dirname6(platformManifest), "vendor", triple);
951
+ const packageRoot = join7(dirname5(platformManifest), "vendor", triple);
1409
952
  const exeName = env.platform === "win32" ? "codex.exe" : "codex";
1410
- const current = join9(packageRoot, "bin", exeName);
1411
- if (env.isFile(current) && env.isFile(join9(packageRoot, "codex-package.json"))) {
953
+ const current = join7(packageRoot, "bin", exeName);
954
+ if (env.isFile(current) && env.isFile(join7(packageRoot, "codex-package.json"))) {
1412
955
  return { ...base, status: "present", path: current };
1413
956
  }
1414
- const legacy = join9(packageRoot, "codex", exeName);
957
+ const legacy = join7(packageRoot, "codex", exeName);
1415
958
  if (env.isFile(legacy)) return { ...base, status: "present", path: legacy };
1416
959
  return { ...base, status: "executable_missing", path: null };
1417
960
  }
@@ -1476,18 +1019,18 @@ function warnAboutHarnessReadiness(cfg, deps = {}) {
1476
1019
 
1477
1020
  // src/runtime-file.ts
1478
1021
  import {
1479
- existsSync as existsSync6,
1022
+ existsSync as existsSync5,
1480
1023
  readFileSync as readFileSync3,
1481
1024
  rmSync as rmSync3,
1482
1025
  writeFileSync as writeFileSync2,
1483
1026
  mkdirSync as mkdirSync4,
1484
- openSync as openSync3,
1485
- closeSync as closeSync2
1027
+ openSync as openSync2,
1028
+ closeSync
1486
1029
  } from "fs";
1487
- import { join as join10 } from "path";
1030
+ import { join as join8 } from "path";
1488
1031
  var PROBE_TIMEOUT_MS = 1e3;
1489
1032
  function runtimePath() {
1490
- return join10(cabaneDir(), "runtime.json");
1033
+ return join8(cabaneDir(), "runtime.json");
1491
1034
  }
1492
1035
  function serialize(state) {
1493
1036
  return JSON.stringify(state, null, 2) + "\n";
@@ -1503,24 +1046,24 @@ function acquireRuntimeState(state) {
1503
1046
  mkdirSync4(cabaneDir(), { recursive: true });
1504
1047
  let fd;
1505
1048
  try {
1506
- fd = openSync3(runtimePath(), "wx");
1049
+ fd = openSync2(runtimePath(), "wx");
1507
1050
  } catch {
1508
1051
  return { acquired: false, existing: readLiveRuntimeState() };
1509
1052
  }
1510
1053
  try {
1511
1054
  writeFileSync2(fd, serialize(state), "utf8");
1512
1055
  } finally {
1513
- closeSync2(fd);
1056
+ closeSync(fd);
1514
1057
  }
1515
1058
  return { acquired: true };
1516
1059
  }
1517
1060
  function clearRuntimeState() {
1518
1061
  const path = runtimePath();
1519
- if (existsSync6(path)) rmSync3(path, { force: true });
1062
+ if (existsSync5(path)) rmSync3(path, { force: true });
1520
1063
  }
1521
1064
  function clearRuntimeStateIfOurs(instanceId) {
1522
1065
  const path = runtimePath();
1523
- if (!existsSync6(path)) return;
1066
+ if (!existsSync5(path)) return;
1524
1067
  try {
1525
1068
  const parsed = JSON.parse(readFileSync3(path, "utf8"));
1526
1069
  if (parsed.instanceId && parsed.instanceId !== instanceId) return;
@@ -1532,37 +1075,271 @@ function readLiveRuntimeState({
1532
1075
  sweep = true
1533
1076
  } = {}) {
1534
1077
  const path = runtimePath();
1535
- if (!existsSync6(path)) return null;
1078
+ if (!existsSync5(path)) return null;
1536
1079
  let parsed;
1537
1080
  try {
1538
1081
  parsed = JSON.parse(readFileSync3(path, "utf8"));
1539
1082
  } catch {
1540
1083
  return null;
1541
1084
  }
1542
- if (typeof parsed.pid !== "number") return null;
1543
- try {
1544
- process.kill(parsed.pid, 0);
1545
- } catch {
1546
- if (sweep) clearRuntimeState();
1547
- return null;
1085
+ if (typeof parsed.pid !== "number") return null;
1086
+ try {
1087
+ process.kill(parsed.pid, 0);
1088
+ } catch {
1089
+ if (sweep) clearRuntimeState();
1090
+ return null;
1091
+ }
1092
+ return parsed;
1093
+ }
1094
+ async function verifyRuntime(state, requestImpl = controlRequest) {
1095
+ if (!state.instanceId || !state.socket) return "unknown";
1096
+ let body;
1097
+ try {
1098
+ body = await requestImpl(
1099
+ state.socket,
1100
+ { cmd: "status" },
1101
+ PROBE_TIMEOUT_MS
1102
+ );
1103
+ } catch (err) {
1104
+ return isNotListening(err) ? "stale" : "unknown";
1105
+ }
1106
+ if (typeof body?.instance_id !== "string") return "unknown";
1107
+ return body.instance_id === state.instanceId ? "ours" : "stale";
1108
+ }
1109
+
1110
+ // src/state.ts
1111
+ import { EventEmitter } from "events";
1112
+ function dispatchToWire(r) {
1113
+ return {
1114
+ id: r.id,
1115
+ timestamp: r.timestamp,
1116
+ workspace_slug: r.workspaceSlug,
1117
+ ...r.agentUsername ? { agent_username: r.agentUsername } : {},
1118
+ message: r.message,
1119
+ status: r.status,
1120
+ ...r.durationMs !== void 0 ? { duration_ms: r.durationMs } : {},
1121
+ ...r.error ? { error: r.error } : {},
1122
+ ...r.reply ? { reply: r.reply } : {}
1123
+ };
1124
+ }
1125
+ var MAX_DISPATCHES = 50;
1126
+ function today() {
1127
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1128
+ }
1129
+ var CompanionStateHub = class {
1130
+ constructor(opts) {
1131
+ this.opts = opts;
1132
+ this.emitter.setMaxListeners(0);
1133
+ }
1134
+ opts;
1135
+ emitter = new EventEmitter();
1136
+ workspaces = /* @__PURE__ */ new Map();
1137
+ dispatches = [];
1138
+ dashboardUrl = null;
1139
+ deviceId = null;
1140
+ deviceLabel = null;
1141
+ deviceError = null;
1142
+ // The latest harness snapshot the supervisor probed, or null before the
1143
+ // first probe (see CompanionStatusJson.harnesses).
1144
+ harnesses = null;
1145
+ // ---- subscription (SSE) ----
1146
+ on(listener) {
1147
+ this.emitter.on("event", listener);
1148
+ return () => this.emitter.off("event", listener);
1149
+ }
1150
+ emit(type, data) {
1151
+ this.emitter.emit("event", { type, data });
1152
+ }
1153
+ // ---- device ----
1154
+ setDevice(info) {
1155
+ if (info.deviceId !== void 0) this.deviceId = info.deviceId;
1156
+ if (info.deviceLabel !== void 0) this.deviceLabel = info.deviceLabel;
1157
+ this.emitStatus();
1158
+ }
1159
+ setDeviceError(message) {
1160
+ if (this.deviceError === message) return;
1161
+ this.deviceError = message;
1162
+ this.emitStatus();
1163
+ }
1164
+ clearDeviceError() {
1165
+ if (this.deviceError === null) return;
1166
+ this.deviceError = null;
1167
+ this.emitStatus();
1168
+ }
1169
+ // ---- harnesses ----
1170
+ // Replace the harness snapshot and push a status change (so the SSE feed flips
1171
+ // the Companion's Harnesses surface live). The supervisor calls this on each
1172
+ // heartbeat-cadence probe and on an on-demand recheck.
1173
+ setHarnesses(harnesses) {
1174
+ this.harnesses = harnesses;
1175
+ this.emitStatus();
1176
+ }
1177
+ // ---- workspace lifecycle ----
1178
+ registerWorkspace(ws) {
1179
+ const existing = this.workspaces.get(ws.workspaceId);
1180
+ if (existing) {
1181
+ existing.slug = ws.slug;
1182
+ existing.name = ws.name;
1183
+ return;
1184
+ }
1185
+ this.workspaces.set(ws.workspaceId, {
1186
+ workspaceId: ws.workspaceId,
1187
+ slug: ws.slug,
1188
+ name: ws.name,
1189
+ connected: false,
1190
+ authFailed: false,
1191
+ lastEventAt: null,
1192
+ dispatchCountToday: 0,
1193
+ countDate: today(),
1194
+ agents: /* @__PURE__ */ new Map()
1195
+ });
1196
+ this.emitStatus();
1197
+ }
1198
+ removeWorkspace(workspaceId) {
1199
+ const ws = this.workspaces.get(workspaceId);
1200
+ if (!ws) return;
1201
+ this.workspaces.delete(workspaceId);
1202
+ this.emit("workspace:disconnected", { slug: ws.slug });
1203
+ this.emitStatus();
1204
+ }
1205
+ setConnected(workspaceId, connected) {
1206
+ const ws = this.workspaces.get(workspaceId);
1207
+ if (!ws || ws.connected === connected) return;
1208
+ ws.connected = connected;
1209
+ if (connected) ws.authFailed = false;
1210
+ this.emitStatus();
1211
+ }
1212
+ setAuthFailed(workspaceId) {
1213
+ const ws = this.workspaces.get(workspaceId);
1214
+ if (!ws) return;
1215
+ if (ws.authFailed && !ws.connected) return;
1216
+ ws.authFailed = true;
1217
+ ws.connected = false;
1218
+ this.emitStatus();
1219
+ }
1220
+ recordEvent(workspaceId) {
1221
+ const ws = this.workspaces.get(workspaceId);
1222
+ if (!ws) return;
1223
+ ws.lastEventAt = (/* @__PURE__ */ new Date()).toISOString();
1224
+ }
1225
+ // ---- agents ----
1226
+ // Upsert one assigned agent's observable state (called on each reconcile).
1227
+ setAgent(workspaceId, agent) {
1228
+ const ws = this.workspaces.get(workspaceId);
1229
+ if (!ws) return;
1230
+ ws.agents.set(agent.agentId, agent);
1231
+ this.emitStatus();
1232
+ }
1233
+ removeAgent(workspaceId, agentId) {
1234
+ const ws = this.workspaces.get(workspaceId);
1235
+ if (!ws) return;
1236
+ if (ws.agents.delete(agentId)) this.emitStatus();
1237
+ }
1238
+ // ---- dispatch feed ----
1239
+ // A DispatchObserver bound to one (workspace, agent), handed to that agent's
1240
+ // Dispatcher by the supervisor.
1241
+ observerFor(workspaceId, agentId, slug) {
1242
+ const username = () => this.workspaces.get(workspaceId)?.agents.get(agentId)?.username ?? "";
1243
+ return {
1244
+ onStart: (info) => this.dispatchStarted(workspaceId, slug, username(), info),
1245
+ onEnd: (info) => this.dispatchEnded(slug, username(), info)
1246
+ };
1247
+ }
1248
+ bumpCount(workspaceId) {
1249
+ const ws = this.workspaces.get(workspaceId);
1250
+ if (!ws) return;
1251
+ const day = today();
1252
+ if (ws.countDate !== day) {
1253
+ ws.countDate = day;
1254
+ ws.dispatchCountToday = 0;
1255
+ }
1256
+ ws.dispatchCountToday += 1;
1257
+ }
1258
+ dispatchStarted(workspaceId, slug, agentUsername, info) {
1259
+ this.bumpCount(workspaceId);
1260
+ const record = {
1261
+ id: info.id,
1262
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1263
+ workspaceSlug: slug,
1264
+ ...agentUsername ? { agentUsername } : {},
1265
+ message: info.message,
1266
+ status: "running"
1267
+ };
1268
+ this.upsertDispatch(record);
1269
+ this.emit("dispatch:started", dispatchToWire(record));
1270
+ this.emitStatus();
1271
+ }
1272
+ dispatchEnded(slug, agentUsername, info) {
1273
+ const existing = this.dispatches.find((d) => d.id === info.id);
1274
+ const record = {
1275
+ id: info.id,
1276
+ timestamp: existing?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
1277
+ workspaceSlug: existing?.workspaceSlug ?? slug,
1278
+ ...existing?.agentUsername ?? agentUsername ? { agentUsername: existing?.agentUsername ?? agentUsername } : {},
1279
+ message: existing?.message ?? "",
1280
+ status: info.ok ? "replied" : "error",
1281
+ durationMs: info.durationMs,
1282
+ ...info.reason ? { error: info.reason } : {},
1283
+ ...info.reply ? { reply: info.reply } : {}
1284
+ };
1285
+ this.upsertDispatch(record);
1286
+ this.emit(info.ok ? "dispatch:completed" : "dispatch:error", dispatchToWire(record));
1287
+ }
1288
+ upsertDispatch(record) {
1289
+ const idx = this.dispatches.findIndex((d) => d.id === record.id);
1290
+ if (idx >= 0) {
1291
+ this.dispatches[idx] = record;
1292
+ return;
1293
+ }
1294
+ this.dispatches.unshift(record);
1295
+ if (this.dispatches.length > MAX_DISPATCHES) this.dispatches.length = MAX_DISPATCHES;
1296
+ }
1297
+ // ---- snapshots for the HTTP routes ----
1298
+ setDashboardUrl(url) {
1299
+ this.dashboardUrl = url;
1300
+ }
1301
+ recentDispatches(limit = 20) {
1302
+ return this.dispatches.slice(0, Math.max(0, limit));
1548
1303
  }
1549
- return parsed;
1550
- }
1551
- async function verifyRuntime(state, requestImpl = controlRequest) {
1552
- if (!state.instanceId || !state.socket) return "unknown";
1553
- let body;
1554
- try {
1555
- body = await requestImpl(
1556
- state.socket,
1557
- { cmd: "status" },
1558
- PROBE_TIMEOUT_MS
1559
- );
1560
- } catch (err) {
1561
- return isNotListening(err) ? "stale" : "unknown";
1304
+ statusJson() {
1305
+ const list = [...this.workspaces.values()].sort((a, b) => a.name.localeCompare(b.name));
1306
+ const lastOverall = list.reduce((acc, ws) => {
1307
+ if (!ws.lastEventAt) return acc;
1308
+ if (!acc || ws.lastEventAt > acc) return ws.lastEventAt;
1309
+ return acc;
1310
+ }, null);
1311
+ return {
1312
+ cabane_url: this.opts.baseUrl,
1313
+ device_id: this.deviceId,
1314
+ device_label: this.deviceLabel,
1315
+ device_error: this.deviceError,
1316
+ connected: list.some((ws) => ws.connected),
1317
+ workspaces: list.map((ws) => ({
1318
+ slug: ws.slug,
1319
+ name: ws.name,
1320
+ last_event_at: ws.lastEventAt,
1321
+ dispatch_count_today: ws.dispatchCountToday,
1322
+ connected: ws.connected,
1323
+ auth_failed: ws.authFailed,
1324
+ agents: [...ws.agents.values()].sort((a, b) => a.username.localeCompare(b.username)).map((a) => ({
1325
+ agent_id: a.agentId,
1326
+ username: a.username,
1327
+ display_name: a.displayName,
1328
+ has_credential: a.hasCredential,
1329
+ missing_secrets: a.missingSecrets
1330
+ }))
1331
+ })),
1332
+ last_event_at_overall: lastOverall,
1333
+ dashboard_url: this.dashboardUrl,
1334
+ companion_version: this.opts.companionVersion,
1335
+ instance_id: this.opts.instanceId ?? null,
1336
+ harnesses: this.harnesses
1337
+ };
1562
1338
  }
1563
- if (typeof body?.instance_id !== "string") return "unknown";
1564
- return body.instance_id === state.instanceId ? "ours" : "stale";
1565
- }
1339
+ emitStatus() {
1340
+ this.emit("status:changed", this.statusJson());
1341
+ }
1342
+ };
1566
1343
 
1567
1344
  // src/heartbeat-status.ts
1568
1345
  import { z as z3 } from "zod";
@@ -2519,27 +2296,27 @@ function requestSignal(signal, timeoutMs = 5e3) {
2519
2296
  // src/credentials.ts
2520
2297
  import {
2521
2298
  chmodSync as chmodSync3,
2522
- existsSync as existsSync7,
2299
+ existsSync as existsSync6,
2523
2300
  mkdirSync as mkdirSync5,
2524
2301
  readFileSync as readFileSync4,
2525
2302
  renameSync as renameSync3,
2526
2303
  rmSync as rmSync4,
2527
2304
  writeFileSync as writeFileSync3
2528
2305
  } from "fs";
2529
- import { join as join11 } from "path";
2306
+ import { join as join9 } from "path";
2530
2307
  import { z as z5 } from "zod";
2531
2308
  var credentialMapSchema = z5.record(z5.string(), z5.string());
2532
2309
  function credentialsDir() {
2533
- return join11(cabaneDir(), "credentials");
2310
+ return join9(cabaneDir(), "credentials");
2534
2311
  }
2535
2312
  function legacyPath() {
2536
- return join11(cabaneDir(), "credentials.json");
2313
+ return join9(cabaneDir(), "credentials.json");
2537
2314
  }
2538
2315
  function devicePath(deviceId) {
2539
- return join11(credentialsDir(), `${deviceId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
2316
+ return join9(credentialsDir(), `${deviceId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
2540
2317
  }
2541
2318
  function read(path) {
2542
- if (!existsSync7(path)) return null;
2319
+ if (!existsSync6(path)) return null;
2543
2320
  let raw;
2544
2321
  try {
2545
2322
  raw = readFileSync4(path, "utf8");
@@ -2604,18 +2381,18 @@ function pruneCredentials(deviceId, keepAgentIds) {
2604
2381
  }
2605
2382
 
2606
2383
  // src/dispatch-dedupe.ts
2607
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync4, renameSync as renameSync4, existsSync as existsSync8 } from "fs";
2608
- import { join as join12 } from "path";
2384
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync4, renameSync as renameSync4, existsSync as existsSync7 } from "fs";
2385
+ import { join as join10 } from "path";
2609
2386
  var MAX_IDS = 256;
2610
2387
  function dir(log) {
2611
- return join12(cabaneDir(), log);
2388
+ return join10(cabaneDir(), log);
2612
2389
  }
2613
2390
  function pathFor(log, workspaceId) {
2614
- return join12(dir(log), encodeURIComponent(workspaceId));
2391
+ return join10(dir(log), encodeURIComponent(workspaceId));
2615
2392
  }
2616
2393
  function readIds(log, workspaceId) {
2617
2394
  const path = pathFor(log, workspaceId);
2618
- if (!existsSync8(path)) return [];
2395
+ if (!existsSync7(path)) return [];
2619
2396
  try {
2620
2397
  return readFileSync5(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
2621
2398
  } catch {
@@ -2648,15 +2425,15 @@ function markCompleted(workspaceId, eventId) {
2648
2425
  }
2649
2426
  var MAX_RESUME_ATTEMPTS = 3;
2650
2427
  function resumeDir() {
2651
- return join12(cabaneDir(), "resume-attempts");
2428
+ return join10(cabaneDir(), "resume-attempts");
2652
2429
  }
2653
2430
  function resumePathFor(workspaceId) {
2654
- return join12(resumeDir(), encodeURIComponent(workspaceId));
2431
+ return join10(resumeDir(), encodeURIComponent(workspaceId));
2655
2432
  }
2656
2433
  function readResumeCounts(workspaceId) {
2657
2434
  const out = /* @__PURE__ */ new Map();
2658
2435
  const path = resumePathFor(workspaceId);
2659
- if (!existsSync8(path)) return out;
2436
+ if (!existsSync7(path)) return out;
2660
2437
  try {
2661
2438
  for (const line of readFileSync5(path, "utf8").split("\n")) {
2662
2439
  const trimmed = line.trim();
@@ -2687,16 +2464,16 @@ function bumpResumeAttempt(workspaceId, eventId) {
2687
2464
  return next;
2688
2465
  }
2689
2466
  function turnDir() {
2690
- return join12(cabaneDir(), "turns");
2467
+ return join10(cabaneDir(), "turns");
2691
2468
  }
2692
2469
  function turnPathFor(workspaceId) {
2693
- return join12(turnDir(), encodeURIComponent(workspaceId));
2470
+ return join10(turnDir(), encodeURIComponent(workspaceId));
2694
2471
  }
2695
2472
  var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2696
2473
  function readTurnIds(workspaceId) {
2697
2474
  const out = /* @__PURE__ */ new Map();
2698
2475
  const path = turnPathFor(workspaceId);
2699
- if (!existsSync8(path)) return out;
2476
+ if (!existsSync7(path)) return out;
2700
2477
  try {
2701
2478
  for (const line of readFileSync5(path, "utf8").split("\n")) {
2702
2479
  const trimmed = line.trim();
@@ -2878,7 +2655,7 @@ var turnOutcomes = ["success", "failure", "cancelled", "skipped"];
2878
2655
  var turnSessionModes = ["fresh", "resumed", "degraded"];
2879
2656
  var turnRuntimeResultKinds = ["success", "error", "no_terminal"];
2880
2657
  var turnFinalSources = [
2881
- // The agent declared its answer with the `answer` turn act (CT1973).
2658
+ // The agent declared its answer with the `answer` turn act.
2882
2659
  "declared",
2883
2660
  "runtime_text",
2884
2661
  "progress_promotion",
@@ -3019,15 +2796,6 @@ function standingSegments(req) {
3019
2796
  return req.standing ?? { core: req.systemPrompt, conversation: "" };
3020
2797
  }
3021
2798
 
3022
- // packages/agent-runtime/src/conformance.ts
3023
- import { z as z9 } from "zod";
3024
- var conformanceFixtureSchema = z9.object({
3025
- name: z9.string(),
3026
- request: turnRequestSchema,
3027
- nativeStream: z9.array(z9.unknown()),
3028
- expected: z9.array(turnEventSchema)
3029
- });
3030
-
3031
2799
  // packages/agent-runtime/src/transcript.ts
3032
2800
  function createTerminalTextBuffer() {
3033
2801
  return { held: null };
@@ -3295,7 +3063,7 @@ function envResolvedFrom(server, secretEnv) {
3295
3063
  }
3296
3064
 
3297
3065
  // packages/agent-runtime/src/claude-code/policy.ts
3298
- import { z as z10 } from "zod";
3066
+ import { z as z9 } from "zod";
3299
3067
  var SUBAGENT_TOOLS = ["Task", "Agent"];
3300
3068
  var UNSUPPORTED_TOOLS = ["AskUserQuestion"];
3301
3069
  var STRANDING_TOOLS = [
@@ -3322,18 +3090,18 @@ function withThinkingSummaries(thinking) {
3322
3090
  if (thinking.type === "disabled") return thinking;
3323
3091
  return { display: "summarized", ...thinking };
3324
3092
  }
3325
- var claudeCodeDialectSchema = z10.object({
3326
- thinking: z10.discriminatedUnion("type", [
3327
- z10.object({
3328
- type: z10.literal("adaptive"),
3329
- display: z10.enum(["summarized", "omitted"]).optional()
3093
+ var claudeCodeDialectSchema = z9.object({
3094
+ thinking: z9.discriminatedUnion("type", [
3095
+ z9.object({
3096
+ type: z9.literal("adaptive"),
3097
+ display: z9.enum(["summarized", "omitted"]).optional()
3330
3098
  }),
3331
- z10.object({
3332
- type: z10.literal("enabled"),
3333
- budgetTokens: z10.number().int().positive().optional(),
3334
- display: z10.enum(["summarized", "omitted"]).optional()
3099
+ z9.object({
3100
+ type: z9.literal("enabled"),
3101
+ budgetTokens: z9.number().int().positive().optional(),
3102
+ display: z9.enum(["summarized", "omitted"]).optional()
3335
3103
  }),
3336
- z10.object({ type: z10.literal("disabled") })
3104
+ z9.object({ type: z9.literal("disabled") })
3337
3105
  ]).optional()
3338
3106
  }).loose();
3339
3107
  function readThinking(runtimeOptions) {
@@ -3396,7 +3164,7 @@ async function preToolUseHook(input) {
3396
3164
  hookSpecificOutput: {
3397
3165
  hookEventName: "PreToolUse",
3398
3166
  permissionDecision: "allow",
3399
- permissionDecisionReason: "headless never-prompt (CT680)",
3167
+ permissionDecisionReason: "headless never-prompt",
3400
3168
  updatedInput: toolInput
3401
3169
  }
3402
3170
  };
@@ -3921,7 +3689,7 @@ async function* runWithResumeRecovery(queryFn, req, signal, initial, augmentOpti
3921
3689
  if (recoverResume) {
3922
3690
  if (signal.aborted) return;
3923
3691
  onWarn?.(
3924
- `claude-code adapter: stored session ${initial.resume} ended before system/init \u2014 starting one fresh session (CT1293 recovery)`,
3692
+ `claude-code adapter: stored session ${initial.resume} ended before system/init \u2014 starting one fresh session`,
3925
3693
  {
3926
3694
  cwd: req.local.cwd,
3927
3695
  reason: "session_start_failed",
@@ -3950,7 +3718,7 @@ function createClaudeCodeAdapter(deps = {}) {
3950
3718
  let built = buildClaudeCodeOptions(req, deps.augmentOptions);
3951
3719
  if (built.freshReason === "cwd_mismatch") {
3952
3720
  deps.onWarn?.(
3953
- "claude-code adapter: stored session cwd no longer matches the current environment \u2014 starting a fresh session (SJ527 guard)",
3721
+ "claude-code adapter: stored session cwd no longer matches the current environment \u2014 starting a fresh session",
3954
3722
  { cwd: req.local.cwd }
3955
3723
  );
3956
3724
  }
@@ -3982,538 +3750,6 @@ function createClaudeCodeAdapter(deps = {}) {
3982
3750
  }
3983
3751
  var claudeCodeAdapter = createClaudeCodeAdapter();
3984
3752
 
3985
- // packages/agent-runtime/src/claude-code/conformance.ts
3986
- var ABORT_SENTINEL = { __abortHere: true };
3987
- var CWD = "/env/here";
3988
- var HEALTHY_MCP_INVENTORY = {
3989
- initReceived: true,
3990
- servers: [{ name: "cabane", status: "connected" }],
3991
- toolCount: 1
3992
- };
3993
- function makeRequest(overrides2 = {}) {
3994
- return {
3995
- systemPrompt: "system",
3996
- prompt: "hi there",
3997
- content: [{ type: "text", text: "hi there" }],
3998
- // The adapter rejects a null model, so the fixture carries a real bare SDK id.
3999
- config: { model: "claude-opus-4-8" },
4000
- session: null,
4001
- cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
4002
- local: { cwd: CWD },
4003
- extra: { mcpServers: {} },
4004
- ...overrides2
4005
- };
4006
- }
4007
- var init = (sessionId, model) => ({
4008
- type: "system",
4009
- subtype: "init",
4010
- session_id: sessionId,
4011
- mcp_servers: HEALTHY_MCP_INVENTORY.servers,
4012
- tools: ["mcp__cabane__query"],
4013
- ...model ? { model } : {}
4014
- });
4015
- var assistantText = (text) => ({
4016
- type: "assistant",
4017
- message: { content: [{ type: "text", text }] }
4018
- });
4019
- var assistantTextTool = (text, tool2) => ({
4020
- type: "assistant",
4021
- message: {
4022
- content: [
4023
- { type: "text", text },
4024
- { type: "tool_use", id: tool2.id, name: tool2.name, input: tool2.input }
4025
- ]
4026
- }
4027
- });
4028
- var toolResult = (id, content, isError = false) => ({
4029
- type: "user",
4030
- message: { content: [{ type: "tool_result", tool_use_id: id, is_error: isError, content }] }
4031
- });
4032
- var resultSuccess = (sessionId) => ({
4033
- type: "result",
4034
- subtype: "success",
4035
- session_id: sessionId
4036
- });
4037
- var resultSuccessFull = (sessionId, extra) => ({
4038
- ...resultSuccess(sessionId),
4039
- ...extra
4040
- });
4041
- var resultError = (subtype) => ({
4042
- type: "result",
4043
- subtype,
4044
- session_id: "s"
4045
- });
4046
- var resultErrorFull = (subtype, extra = {}) => ({
4047
- type: "result",
4048
- subtype,
4049
- session_id: "s",
4050
- ...extra
4051
- });
4052
- var rateLimitEvent = (status, resetsAt, rateLimitType) => ({
4053
- type: "rate_limit_event",
4054
- rate_limit_info: {
4055
- status,
4056
- ...resetsAt !== void 0 ? { resetsAt } : {},
4057
- ...rateLimitType !== void 0 ? { rateLimitType } : {}
4058
- },
4059
- session_id: "s"
4060
- });
4061
- var authStatus = (error) => ({
4062
- type: "auth_status",
4063
- isAuthenticating: false,
4064
- output: [],
4065
- error,
4066
- session_id: "s"
4067
- });
4068
- var sessionEvent = (sdkSessionId, cwd = CWD, degraded = false) => ({
4069
- type: "session",
4070
- state: encodeSession({ sdkSessionId, cwd }),
4071
- ...degraded ? { degraded: true } : {}
4072
- });
4073
- var BASE_CLAUDE_CODE_CONFORMANCE_FIXTURES = [
4074
- {
4075
- // A plain text reply: the held text-only block flushes as the terminal final.
4076
- name: "clean turn",
4077
- request: makeRequest(),
4078
- nativeStream: [init("s1"), assistantText("Hello there."), resultSuccess("s1")],
4079
- expected: [
4080
- sessionEvent("s1"),
4081
- { type: "text", body: "Hello there.", terminal: true },
4082
- { type: "result", ok: true }
4083
- ]
4084
- },
4085
- {
4086
- // Narration + a tool call + result + closing reply.
4087
- name: "tool turn",
4088
- request: makeRequest(),
4089
- nativeStream: [
4090
- init("s1"),
4091
- assistantTextTool("Reading your notes.", {
4092
- id: "tu1",
4093
- name: "mcp__cabane__cabane_read",
4094
- input: { path: "sleep.md" }
4095
- }),
4096
- toolResult("tu1", [{ type: "text", text: "sleep early" }]),
4097
- assistantText("Here's the summary."),
4098
- resultSuccess("s1")
4099
- ],
4100
- expected: [
4101
- sessionEvent("s1"),
4102
- { type: "text", body: "Reading your notes.", terminal: false },
4103
- {
4104
- type: "tool",
4105
- id: "tu1",
4106
- name: "cabane_read",
4107
- phase: "start",
4108
- summary: "sleep.md",
4109
- input: { path: "sleep.md" },
4110
- // Derived from the raw `mcp__cabane__` name.
4111
- mcpServer: "cabane"
4112
- },
4113
- {
4114
- type: "tool",
4115
- id: "tu1",
4116
- name: "cabane_read",
4117
- phase: "done",
4118
- summary: "sleep.md",
4119
- input: { path: "sleep.md" },
4120
- result: [{ type: "text", text: "sleep early" }],
4121
- mcpServer: "cabane"
4122
- },
4123
- { type: "text", body: "Here's the summary.", terminal: true },
4124
- { type: "result", ok: true }
4125
- ]
4126
- },
4127
- {
4128
- // Highest risk: cancel mid-stream. The abort lands after the tool result; the adapter stops
4129
- // before the closing reply — no final text, no `result` event (the server writes the stopped
4130
- // marker).
4131
- name: "cancel mid-stream",
4132
- request: makeRequest(),
4133
- nativeStream: [
4134
- init("s1"),
4135
- assistantTextTool("Working on it.", {
4136
- id: "tu1",
4137
- name: "mcp__cabane__cabane_list",
4138
- input: {}
4139
- }),
4140
- toolResult("tu1"),
4141
- ABORT_SENTINEL,
4142
- assistantText("This reply must never be emitted."),
4143
- resultSuccess("s1")
4144
- ],
4145
- expected: [
4146
- sessionEvent("s1"),
4147
- { type: "text", body: "Working on it.", terminal: false },
4148
- {
4149
- type: "tool",
4150
- id: "tu1",
4151
- name: "cabane_list",
4152
- phase: "start",
4153
- summary: "",
4154
- input: {},
4155
- mcpServer: "cabane"
4156
- },
4157
- {
4158
- type: "tool",
4159
- id: "tu1",
4160
- name: "cabane_list",
4161
- phase: "done",
4162
- summary: "",
4163
- input: {},
4164
- result: void 0,
4165
- mcpServer: "cabane"
4166
- }
4167
- ]
4168
- },
4169
- {
4170
- // An error result: partial narration lands as `progress` (terminal:false),
4171
- // and the `result` event carries the machine reason.
4172
- name: "error result",
4173
- request: makeRequest(),
4174
- nativeStream: [init("s1"), assistantText("Partial work."), resultError("error_max_turns")],
4175
- expected: [
4176
- sessionEvent("s1"),
4177
- { type: "text", body: "Partial work.", terminal: false },
4178
- { type: "result", ok: false, reason: "result_error:error_max_turns" }
4179
- ]
4180
- },
4181
- {
4182
- // The empty-runtime anomaly's captured trace: `system/init` then a successful `result`, nothing
4183
- // in between. Distinct from `empty-final` (a tool ran, no speech); this produced nothing. The
4184
- // adapter reports faithfully — `result{ok:true}` — and the host decides that a success with no
4185
- // content-bearing event is a failed turn.
4186
- name: "zero-content success (empty runtime result)",
4187
- request: makeRequest(),
4188
- nativeStream: [init("s1"), resultSuccess("s1")],
4189
- expected: [sessionEvent("s1"), { type: "result", ok: true }]
4190
- },
4191
- {
4192
- // On resume, a completed background task can emit a notification and its own zero-token result
4193
- // before the prompt's frames. That result is not the turn boundary; decoding continues through
4194
- // the reply and real usage.
4195
- name: "task-notification result does not end a resumed turn",
4196
- request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
4197
- nativeStream: [
4198
- init("s1"),
4199
- {
4200
- type: "system",
4201
- subtype: "task_notification",
4202
- task_id: "task-1",
4203
- status: "completed",
4204
- output_file: "/tmp/task-1.output",
4205
- summary: "Background work completed.",
4206
- session_id: "s1"
4207
- },
4208
- resultSuccessFull("s1", {
4209
- is_error: false,
4210
- origin: { kind: "task-notification" },
4211
- usage: {
4212
- input_tokens: 0,
4213
- output_tokens: 0,
4214
- cache_read_input_tokens: 0,
4215
- cache_creation_input_tokens: 0
4216
- }
4217
- }),
4218
- assistantTextTool("Checking the returned work.", {
4219
- id: "tu1",
4220
- name: "mcp__cabane__cabane_read",
4221
- input: { path: "result.md" }
4222
- }),
4223
- toolResult("tu1", [{ type: "text", text: "ready" }]),
4224
- assistantText("The prompt completed normally."),
4225
- resultSuccessFull("s1", {
4226
- is_error: false,
4227
- usage: {
4228
- input_tokens: 120,
4229
- output_tokens: 30,
4230
- cache_read_input_tokens: 80,
4231
- cache_creation_input_tokens: 10
4232
- }
4233
- })
4234
- ],
4235
- expected: [
4236
- { type: "text", body: "Checking the returned work.", terminal: false },
4237
- {
4238
- type: "tool",
4239
- id: "tu1",
4240
- name: "cabane_read",
4241
- phase: "start",
4242
- summary: "result.md",
4243
- input: { path: "result.md" },
4244
- mcpServer: "cabane"
4245
- },
4246
- {
4247
- type: "tool",
4248
- id: "tu1",
4249
- name: "cabane_read",
4250
- phase: "done",
4251
- summary: "result.md",
4252
- input: { path: "result.md" },
4253
- result: [{ type: "text", text: "ready" }],
4254
- mcpServer: "cabane"
4255
- },
4256
- { type: "text", body: "The prompt completed normally.", terminal: true },
4257
- {
4258
- type: "result",
4259
- ok: true,
4260
- usage: {
4261
- inputTokens: 210,
4262
- outputTokens: 30,
4263
- cacheReadTokens: 80,
4264
- cacheCreationTokens: 10
4265
- }
4266
- }
4267
- ]
4268
- },
4269
- {
4270
- // The filter is deliberately narrow: an origin-less zero-token result still ends the turn, even
4271
- // if more frames follow.
4272
- name: "origin-less result still ends a resumed turn",
4273
- request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
4274
- nativeStream: [
4275
- init("s1"),
4276
- resultSuccessFull("s1", {
4277
- is_error: false,
4278
- usage: {
4279
- input_tokens: 0,
4280
- output_tokens: 0,
4281
- cache_read_input_tokens: 0,
4282
- cache_creation_input_tokens: 0
4283
- }
4284
- }),
4285
- assistantText("This must not be emitted."),
4286
- resultSuccess("s1")
4287
- ],
4288
- expected: [
4289
- {
4290
- type: "result",
4291
- ok: true,
4292
- usage: {
4293
- inputTokens: 0,
4294
- outputTokens: 0,
4295
- cacheReadTokens: 0,
4296
- cacheCreationTokens: 0
4297
- }
4298
- }
4299
- ]
4300
- },
4301
- {
4302
- // Empty-final: a clean turn that ended on a tool call with no closing text.
4303
- // The adapter emits NO final text — empty-final promotion is host/pump
4304
- // territory (this fixture pins that boundary).
4305
- name: "empty-final (no closing text)",
4306
- request: makeRequest(),
4307
- nativeStream: [
4308
- init("s1"),
4309
- assistantTextTool("", {
4310
- id: "tu1",
4311
- name: "mcp__cabane__cabane_write",
4312
- input: { path: "a.md" }
4313
- }),
4314
- toolResult("tu1"),
4315
- resultSuccess("s1")
4316
- ],
4317
- expected: [
4318
- sessionEvent("s1"),
4319
- {
4320
- type: "tool",
4321
- id: "tu1",
4322
- name: "cabane_write",
4323
- phase: "start",
4324
- summary: "a.md",
4325
- input: { path: "a.md" },
4326
- mcpServer: "cabane"
4327
- },
4328
- {
4329
- type: "tool",
4330
- id: "tu1",
4331
- name: "cabane_write",
4332
- phase: "done",
4333
- summary: "a.md",
4334
- input: { path: "a.md" },
4335
- result: void 0,
4336
- mcpServer: "cabane"
4337
- },
4338
- { type: "result", ok: true }
4339
- ]
4340
- },
4341
- {
4342
- // A subscription cap. The SDK emits a rejected `rate_limit_event` with a named subscription
4343
- // window; the terminal error result classifies as `usage_capped`. The held provider line
4344
- // independently classifies to the same kind, so it lands as a runtime notice, not agent
4345
- // progress.
4346
- name: "subscription cap \u2192 usage_capped",
4347
- request: makeRequest(),
4348
- nativeStream: [
4349
- init("s1"),
4350
- assistantText("You've hit your session limit \xB7 resets 8:20pm (UTC)"),
4351
- rateLimitEvent("rejected", void 0, "five_hour"),
4352
- resultError("error_during_execution")
4353
- ],
4354
- expected: [
4355
- sessionEvent("s1"),
4356
- {
4357
- type: "runtime_notice",
4358
- body: "You've hit your session limit \xB7 resets 8:20pm (UTC)"
4359
- },
4360
- { type: "result", ok: false, reason: "usage_capped" }
4361
- ]
4362
- },
4363
- {
4364
- // A failed turn can still contain the agent's own partial narration. No text classification
4365
- // means no authorship rewrite, even though the structured result is the same usage cap.
4366
- name: "partial narration before subscription cap stays progress",
4367
- request: makeRequest(),
4368
- nativeStream: [
4369
- init("s1"),
4370
- assistantText("Partial work."),
4371
- rateLimitEvent("rejected", void 0, "five_hour"),
4372
- resultError("error_during_execution")
4373
- ],
4374
- expected: [
4375
- sessionEvent("s1"),
4376
- { type: "text", body: "Partial work.", terminal: false },
4377
- { type: "result", ok: false, reason: "usage_capped" }
4378
- ]
4379
- },
4380
- {
4381
- // Recognized runtime prose stays authored when its kind disagrees with the structured terminal
4382
- // failure.
4383
- name: "cap notice before server failure stays progress",
4384
- request: makeRequest(),
4385
- nativeStream: [
4386
- init("s1"),
4387
- assistantText("You've hit your session limit \xB7 resets 8:20pm (UTC)"),
4388
- resultErrorFull("error_during_execution", {
4389
- is_error: true,
4390
- result: '529 {"type":"error","error":{"type":"overloaded_error"}}'
4391
- })
4392
- ],
4393
- expected: [
4394
- sessionEvent("s1"),
4395
- {
4396
- type: "text",
4397
- body: "You've hit your session limit \xB7 resets 8:20pm (UTC)",
4398
- terminal: false
4399
- },
4400
- { type: "result", ok: false, reason: "server_error" }
4401
- ]
4402
- },
4403
- {
4404
- // `classifyErrorText` deliberately searches arbitrary error blobs for status tokens. Assistant
4405
- // narration needs the stricter whole-notice classifier, or an incidental count would be
4406
- // de-authored when the turn later fails with the matching provider kind.
4407
- name: "incidental 5xx-shaped narration before server failure stays progress",
4408
- request: makeRequest(),
4409
- nativeStream: [
4410
- init("s1"),
4411
- assistantText("Indexed 500 files. Writing the summary next."),
4412
- resultErrorFull("error_during_execution", {
4413
- is_error: true,
4414
- result: '529 {"type":"error","error":{"type":"overloaded_error"}}'
4415
- })
4416
- ],
4417
- expected: [
4418
- sessionEvent("s1"),
4419
- {
4420
- type: "text",
4421
- body: "Indexed 500 files. Writing the summary next.",
4422
- terminal: false
4423
- },
4424
- { type: "result", ok: false, reason: "server_error" }
4425
- ]
4426
- },
4427
- {
4428
- // A cap signalled only by the result's `terminal_reason:'blocking_limit'` (no rate-limit event)
4429
- // still classifies as `usage_capped`.
4430
- name: "blocking_limit terminal reason \u2192 usage_capped",
4431
- request: makeRequest(),
4432
- nativeStream: [
4433
- init("s1"),
4434
- resultErrorFull("error_during_execution", {
4435
- is_error: true,
4436
- terminal_reason: "blocking_limit",
4437
- result: "stopped"
4438
- })
4439
- ],
4440
- expected: [sessionEvent("s1"), { type: "result", ok: false, reason: "usage_capped" }]
4441
- },
4442
- {
4443
- // An expired login surfaces as an `auth_status` error frame; the failing result classifies as
4444
- // `auth_expired`.
4445
- name: "expired login \u2192 auth_expired",
4446
- request: makeRequest(),
4447
- nativeStream: [
4448
- init("s1"),
4449
- authStatus("OAuth token has expired, please run /login"),
4450
- resultErrorFull("error_during_execution", {
4451
- is_error: true,
4452
- result: "authentication failed"
4453
- })
4454
- ],
4455
- expected: [sessionEvent("s1"), { type: "result", ok: false, reason: "auth_expired" }]
4456
- },
4457
- {
4458
- // A transient provider outage in the terminal result text (no structured event or enum) — a 529
4459
- // overload — classifies as `server_error` via the text fallback. It must not read as the cap or
4460
- // stamp the connector `limited`.
4461
- name: "provider overload text \u2192 server_error",
4462
- request: makeRequest(),
4463
- nativeStream: [
4464
- init("s1"),
4465
- resultErrorFull("error_during_execution", {
4466
- is_error: true,
4467
- result: '529 {"type":"error","error":{"type":"overloaded_error"}}'
4468
- })
4469
- ],
4470
- expected: [sessionEvent("s1"), { type: "result", ok: false, reason: "server_error" }]
4471
- },
4472
- {
4473
- // The resolved model is captured off `system/init.model` and attached to the terminal `result`
4474
- // even on a plain resume (this fixture resumes, so no `session` event fires). What's asserted is
4475
- // that the concrete id the harness reported comes home.
4476
- name: "resolved model captured from system/init",
4477
- request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
4478
- nativeStream: [init("s1", "claude-opus-4-8"), assistantText("Done."), resultSuccess("s1")],
4479
- expected: [
4480
- { type: "text", body: "Done.", terminal: true },
4481
- { type: "result", ok: true, resolvedModel: "claude-opus-4-8" }
4482
- ]
4483
- },
4484
- {
4485
- // Session resume echo: the stored state matches the cwd, so its SDK id is resumed and
4486
- // `system/init` echoes the same id — no session event fires.
4487
- name: "session resume (no re-emit on echo)",
4488
- request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
4489
- nativeStream: [init("s1"), assistantText("Back again."), resultSuccess("s1")],
4490
- expected: [
4491
- { type: "text", body: "Back again.", terminal: true },
4492
- { type: "result", ok: true }
4493
- ]
4494
- },
4495
- {
4496
- // Cwd-mismatch degrade: the stored state was created under another cwd, so the adapter starts
4497
- // fresh — `system/init` reports a new id, and the session event records the current cwd.
4498
- name: "session cwd-mismatch degrades to fresh",
4499
- request: makeRequest({ session: encodeSession({ sdkSessionId: "old", cwd: "/env/gone" }) }),
4500
- nativeStream: [init("fresh1"), assistantText("Fresh start."), resultSuccess("fresh1")],
4501
- expected: [
4502
- // The degrade is flagged on the session event so the host relays it on settle and the server
4503
- // rewinds the catch-up mark (recovering context the discarded session no longer carries).
4504
- sessionEvent("fresh1", CWD, true),
4505
- { type: "text", body: "Fresh start.", terminal: true },
4506
- { type: "result", ok: true }
4507
- ]
4508
- }
4509
- ];
4510
- var CLAUDE_CODE_CONFORMANCE_FIXTURES = BASE_CLAUDE_CODE_CONFORMANCE_FIXTURES.map((fixture) => ({
4511
- ...fixture,
4512
- expected: fixture.expected.map(
4513
- (event) => event.type === "result" ? { ...event, mcpInventory: HEALTHY_MCP_INVENTORY } : event
4514
- )
4515
- }));
4516
-
4517
3753
  // packages/agent-runtime/src/opencode/addendum.ts
4518
3754
  var OPENCODE_ADDENDUM = [
4519
3755
  "Every tool here goes by its plain name: the workspace tools are `query`, `mutate` and",
@@ -4755,7 +3991,7 @@ function sealHeld(held, terminal) {
4755
3991
  }
4756
3992
 
4757
3993
  // packages/agent-runtime/src/opencode/policy.ts
4758
- import { z as z11 } from "zod";
3994
+ import { z as z10 } from "zod";
4759
3995
  var OPENCODE_SCHEDULING_TOOLS = [];
4760
3996
  var OPENCODE_UI_PROMPT_TOOLS = ["question"];
4761
3997
  function opencodeTools() {
@@ -4764,8 +4000,8 @@ function opencodeTools() {
4764
4000
  tools[name] = false;
4765
4001
  return tools;
4766
4002
  }
4767
- var opencodeDialectSchema = z11.object({
4768
- agent: z11.string().min(1).optional()
4003
+ var opencodeDialectSchema = z10.object({
4004
+ agent: z10.string().min(1).optional()
4769
4005
  }).loose();
4770
4006
  function readOpencodeDialect(runtimeOptions) {
4771
4007
  const parsed = opencodeDialectSchema.safeParse(runtimeOptions?.["opencode"] ?? {});
@@ -5149,7 +4385,7 @@ function createOpencodeAdapter(deps = {}) {
5149
4385
  const resume = "resume" in decision ? decision.resume : null;
5150
4386
  if ("fresh" in decision && decision.reason === "directory_mismatch") {
5151
4387
  deps.onWarn?.(
5152
- "opencode adapter: stored session directory no longer matches the current environment \u2014 starting a fresh session (SJ527-analogue guard)",
4388
+ "opencode adapter: stored session directory no longer matches the current environment \u2014 starting a fresh session",
5153
4389
  { directory }
5154
4390
  );
5155
4391
  }
@@ -5166,499 +4402,23 @@ function createOpencodeAdapter(deps = {}) {
5166
4402
  const result = await transport.run(spec, signal);
5167
4403
  if (signal.aborted) return;
5168
4404
  if (result.created || result.sessionId !== resume) {
5169
- yield {
5170
- type: "session",
5171
- state: encodeSession2({ sessionId: result.sessionId, directory: result.directory }),
5172
- ...degraded ? { degraded: true } : {}
5173
- };
5174
- }
5175
- yield* decodeOpencodeStream(result.events, {
5176
- signal,
5177
- sessionId: result.sessionId,
5178
- // The keys the turn's servers were installed under: what the decoder
5179
- // splits a raw `<server>_<tool>` id on.
5180
- mcpServers: Object.keys(spec.mcp)
5181
- });
5182
- }
5183
- };
5184
- }
5185
- var opencodeAdapter = createOpencodeAdapter();
5186
-
5187
- // packages/agent-runtime/src/opencode/conformance.ts
5188
- var ABORT_SENTINEL2 = { __abortHere: true };
5189
- var NEW_SESSION_ID = "sess_new";
5190
- var BOUNDED_NOTICE = "You've hit your usage limit \xB7 ".padEnd(200, "x");
5191
- var DIR = "/env/here";
5192
- function makeRequest2(overrides2 = {}) {
5193
- return {
5194
- systemPrompt: "system",
5195
- prompt: "hi there",
5196
- content: [{ type: "text", text: "hi there" }],
5197
- config: { model: "deepseek/deepseek-chat" },
5198
- session: null,
5199
- cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
5200
- local: { cwd: DIR },
5201
- extra: { mcpServers: {} },
5202
- ...overrides2
5203
- };
5204
- }
5205
- var textPart = (id, text) => ({
5206
- type: "message.part.updated",
5207
- properties: { part: { type: "text", id, text } }
5208
- });
5209
- var reasoningPart = (id, text) => ({
5210
- type: "message.part.updated",
5211
- properties: { part: { type: "reasoning", id, text } }
5212
- });
5213
- var toolPart = (id, tool2, state, callID) => ({
5214
- type: "message.part.updated",
5215
- properties: { part: { type: "tool", id, tool: tool2, state, ...callID ? { callID } : {} } }
5216
- });
5217
- var messageUpdated = (role, id) => ({
5218
- type: "message.updated",
5219
- properties: { info: { role, id } }
5220
- });
5221
- var echoedUserPart = (partId, messageID, text) => ({
5222
- type: "message.part.updated",
5223
- properties: { part: { type: "text", id: partId, messageID, text } }
5224
- });
5225
- var idle = () => ({ type: "session.idle", properties: {} });
5226
- var errored = (message) => ({
5227
- type: "session.error",
5228
- properties: { error: { message } }
5229
- });
5230
- var sessionEvent2 = (sessionId, directory = DIR, degraded = false) => ({
5231
- type: "session",
5232
- state: encodeSession2({ sessionId, directory }),
5233
- ...degraded ? { degraded: true } : {}
5234
- });
5235
- var OPENCODE_CONFORMANCE_FIXTURES = [
5236
- {
5237
- // A plain text reply: the held text part seals as the terminal final at idle.
5238
- name: "clean turn",
5239
- request: makeRequest2(),
5240
- nativeStream: [textPart("p1", "Hello there."), idle()],
5241
- expected: [
5242
- sessionEvent2(NEW_SESSION_ID),
5243
- { type: "text", body: "Hello there.", terminal: true },
5244
- { type: "result", ok: true }
5245
- ]
5246
- },
5247
- {
5248
- // Narration + a tool call + result + closing reply. The narration part is
5249
- // sealed as interim (a tool follows it); the trailing text is the final.
5250
- name: "tool turn",
5251
- request: makeRequest2(),
5252
- nativeStream: [
5253
- textPart("p1", "Reading your notes."),
5254
- toolPart("t1", "read", { status: "running", input: { path: "sleep.md" } }, "call_1"),
5255
- toolPart(
5256
- "t1",
5257
- "read",
5258
- { status: "completed", input: { path: "sleep.md" }, output: "sleep early" },
5259
- "call_1"
5260
- ),
5261
- textPart("p2", "Here's the summary."),
5262
- idle()
5263
- ],
5264
- expected: [
5265
- sessionEvent2(NEW_SESSION_ID),
5266
- { type: "text", body: "Reading your notes.", terminal: false },
5267
- {
5268
- type: "tool",
5269
- id: "call_1",
5270
- name: "read",
5271
- phase: "start",
5272
- summary: "sleep.md",
5273
- input: { path: "sleep.md" }
5274
- },
5275
- {
5276
- type: "tool",
5277
- id: "call_1",
5278
- name: "read",
5279
- phase: "done",
5280
- summary: "sleep.md",
5281
- input: { path: "sleep.md" },
5282
- result: "sleep early"
5283
- },
5284
- { type: "text", body: "Here's the summary.", terminal: true },
5285
- { type: "result", ok: true }
5286
- ]
5287
- },
5288
- {
5289
- // A streaming provider's tool input arrives late. opencode re-emits the tool part as a full
5290
- // snapshot as the args parse: DeepSeek sends `input: {}` on the early `pending`/`running` frame
5291
- // and the full args on a later one (captured on opencode 1.17: `pending {}` → `running {full}` →
5292
- // `completed {full}`). `start` fires off the first (empty) frame — the live card — but `done`
5293
- // (and the row it upserts) must carry the latest non-empty input and its summary. Every other
5294
- // tool fixture bakes the full input into the first snapshot, so only this one catches it.
5295
- name: "streaming tool input \u2014 late args refresh the done event (CT301)",
5296
- request: makeRequest2(),
5297
- nativeStream: [
5298
- toolPart("t1", "cabane_list", { status: "pending", input: {} }, "call_1"),
5299
- toolPart(
5300
- "t1",
5301
- "cabane_list",
5302
- { status: "running", input: { path: "/product/tasks" } },
5303
- "call_1"
5304
- ),
5305
- toolPart(
5306
- "t1",
5307
- "cabane_list",
5308
- { status: "completed", input: { path: "/product/tasks" }, output: "CT301.md" },
5309
- "call_1"
5310
- ),
5311
- idle()
5312
- ],
5313
- expected: [
5314
- sessionEvent2(NEW_SESSION_ID),
5315
- // start: fires off the first (empty) snapshot — empty input + summary, by design.
5316
- {
5317
- type: "tool",
5318
- id: "call_1",
5319
- name: "cabane_list",
5320
- phase: "start",
5321
- summary: "",
5322
- input: {},
5323
- mcpServer: "cabane"
5324
- },
5325
- // done: carries the full args captured from the later snapshot, and the
5326
- // summary recomputed from them — NOT the first-seen `{}`.
5327
- {
5328
- type: "tool",
5329
- id: "call_1",
5330
- name: "cabane_list",
5331
- phase: "done",
5332
- summary: "/product/tasks",
5333
- input: { path: "/product/tasks" },
5334
- result: "CT301.md",
5335
- mcpServer: "cabane"
5336
- },
5337
- { type: "result", ok: true }
5338
- ]
5339
- },
5340
- {
5341
- // The turn-control server's `answer` (CT1973). opencode names an MCP tool `<server>_<tool>`,
5342
- // so the installed keys split the raw id: `cabane_companion_answer` is the turn-control
5343
- // server's, never the workspace server's `companion_answer`. The pump binds on that.
5344
- name: "turn-control answer is read as the turn-control server\u2019s, not cabane\u2019s",
5345
- request: makeRequest2({
5346
- cabane: {
5347
- mcpUrl: "https://cabane.test/api/mcp",
5348
- turnControlUrl: "https://cabane.test/api/turn-control",
5349
- bearer: "tok",
5350
- activeConversationId: "conv"
5351
- }
5352
- }),
5353
- nativeStream: [
5354
- textPart("p1", "On it."),
5355
- toolPart("t1", "cabane_companion_answer", { status: "running", input: {} }, "call_1"),
5356
- toolPart(
5357
- "t1",
5358
- "cabane_companion_answer",
5359
- { status: "completed", input: {}, output: '{"ok":true,"kind":"answer"}' },
5360
- "call_1"
5361
- ),
5362
- textPart("p2", "The answer."),
5363
- idle()
5364
- ],
5365
- expected: [
5366
- sessionEvent2(NEW_SESSION_ID),
5367
- { type: "text", body: "On it.", terminal: false },
5368
- {
5369
- type: "tool",
5370
- id: "call_1",
5371
- name: "cabane_companion_answer",
5372
- phase: "start",
5373
- summary: "",
5374
- input: {},
5375
- mcpServer: "cabane_companion"
5376
- },
5377
- {
5378
- type: "tool",
5379
- id: "call_1",
5380
- name: "cabane_companion_answer",
5381
- phase: "done",
5382
- summary: "",
5383
- input: {},
5384
- result: '{"ok":true,"kind":"answer"}',
5385
- mcpServer: "cabane_companion"
5386
- },
5387
- { type: "text", body: "The answer.", terminal: true },
5388
- { type: "result", ok: true }
5389
- ]
5390
- },
5391
- {
5392
- // Highest risk: cancel mid-stream. The abort lands after the tool result; the adapter stops before
5393
- // the closing reply — no final text, no `result` event (the server writes the stopped marker).
5394
- name: "cancel mid-stream",
5395
- request: makeRequest2(),
5396
- nativeStream: [
5397
- textPart("p1", "Working on it."),
5398
- toolPart("t1", "list", { status: "running", input: {} }, "call_1"),
5399
- toolPart("t1", "list", { status: "completed", input: {}, output: "ok" }, "call_1"),
5400
- ABORT_SENTINEL2,
5401
- textPart("p2", "This reply must never be emitted."),
5402
- idle()
5403
- ],
5404
- expected: [
5405
- sessionEvent2(NEW_SESSION_ID),
5406
- { type: "text", body: "Working on it.", terminal: false },
5407
- { type: "tool", id: "call_1", name: "list", phase: "start", summary: "", input: {} },
5408
- {
5409
- type: "tool",
5410
- id: "call_1",
5411
- name: "list",
5412
- phase: "done",
5413
- summary: "",
5414
- input: {},
5415
- result: "ok"
5416
- }
5417
- ]
5418
- },
5419
- {
5420
- // An error result: partial narration lands as `progress` (terminal:false),
5421
- // and the `result` event carries the machine reason.
5422
- name: "error result",
5423
- request: makeRequest2(),
5424
- nativeStream: [textPart("p1", "Partial work."), errored("provider exploded")],
5425
- expected: [
5426
- sessionEvent2(NEW_SESSION_ID),
5427
- { type: "text", body: "Partial work.", terminal: false },
5428
- { type: "runtime_notice", body: "provider exploded" },
5429
- { type: "result", ok: false, reason: "error:provider exploded" }
5430
- ]
5431
- },
5432
- {
5433
- // A provider 429 throttle. The wrapped `RateLimitError` is unwrapped by `readSessionError` and
5434
- // text-classified: a bare "rate limit (429)" with no cap noun is the throttle (`rate_limited`),
5435
- // not the account cap.
5436
- name: "provider 429 \u2192 rate_limited (throttle, no reset)",
5437
- request: makeRequest2(),
5438
- nativeStream: [
5439
- {
5440
- type: "session.error",
5441
- properties: { error: { name: "RateLimitError", message: "rate limit exceeded (429)" } }
5442
- }
5443
- ],
5444
- expected: [
5445
- sessionEvent2(NEW_SESSION_ID),
5446
- { type: "runtime_notice", body: "RateLimitError: rate limit exceeded (429)" },
5447
- { type: "result", ok: false, reason: "rate_limited" }
5448
- ]
5449
- },
5450
- {
5451
- // A subscription cap surfaced as text ("usage limit reached") → `usage_capped`. opencode carries
5452
- // no reset hint, so it's reset-less (the server says "try again later").
5453
- name: "usage cap text \u2192 usage_capped (no reset)",
5454
- request: makeRequest2(),
5455
- nativeStream: [
5456
- {
5457
- type: "session.error",
5458
- properties: { error: { name: "Error", message: "Claude usage limit reached" } }
5459
- }
5460
- ],
5461
- expected: [
5462
- sessionEvent2(NEW_SESSION_ID),
5463
- { type: "runtime_notice", body: "Claude usage limit reached" },
5464
- { type: "result", ok: false, reason: "usage_capped" }
5465
- ]
5466
- },
5467
- {
5468
- // A transient provider outage (5xx / overload) surfaced as text → `server_error`, never the cap.
5469
- name: "provider overload text \u2192 server_error",
5470
- request: makeRequest2(),
5471
- nativeStream: [
5472
- {
5473
- type: "session.error",
5474
- properties: {
5475
- error: { name: "Error", message: "AI_APICallError: overloaded_error (529)" }
5476
- }
5477
- }
5478
- ],
5479
- expected: [
5480
- sessionEvent2(NEW_SESSION_ID),
5481
- { type: "runtime_notice", body: "AI_APICallError: overloaded_error (529)" },
5482
- { type: "result", ok: false, reason: "server_error" }
5483
- ]
5484
- },
5485
- {
5486
- // An expired or invalid credential classifies as `auth_expired`.
5487
- name: "auth failure \u2192 auth_expired",
5488
- request: makeRequest2(),
5489
- nativeStream: [
5490
- {
5491
- type: "session.error",
5492
- properties: { error: { name: "AuthenticationError", message: "HTTP 401 Unauthorized" } }
4405
+ yield {
4406
+ type: "session",
4407
+ state: encodeSession2({ sessionId: result.sessionId, directory: result.directory }),
4408
+ ...degraded ? { degraded: true } : {}
4409
+ };
5493
4410
  }
5494
- ],
5495
- expected: [
5496
- sessionEvent2(NEW_SESSION_ID),
5497
- { type: "runtime_notice", body: "AuthenticationError: HTTP 401 Unauthorized" },
5498
- { type: "result", ok: false, reason: "auth_expired" }
5499
- ]
5500
- },
5501
- {
5502
- // An unclassified failure stays informative: its provider text is visible, and the raw machine
5503
- // reason is unchanged.
5504
- name: "unclassified failure keeps a runtime notice and raw reason",
5505
- request: makeRequest2(),
5506
- nativeStream: [errored("something local broke")],
5507
- expected: [
5508
- sessionEvent2(NEW_SESSION_ID),
5509
- { type: "runtime_notice", body: "something local broke" },
5510
- { type: "result", ok: false, reason: "error:something local broke" }
5511
- ]
5512
- },
5513
- {
5514
- name: "runtime notice is trimmed and bounded to 200 characters",
5515
- request: makeRequest2(),
5516
- nativeStream: [errored(` ${BOUNDED_NOTICE}${"y".repeat(20)} `)],
5517
- expected: [
5518
- sessionEvent2(NEW_SESSION_ID),
5519
- { type: "runtime_notice", body: BOUNDED_NOTICE },
5520
- { type: "result", ok: false, reason: "usage_capped" }
5521
- ]
5522
- },
5523
- {
5524
- // Empty, whitespace-only and bare status-token failures still set the machine reason but add no
5525
- // empty or unhelpful transcript notice.
5526
- name: "empty failure text emits no runtime notice",
5527
- request: makeRequest2(),
5528
- nativeStream: [errored("")],
5529
- expected: [
5530
- sessionEvent2(NEW_SESSION_ID),
5531
- { type: "result", ok: false, reason: "error:unknown" }
5532
- ]
5533
- },
5534
- {
5535
- name: "whitespace-only failure text emits no runtime notice",
5536
- request: makeRequest2(),
5537
- nativeStream: [errored(" ")],
5538
- expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "error: " }]
5539
- },
5540
- {
5541
- name: "bare status token emits no runtime notice",
5542
- request: makeRequest2(),
5543
- nativeStream: [errored("429")],
5544
- expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "rate_limited" }]
5545
- },
5546
- {
5547
- name: "single-word provider sentence emits a runtime notice",
5548
- request: makeRequest2(),
5549
- nativeStream: [errored("Unavailable.")],
5550
- expected: [
5551
- sessionEvent2(NEW_SESSION_ID),
5552
- { type: "runtime_notice", body: "Unavailable." },
5553
- { type: "result", ok: false, reason: "error:Unavailable." }
5554
- ]
5555
- },
5556
- {
5557
- // The zero-content success the claude-code suite pins, in opencode's vocabulary — the session
5558
- // goes idle having emitted no part. No opencode sample has been seen; the fixture exists because
5559
- // the host's classification is runtime-neutral, so a runtime that can produce this envelope must
5560
- // be shown producing it in the shape the classifier reads.
5561
- name: "zero-content success (empty runtime result)",
5562
- request: makeRequest2(),
5563
- nativeStream: [idle()],
5564
- expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: true }]
5565
- },
5566
- {
5567
- // Empty-final: a clean turn that ended on a tool call with no closing text.
5568
- // The adapter emits NO final text — empty-final promotion is host/pump
5569
- // territory (this fixture pins that boundary).
5570
- name: "empty-final (no closing text)",
5571
- request: makeRequest2(),
5572
- nativeStream: [
5573
- toolPart("t1", "write", { status: "running", input: { path: "a.md" } }, "call_1"),
5574
- toolPart("t1", "write", { status: "completed", input: { path: "a.md" } }, "call_1"),
5575
- idle()
5576
- ],
5577
- expected: [
5578
- sessionEvent2(NEW_SESSION_ID),
5579
- {
5580
- type: "tool",
5581
- id: "call_1",
5582
- name: "write",
5583
- phase: "start",
5584
- summary: "a.md",
5585
- input: { path: "a.md" }
5586
- },
5587
- {
5588
- type: "tool",
5589
- id: "call_1",
5590
- name: "write",
5591
- phase: "done",
5592
- summary: "a.md",
5593
- input: { path: "a.md" },
5594
- result: void 0
5595
- },
5596
- { type: "result", ok: true }
5597
- ]
5598
- },
5599
- {
5600
- // Thinking: a reasoning part seals as a transient `thinking` when the reply
5601
- // text supersedes it; the text is the final.
5602
- name: "thinking then reply",
5603
- request: makeRequest2(),
5604
- nativeStream: [
5605
- reasoningPart("r1", "Let me check the notes."),
5606
- textPart("p1", "All set."),
5607
- idle()
5608
- ],
5609
- expected: [
5610
- sessionEvent2(NEW_SESSION_ID),
5611
- { type: "thinking", text: "Let me check the notes." },
5612
- { type: "text", body: "All set.", terminal: true },
5613
- { type: "result", ok: true }
5614
- ]
5615
- },
5616
- {
5617
- // The stored state matches the directory, so the adapter resumes its id and the transport reports
5618
- // `created:false` on the same id — no session event fires. Resume is what carries cross-turn
5619
- // memory.
5620
- name: "session resume (no re-emit on echo)",
5621
- request: makeRequest2({ session: encodeSession2({ sessionId: "s1", directory: DIR }) }),
5622
- nativeStream: [textPart("p1", "Back again."), idle()],
5623
- expected: [
5624
- { type: "text", body: "Back again.", terminal: true },
5625
- { type: "result", ok: true }
5626
- ]
5627
- },
5628
- {
5629
- // On resume, as on a fresh turn, the decoder learns the user-message id from the stream:
5630
- // `message.updated` user frame precedes the echoed part — and drops the echo, so the injected
5631
- // per-turn context can't leak into Steps. Only the reply surfaces; no session event.
5632
- name: "session resume \u2014 prompt echo filtered via stream-derived user id",
5633
- request: makeRequest2({ session: encodeSession2({ sessionId: "s1", directory: DIR }) }),
5634
- nativeStream: [
5635
- messageUpdated("user", "msg_u1"),
5636
- echoedUserPart("up1", "msg_u1", "INJECTED CONTEXT \u2014 must not leak"),
5637
- messageUpdated("assistant", "msg_a1"),
5638
- echoedUserPart("ap1", "msg_a1", "The real reply."),
5639
- idle()
5640
- ],
5641
- expected: [
5642
- { type: "text", body: "The real reply.", terminal: true },
5643
- { type: "result", ok: true }
5644
- ]
5645
- },
5646
- {
5647
- // Directory-mismatch degrade: the stored state was created under another directory, so the
5648
- // adapter starts fresh — the transport creates a new session, and the session event records the
5649
- // current directory.
5650
- name: "session directory-mismatch degrades to fresh",
5651
- request: makeRequest2({ session: encodeSession2({ sessionId: "old", directory: "/env/gone" }) }),
5652
- nativeStream: [textPart("p1", "Fresh start."), idle()],
5653
- expected: [
5654
- // The degrade is flagged on the session event so the host relays it on settle and the server
5655
- // rewinds the catch-up mark, recovering context the discarded session dropped.
5656
- sessionEvent2(NEW_SESSION_ID, DIR, true),
5657
- { type: "text", body: "Fresh start.", terminal: true },
5658
- { type: "result", ok: true }
5659
- ]
5660
- }
5661
- ];
4411
+ yield* decodeOpencodeStream(result.events, {
4412
+ signal,
4413
+ sessionId: result.sessionId,
4414
+ // The keys the turn's servers were installed under: what the decoder
4415
+ // splits a raw `<server>_<tool>` id on.
4416
+ mcpServers: Object.keys(spec.mcp)
4417
+ });
4418
+ }
4419
+ };
4420
+ }
4421
+ var opencodeAdapter = createOpencodeAdapter();
5662
4422
 
5663
4423
  // packages/agent-runtime/src/codex/addendum.ts
5664
4424
  var CODEX_ADDENDUM = [
@@ -6071,13 +4831,13 @@ function sealHeld2(held, terminal) {
6071
4831
  }
6072
4832
 
6073
4833
  // packages/agent-runtime/src/codex/policy.ts
6074
- import { z as z12 } from "zod";
4834
+ import { z as z11 } from "zod";
6075
4835
  function codexSandboxMode(local) {
6076
4836
  return local.codex?.configSetsSandboxMode ? null : "danger-full-access";
6077
4837
  }
6078
4838
  var CODEX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
6079
- var codexDialectSchema = z12.object({
6080
- modelReasoningEffort: z12.enum(CODEX_REASONING_EFFORTS).optional()
4839
+ var codexDialectSchema = z11.object({
4840
+ modelReasoningEffort: z11.enum(CODEX_REASONING_EFFORTS).optional()
6081
4841
  }).loose();
6082
4842
  function readCodexDialect(runtimeOptions) {
6083
4843
  const parsed = codexDialectSchema.safeParse(runtimeOptions?.["codex"] ?? {});
@@ -6380,7 +5140,7 @@ function createCodexAdapter(deps = {}) {
6380
5140
  let threadUsageBaseline = "resume" in decision ? decision.usageBaseline : null;
6381
5141
  if ("fresh" in decision && decision.reason === "cwd_mismatch") {
6382
5142
  deps.onWarn?.(
6383
- "codex adapter: stored session directory no longer matches the current environment \u2014 starting a fresh thread (SJ527-analogue guard)",
5143
+ "codex adapter: stored session directory no longer matches the current environment \u2014 starting a fresh thread",
6384
5144
  { directory }
6385
5145
  );
6386
5146
  }
@@ -6425,7 +5185,7 @@ function createCodexAdapter(deps = {}) {
6425
5185
  if (imageUrls.length > 0) {
6426
5186
  if (!deps.prepareImages) {
6427
5187
  deps.onWarn?.(
6428
- "codex adapter: no image preparer wired on this host \u2014 the turn runs without its images (CT1757)",
5188
+ "codex adapter: no image preparer wired on this host \u2014 the turn runs without its images",
6429
5189
  { images: imageUrls.length }
6430
5190
  );
6431
5191
  } else {
@@ -6434,7 +5194,7 @@ function createCodexAdapter(deps = {}) {
6434
5194
  } catch (err) {
6435
5195
  if (!signal.aborted) {
6436
5196
  deps.onWarn?.(
6437
- "codex adapter: preparing the images failed \u2014 the turn runs without them (CT1757)",
5197
+ "codex adapter: preparing the images failed \u2014 the turn runs without them",
6438
5198
  { err: err instanceof Error ? err.message : String(err) }
6439
5199
  );
6440
5200
  }
@@ -6510,7 +5270,7 @@ function createCodexAdapter(deps = {}) {
6510
5270
  try {
6511
5271
  await images.cleanup();
6512
5272
  } catch (err) {
6513
- deps.onWarn?.("codex adapter: image cleanup failed (CT1757)", {
5273
+ deps.onWarn?.("codex adapter: image cleanup failed", {
6514
5274
  err: err instanceof Error ? err.message : String(err)
6515
5275
  });
6516
5276
  }
@@ -6521,993 +5281,6 @@ function createCodexAdapter(deps = {}) {
6521
5281
  }
6522
5282
  var codexAdapter = createCodexAdapter();
6523
5283
 
6524
- // packages/agent-runtime/src/codex/conformance.ts
6525
- var ABORT_SENTINEL3 = { __abortHere: true };
6526
- var NEW_THREAD_ID = "th_new";
6527
- var BOUNDED_NOTICE2 = "You've hit your usage limit \xB7 ".padEnd(200, "x");
6528
- var DIR2 = "/env/here";
6529
- var PROMPT = "system";
6530
- function makeRequest3(overrides2 = {}) {
6531
- return {
6532
- systemPrompt: PROMPT,
6533
- prompt: "hi there",
6534
- content: [{ type: "text", text: "hi there" }],
6535
- // The resolved model and reasoning effort ride the terminal `result` off the run spec's model.
6536
- // Behavioural fixtures use the model-less token (no `resolvedModel` emitted); a dedicated capture
6537
- // fixture sets a real model and effort.
6538
- config: { model: null },
6539
- session: null,
6540
- cabane: { mcpUrl: "https://cabane.test/api/mcp", bearer: "tok", activeConversationId: "conv" },
6541
- local: { cwd: DIR2 },
6542
- extra: { mcpServers: {} },
6543
- ...overrides2
6544
- };
6545
- }
6546
- var threadStarted = (threadId) => ({
6547
- type: "thread.started",
6548
- thread_id: threadId
6549
- });
6550
- var agentMessage = (id, text) => ({
6551
- type: "item.completed",
6552
- item: { id, type: "agent_message", text }
6553
- });
6554
- var reasoning = (id, text) => ({
6555
- type: "item.completed",
6556
- item: { id, type: "reasoning", text }
6557
- });
6558
- var toolFrame = (evType, item) => ({ type: evType, item });
6559
- var turnCompleted = (usage) => ({
6560
- type: "turn.completed",
6561
- ...usage ? { usage } : {}
6562
- });
6563
- var turnFailed = (message) => ({
6564
- type: "turn.failed",
6565
- error: { message }
6566
- });
6567
- var fatalError = (message) => ({ type: "error", message });
6568
- var errorItem = (id, message) => ({
6569
- type: "item.completed",
6570
- item: { id, type: "error", message }
6571
- });
6572
- var OP_SUCCESS = { ok: true, result: { source: "native" } };
6573
- var OP_FAILURE = {
6574
- ok: false,
6575
- error: {
6576
- kind: "output_too_large",
6577
- code: "output_too_large",
6578
- message: "files.read returned more than the result ceiling"
6579
- }
6580
- };
6581
- var startedSession = (threadId, cwd = DIR2, degraded = false) => ({
6582
- type: "session",
6583
- state: encodeSession3({ threadId, cwd, promptFingerprint: null, usageBaseline: null }),
6584
- ...degraded ? { degraded: true } : {}
6585
- });
6586
- var closingSession = (threadId, usageBaseline = null) => ({
6587
- type: "session",
6588
- state: encodeSession3({
6589
- threadId,
6590
- cwd: DIR2,
6591
- promptFingerprint: fingerprintPrompt(PROMPT),
6592
- usageBaseline,
6593
- compactions: 0
6594
- })
6595
- });
6596
- var CODEX_CONFORMANCE_FIXTURES = [
6597
- {
6598
- // A plain text reply: the held agent message seals as the terminal final at `turn.completed`;
6599
- // usage rides the `result`.
6600
- //
6601
- // The counters nest — `cached_input_tokens` is a slice of `input_tokens`,
6602
- // `reasoning_output_tokens` a slice of `output_tokens` — so input is 100 (not 120) and output 50
6603
- // (not 60), with the cache read broken out beside them.
6604
- name: "clean turn",
6605
- request: makeRequest3(),
6606
- nativeStream: [
6607
- threadStarted(NEW_THREAD_ID),
6608
- agentMessage("m1", "Hello there."),
6609
- turnCompleted({
6610
- input_tokens: 100,
6611
- cached_input_tokens: 20,
6612
- cache_write_input_tokens: 5,
6613
- output_tokens: 50,
6614
- reasoning_output_tokens: 10
6615
- })
6616
- ],
6617
- expected: [
6618
- startedSession(NEW_THREAD_ID),
6619
- { type: "text", body: "Hello there.", terminal: true },
6620
- // The closing session write — the thread's ending totals, recorded as the baseline the next turn
6621
- // subtracts. A fresh thread, so the `result` usage below is the frame verbatim.
6622
- closingSession(NEW_THREAD_ID, {
6623
- inputTokens: 100,
6624
- outputTokens: 50,
6625
- cacheReadTokens: 20,
6626
- cacheCreationTokens: 5
6627
- }),
6628
- {
6629
- type: "result",
6630
- ok: true,
6631
- usage: {
6632
- inputTokens: 100,
6633
- outputTokens: 50,
6634
- cacheReadTokens: 20,
6635
- cacheCreationTokens: 5
6636
- }
6637
- }
6638
- ]
6639
- },
6640
- {
6641
- // Narration + an MCP tool call + result + closing reply. The narration message
6642
- // is sealed as interim (a tool follows it); the trailing message is the final.
6643
- name: "tool turn",
6644
- request: makeRequest3(),
6645
- nativeStream: [
6646
- threadStarted(NEW_THREAD_ID),
6647
- agentMessage("m1", "Reading your notes."),
6648
- toolFrame("item.started", {
6649
- id: "t1",
6650
- type: "mcp_tool_call",
6651
- server: "cabane",
6652
- tool: "read",
6653
- arguments: { path: "sleep.md" },
6654
- status: "in_progress"
6655
- }),
6656
- toolFrame("item.completed", {
6657
- id: "t1",
6658
- type: "mcp_tool_call",
6659
- server: "cabane",
6660
- tool: "read",
6661
- arguments: { path: "sleep.md" },
6662
- status: "completed",
6663
- result: { content: [{ type: "text", text: "sleep early" }] }
6664
- }),
6665
- agentMessage("m2", "Here's the summary."),
6666
- turnCompleted()
6667
- ],
6668
- expected: [
6669
- startedSession(NEW_THREAD_ID),
6670
- { type: "text", body: "Reading your notes.", terminal: false },
6671
- {
6672
- type: "tool",
6673
- id: "t1",
6674
- name: "read",
6675
- mcpServer: "cabane",
6676
- phase: "start",
6677
- summary: "sleep.md",
6678
- input: { path: "sleep.md" }
6679
- },
6680
- {
6681
- type: "tool",
6682
- id: "t1",
6683
- name: "read",
6684
- mcpServer: "cabane",
6685
- phase: "done",
6686
- summary: "sleep.md",
6687
- input: { path: "sleep.md" },
6688
- result: [{ type: "text", text: "sleep early" }]
6689
- },
6690
- { type: "text", body: "Here's the summary.", terminal: true },
6691
- closingSession(NEW_THREAD_ID),
6692
- { type: "result", ok: true }
6693
- ]
6694
- },
6695
- {
6696
- // The recorded MCP success shape: Codex exposes the text copy and the native object together;
6697
- // normalization must keep the object.
6698
- name: "MCP success prefers native structured content",
6699
- request: makeRequest3(),
6700
- nativeStream: [
6701
- threadStarted(NEW_THREAD_ID),
6702
- toolFrame("item.started", {
6703
- id: "query-success",
6704
- type: "mcp_tool_call",
6705
- server: "cabane",
6706
- tool: "query",
6707
- arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
6708
- status: "in_progress"
6709
- }),
6710
- toolFrame("item.completed", {
6711
- id: "query-success",
6712
- type: "mcp_tool_call",
6713
- server: "cabane",
6714
- tool: "query",
6715
- arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
6716
- status: "completed",
6717
- result: {
6718
- content: [{ type: "text", text: JSON.stringify(OP_SUCCESS) }],
6719
- structured_content: OP_SUCCESS
6720
- }
6721
- }),
6722
- turnCompleted()
6723
- ],
6724
- expected: [
6725
- startedSession(NEW_THREAD_ID),
6726
- {
6727
- type: "tool",
6728
- id: "query-success",
6729
- name: "query",
6730
- mcpServer: "cabane",
6731
- phase: "start",
6732
- summary: "",
6733
- input: { op: "files.read", args: { path: "/ABOUT.md" } }
6734
- },
6735
- {
6736
- type: "tool",
6737
- id: "query-success",
6738
- name: "query",
6739
- mcpServer: "cabane",
6740
- phase: "done",
6741
- summary: "",
6742
- input: { op: "files.read", args: { path: "/ABOUT.md" } },
6743
- result: OP_SUCCESS
6744
- },
6745
- closingSession(NEW_THREAD_ID),
6746
- { type: "result", ok: true }
6747
- ]
6748
- },
6749
- {
6750
- // MCP `isError` keeps the failed phase, while the raw camel-case structured payload survives
6751
- // instead of the generic `error` token.
6752
- name: "failed MCP call preserves structured receipt while remaining error phase",
6753
- request: makeRequest3(),
6754
- nativeStream: [
6755
- threadStarted(NEW_THREAD_ID),
6756
- toolFrame("item.started", {
6757
- id: "query-failed",
6758
- type: "mcp_tool_call",
6759
- server: "cabane",
6760
- tool: "query",
6761
- arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
6762
- status: "in_progress"
6763
- }),
6764
- toolFrame("item.completed", {
6765
- id: "query-failed",
6766
- type: "mcp_tool_call",
6767
- server: "cabane",
6768
- tool: "query",
6769
- arguments: { op: "files.read", args: { path: "/ABOUT.md" } },
6770
- status: "failed",
6771
- result: {
6772
- content: [{ type: "text", text: JSON.stringify(OP_FAILURE) }],
6773
- structuredContent: OP_FAILURE,
6774
- isError: true
6775
- },
6776
- error: { message: "error" }
6777
- }),
6778
- turnCompleted()
6779
- ],
6780
- expected: [
6781
- startedSession(NEW_THREAD_ID),
6782
- {
6783
- type: "tool",
6784
- id: "query-failed",
6785
- name: "query",
6786
- mcpServer: "cabane",
6787
- phase: "start",
6788
- summary: "",
6789
- input: { op: "files.read", args: { path: "/ABOUT.md" } }
6790
- },
6791
- {
6792
- type: "tool",
6793
- id: "query-failed",
6794
- name: "query",
6795
- mcpServer: "cabane",
6796
- phase: "error",
6797
- summary: "",
6798
- input: { op: "files.read", args: { path: "/ABOUT.md" } },
6799
- result: OP_FAILURE
6800
- },
6801
- closingSession(NEW_THREAD_ID),
6802
- { type: "result", ok: true }
6803
- ]
6804
- },
6805
- {
6806
- // The turn-control server's `answer` (CT1973): the tool event carries the server Codex names
6807
- // beside the tool, which is what the pump binds the declaration to. A non-cabane server keeps
6808
- // its prefix on the card as before.
6809
- name: "turn-control answer carries its server",
6810
- request: makeRequest3(),
6811
- nativeStream: [
6812
- threadStarted(NEW_THREAD_ID),
6813
- agentMessage("m1", "On it."),
6814
- toolFrame("item.completed", {
6815
- id: "answer-1",
6816
- type: "mcp_tool_call",
6817
- server: "cabane_companion",
6818
- tool: "answer",
6819
- arguments: {},
6820
- status: "completed",
6821
- result: { content: [{ type: "text", text: '{"ok":true,"kind":"answer"}' }] }
6822
- }),
6823
- agentMessage("m2", "The answer."),
6824
- turnCompleted()
6825
- ],
6826
- expected: [
6827
- startedSession(NEW_THREAD_ID),
6828
- { type: "text", body: "On it.", terminal: false },
6829
- {
6830
- type: "tool",
6831
- id: "answer-1",
6832
- name: "cabane_companion.answer",
6833
- mcpServer: "cabane_companion",
6834
- phase: "start",
6835
- summary: "",
6836
- input: {}
6837
- },
6838
- {
6839
- type: "tool",
6840
- id: "answer-1",
6841
- name: "cabane_companion.answer",
6842
- mcpServer: "cabane_companion",
6843
- phase: "done",
6844
- summary: "",
6845
- input: {},
6846
- result: [{ type: "text", text: '{"ok":true,"kind":"answer"}' }]
6847
- },
6848
- { type: "text", body: "The answer.", terminal: true },
6849
- closingSession(NEW_THREAD_ID),
6850
- { type: "result", ok: true }
6851
- ]
6852
- },
6853
- {
6854
- // A transport/tool failure with no MCP result payload still uses the real adapter error message,
6855
- // never `undefined`.
6856
- name: "failed MCP call without result keeps error message fallback",
6857
- request: makeRequest3(),
6858
- nativeStream: [
6859
- threadStarted(NEW_THREAD_ID),
6860
- toolFrame("item.completed", {
6861
- id: "mcp-failed",
6862
- type: "mcp_tool_call",
6863
- server: "other",
6864
- tool: "lookup",
6865
- arguments: { q: "receipt" },
6866
- status: "failed",
6867
- result: { content: [], isError: true },
6868
- error: { message: "connection closed" }
6869
- }),
6870
- turnCompleted()
6871
- ],
6872
- expected: [
6873
- startedSession(NEW_THREAD_ID),
6874
- {
6875
- type: "tool",
6876
- id: "mcp-failed",
6877
- name: "other.lookup",
6878
- mcpServer: "other",
6879
- phase: "start",
6880
- summary: "receipt",
6881
- input: { q: "receipt" }
6882
- },
6883
- {
6884
- type: "tool",
6885
- id: "mcp-failed",
6886
- name: "other.lookup",
6887
- mcpServer: "other",
6888
- phase: "error",
6889
- summary: "receipt",
6890
- input: { q: "receipt" },
6891
- result: "connection closed"
6892
- },
6893
- closingSession(NEW_THREAD_ID),
6894
- { type: "result", ok: true }
6895
- ]
6896
- },
6897
- {
6898
- // A connector's structured metadata beside an image-only MCP response: no image/base64 payload
6899
- // enters activity receipts.
6900
- name: "image-only MCP result remains empty despite structured metadata",
6901
- request: makeRequest3(),
6902
- nativeStream: [
6903
- threadStarted(NEW_THREAD_ID),
6904
- toolFrame("item.completed", {
6905
- id: "mcp-image",
6906
- type: "mcp_tool_call",
6907
- server: "other",
6908
- tool: "render",
6909
- arguments: {},
6910
- status: "completed",
6911
- result: {
6912
- content: [{ type: "image", data: "base64-bytes", mimeType: "image/png" }],
6913
- structured_content: { image: "base64-bytes" }
6914
- }
6915
- }),
6916
- turnCompleted()
6917
- ],
6918
- expected: [
6919
- startedSession(NEW_THREAD_ID),
6920
- {
6921
- type: "tool",
6922
- id: "mcp-image",
6923
- name: "other.render",
6924
- mcpServer: "other",
6925
- phase: "start",
6926
- summary: "",
6927
- input: {}
6928
- },
6929
- {
6930
- type: "tool",
6931
- id: "mcp-image",
6932
- name: "other.render",
6933
- mcpServer: "other",
6934
- phase: "done",
6935
- summary: "",
6936
- input: {},
6937
- result: [{ type: "image", data: "base64-bytes", mimeType: "image/png" }]
6938
- },
6939
- closingSession(NEW_THREAD_ID),
6940
- { type: "result", ok: true }
6941
- ]
6942
- },
6943
- {
6944
- // A streaming tool whose args arrive on a later snapshot: `item.started` carries
6945
- // empty args, `item.updated` fills them, `item.completed` finishes. The `start`
6946
- // fires off the first (empty) frame — that's the live card — but the `done` must
6947
- // carry the LATEST non-empty input + its recomputed summary, not the first `{}`.
6948
- name: "streaming tool input \u2014 late args refresh the done event",
6949
- request: makeRequest3(),
6950
- nativeStream: [
6951
- threadStarted(NEW_THREAD_ID),
6952
- toolFrame("item.started", {
6953
- id: "t1",
6954
- type: "mcp_tool_call",
6955
- server: "cabane",
6956
- tool: "list",
6957
- arguments: {},
6958
- status: "in_progress"
6959
- }),
6960
- toolFrame("item.updated", {
6961
- id: "t1",
6962
- type: "mcp_tool_call",
6963
- server: "cabane",
6964
- tool: "list",
6965
- arguments: { path: "/product/tasks" },
6966
- status: "in_progress"
6967
- }),
6968
- toolFrame("item.completed", {
6969
- id: "t1",
6970
- type: "mcp_tool_call",
6971
- server: "cabane",
6972
- tool: "list",
6973
- arguments: { path: "/product/tasks" },
6974
- status: "completed",
6975
- result: { content: [{ type: "text", text: "CT481.md" }] }
6976
- }),
6977
- turnCompleted()
6978
- ],
6979
- expected: [
6980
- startedSession(NEW_THREAD_ID),
6981
- {
6982
- type: "tool",
6983
- id: "t1",
6984
- name: "list",
6985
- mcpServer: "cabane",
6986
- phase: "start",
6987
- summary: "",
6988
- input: {}
6989
- },
6990
- {
6991
- type: "tool",
6992
- id: "t1",
6993
- name: "list",
6994
- mcpServer: "cabane",
6995
- phase: "done",
6996
- summary: "/product/tasks",
6997
- input: { path: "/product/tasks" },
6998
- result: [{ type: "text", text: "CT481.md" }]
6999
- },
7000
- closingSession(NEW_THREAD_ID),
7001
- { type: "result", ok: true }
7002
- ]
7003
- },
7004
- {
7005
- // web_search lifecycle. A `web_search` item carries no `status` — completion is signalled by the
7006
- // frame type (`item.started` → `item.completed`). The `start` fires off the first frame (empty
7007
- // query); the `done` must resolve off `item.completed` with the populated query, not stay
7008
- // "running".
7009
- name: "web_search lifecycle \u2014 completes off frame type, populated query on done",
7010
- request: makeRequest3(),
7011
- nativeStream: [
7012
- threadStarted(NEW_THREAD_ID),
7013
- toolFrame("item.started", { id: "ws1", type: "web_search", query: "" }),
7014
- toolFrame("item.completed", { id: "ws1", type: "web_search", query: "best pizza in nyc" }),
7015
- turnCompleted()
7016
- ],
7017
- expected: [
7018
- startedSession(NEW_THREAD_ID),
7019
- {
7020
- type: "tool",
7021
- id: "ws1",
7022
- name: "web_search",
7023
- phase: "start",
7024
- summary: "",
7025
- input: { query: "" }
7026
- },
7027
- {
7028
- type: "tool",
7029
- id: "ws1",
7030
- name: "web_search",
7031
- phase: "done",
7032
- summary: "best pizza in nyc",
7033
- input: { query: "best pizza in nyc" }
7034
- },
7035
- closingSession(NEW_THREAD_ID),
7036
- { type: "result", ok: true }
7037
- ]
7038
- },
7039
- {
7040
- // A non-text web action (`action.type: "other"`) legitimately completes with an empty query —
7041
- // Codex's own data. It must still resolve to `done`.
7042
- name: "web_search lifecycle \u2014 empty-query completion still resolves to done",
7043
- request: makeRequest3(),
7044
- nativeStream: [
7045
- threadStarted(NEW_THREAD_ID),
7046
- toolFrame("item.started", { id: "ws2", type: "web_search", query: "" }),
7047
- toolFrame("item.completed", { id: "ws2", type: "web_search", query: "" }),
7048
- turnCompleted()
7049
- ],
7050
- expected: [
7051
- startedSession(NEW_THREAD_ID),
7052
- {
7053
- type: "tool",
7054
- id: "ws2",
7055
- name: "web_search",
7056
- phase: "start",
7057
- summary: "",
7058
- input: { query: "" }
7059
- },
7060
- {
7061
- type: "tool",
7062
- id: "ws2",
7063
- name: "web_search",
7064
- phase: "done",
7065
- summary: "",
7066
- input: { query: "" }
7067
- },
7068
- closingSession(NEW_THREAD_ID),
7069
- { type: "result", ok: true }
7070
- ]
7071
- },
7072
- {
7073
- // Highest risk: cancel mid-stream. The abort lands after the tool result; the adapter stops before
7074
- // the closing reply — no final text, no `result` event (the server writes the stopped marker).
7075
- name: "cancel mid-stream",
7076
- request: makeRequest3(),
7077
- nativeStream: [
7078
- threadStarted(NEW_THREAD_ID),
7079
- agentMessage("m1", "Working on it."),
7080
- toolFrame("item.started", {
7081
- id: "t1",
7082
- type: "command_execution",
7083
- command: "ls",
7084
- status: "in_progress"
7085
- }),
7086
- toolFrame("item.completed", {
7087
- id: "t1",
7088
- type: "command_execution",
7089
- command: "ls",
7090
- aggregated_output: "ok",
7091
- exit_code: 0,
7092
- status: "completed"
7093
- }),
7094
- ABORT_SENTINEL3,
7095
- agentMessage("m2", "This reply must never be emitted."),
7096
- turnCompleted()
7097
- ],
7098
- expected: [
7099
- startedSession(NEW_THREAD_ID),
7100
- { type: "text", body: "Working on it.", terminal: false },
7101
- {
7102
- type: "tool",
7103
- id: "t1",
7104
- name: "shell",
7105
- phase: "start",
7106
- summary: "ls",
7107
- input: { command: "ls" }
7108
- },
7109
- {
7110
- type: "tool",
7111
- id: "t1",
7112
- name: "shell",
7113
- phase: "done",
7114
- summary: "ls",
7115
- input: { command: "ls" },
7116
- result: { output: "ok", exit_code: 0 }
7117
- }
7118
- ]
7119
- },
7120
- {
7121
- // A failed turn: partial narration lands as `progress` (terminal:false), and the
7122
- // `result` event carries the machine reason off `turn.failed`.
7123
- name: "turn.failed result",
7124
- request: makeRequest3(),
7125
- nativeStream: [
7126
- threadStarted(NEW_THREAD_ID),
7127
- agentMessage("m1", "Partial work."),
7128
- turnFailed("provider exploded")
7129
- ],
7130
- expected: [
7131
- startedSession(NEW_THREAD_ID),
7132
- { type: "text", body: "Partial work.", terminal: false },
7133
- { type: "runtime_notice", body: "provider exploded" },
7134
- { type: "result", ok: false, reason: "error:provider exploded" }
7135
- ]
7136
- },
7137
- {
7138
- // A fatal top-level `error` frame (distinct from `turn.failed`): held narration
7139
- // seals as interim, then a failed `result`.
7140
- name: "fatal stream error",
7141
- request: makeRequest3(),
7142
- nativeStream: [
7143
- threadStarted(NEW_THREAD_ID),
7144
- agentMessage("m1", "Starting."),
7145
- fatalError("stream died")
7146
- ],
7147
- expected: [
7148
- startedSession(NEW_THREAD_ID),
7149
- { type: "text", body: "Starting.", terminal: false },
7150
- { type: "runtime_notice", body: "stream died" },
7151
- { type: "result", ok: false, reason: "error:stream died" }
7152
- ]
7153
- },
7154
- {
7155
- // The failure text goes through the shared classifier (Codex is text-only), so a provider 429 /
7156
- // outage / auth / cap failure splits into the honest taxonomy. One fixture per kind:
7157
- name: "turn.failed 429 \u2192 rate_limited (throttle)",
7158
- request: makeRequest3(),
7159
- nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("429 Too Many Requests")],
7160
- expected: [
7161
- startedSession(NEW_THREAD_ID),
7162
- { type: "runtime_notice", body: "429 Too Many Requests" },
7163
- { type: "result", ok: false, reason: "rate_limited" }
7164
- ]
7165
- },
7166
- {
7167
- name: "turn.failed overload \u2192 server_error",
7168
- request: makeRequest3(),
7169
- nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("503 Service Unavailable")],
7170
- expected: [
7171
- startedSession(NEW_THREAD_ID),
7172
- { type: "runtime_notice", body: "503 Service Unavailable" },
7173
- { type: "result", ok: false, reason: "server_error" }
7174
- ]
7175
- },
7176
- {
7177
- name: "turn.failed usage cap \u2192 usage_capped",
7178
- request: makeRequest3(),
7179
- nativeStream: [
7180
- threadStarted(NEW_THREAD_ID),
7181
- turnFailed("You've hit your session limit \xB7 resets 8:20pm (UTC)")
7182
- ],
7183
- expected: [
7184
- startedSession(NEW_THREAD_ID),
7185
- {
7186
- type: "runtime_notice",
7187
- body: "You've hit your session limit \xB7 resets 8:20pm (UTC)"
7188
- },
7189
- { type: "result", ok: false, reason: "usage_capped" }
7190
- ]
7191
- },
7192
- {
7193
- name: "turn.failed auth \u2192 auth_expired",
7194
- request: makeRequest3(),
7195
- nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("401 Unauthorized: invalid api key")],
7196
- expected: [
7197
- startedSession(NEW_THREAD_ID),
7198
- { type: "runtime_notice", body: "401 Unauthorized: invalid api key" },
7199
- { type: "result", ok: false, reason: "auth_expired" }
7200
- ]
7201
- },
7202
- {
7203
- // A failure text the classifier can't place keeps the raw `error:<text>` reason (the `unknown`
7204
- // fallback).
7205
- name: "turn.failed unclassified \u2192 raw error reason (unchanged)",
7206
- request: makeRequest3(),
7207
- nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("something local broke")],
7208
- expected: [
7209
- startedSession(NEW_THREAD_ID),
7210
- { type: "runtime_notice", body: "something local broke" },
7211
- { type: "result", ok: false, reason: "error:something local broke" }
7212
- ]
7213
- },
7214
- {
7215
- name: "runtime notice is trimmed and bounded to 200 characters",
7216
- request: makeRequest3(),
7217
- nativeStream: [
7218
- threadStarted(NEW_THREAD_ID),
7219
- turnFailed(` ${BOUNDED_NOTICE2}${"y".repeat(20)} `)
7220
- ],
7221
- expected: [
7222
- startedSession(NEW_THREAD_ID),
7223
- { type: "runtime_notice", body: BOUNDED_NOTICE2 },
7224
- { type: "result", ok: false, reason: "usage_capped" }
7225
- ]
7226
- },
7227
- {
7228
- // Empty, whitespace-only and bare status-token failures still set the machine reason but add no
7229
- // empty or unhelpful transcript notice.
7230
- name: "empty failure text emits no runtime notice",
7231
- request: makeRequest3(),
7232
- nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("")],
7233
- expected: [
7234
- startedSession(NEW_THREAD_ID),
7235
- { type: "result", ok: false, reason: "error:unknown" }
7236
- ]
7237
- },
7238
- {
7239
- name: "whitespace-only failure text emits no runtime notice",
7240
- request: makeRequest3(),
7241
- nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed(" ")],
7242
- expected: [startedSession(NEW_THREAD_ID), { type: "result", ok: false, reason: "error: " }]
7243
- },
7244
- {
7245
- name: "bare status token emits no runtime notice",
7246
- request: makeRequest3(),
7247
- nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("429")],
7248
- expected: [
7249
- startedSession(NEW_THREAD_ID),
7250
- { type: "result", ok: false, reason: "rate_limited" }
7251
- ]
7252
- },
7253
- {
7254
- name: "single-word provider sentence emits a runtime notice",
7255
- request: makeRequest3(),
7256
- nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("Unavailable.")],
7257
- expected: [
7258
- startedSession(NEW_THREAD_ID),
7259
- { type: "runtime_notice", body: "Unavailable." },
7260
- { type: "result", ok: false, reason: "error:Unavailable." }
7261
- ]
7262
- },
7263
- {
7264
- // Fail loud on unknown model metadata. An unknown `--model` makes Codex emit an `error` item
7265
- // ("… Defaulting to fallback metadata …") and misbehave (the spurious tool call scripted after it
7266
- // must never be emitted). The adapter ends the turn when the signal lands, with a machine reason.
7267
- name: "unknown model metadata \u2192 fail loud (guard)",
7268
- request: makeRequest3(),
7269
- nativeStream: [
7270
- threadStarted(NEW_THREAD_ID),
7271
- errorItem(
7272
- "e0",
7273
- "Model metadata for `gpt-5-codex` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."
7274
- ),
7275
- // Everything after the guard fires must be dropped (the haywire the guard prevents).
7276
- toolFrame("item.started", {
7277
- id: "t1",
7278
- type: "web_search",
7279
- query: "anything",
7280
- status: "in_progress"
7281
- }),
7282
- agentMessage("m1", "This degraded reply must never be emitted."),
7283
- turnCompleted()
7284
- ],
7285
- expected: [
7286
- startedSession(NEW_THREAD_ID),
7287
- {
7288
- type: "result",
7289
- ok: false,
7290
- reason: "model_unavailable:Model metadata for `gpt-5-codex` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."
7291
- }
7292
- ]
7293
- },
7294
- {
7295
- // A NON-metadata error item is non-fatal — it's skipped, and the turn proceeds
7296
- // to its normal reply (guard specificity: only the fallback-metadata phrase trips).
7297
- name: "non-metadata error item is skipped (non-fatal)",
7298
- request: makeRequest3(),
7299
- nativeStream: [
7300
- threadStarted(NEW_THREAD_ID),
7301
- errorItem("e0", "a transient hiccup"),
7302
- agentMessage("m1", "All good now."),
7303
- turnCompleted()
7304
- ],
7305
- expected: [
7306
- startedSession(NEW_THREAD_ID),
7307
- { type: "text", body: "All good now.", terminal: true },
7308
- closingSession(NEW_THREAD_ID),
7309
- { type: "result", ok: true }
7310
- ]
7311
- },
7312
- {
7313
- // The zero-content success the claude-code suite pins, in Codex's vocabulary — the thread
7314
- // starts, the turn completes, nothing in between. No Codex sample has been seen; the fixture
7315
- // exists because the host's classification is runtime-neutral, so a runtime that can produce
7316
- // this envelope must be shown producing it in the shape the classifier reads.
7317
- name: "zero-content success (empty runtime result)",
7318
- request: makeRequest3(),
7319
- nativeStream: [threadStarted(NEW_THREAD_ID), turnCompleted()],
7320
- expected: [
7321
- startedSession(NEW_THREAD_ID),
7322
- closingSession(NEW_THREAD_ID),
7323
- { type: "result", ok: true }
7324
- ]
7325
- },
7326
- {
7327
- // Empty-final: a clean turn that ended on a tool call with no closing message.
7328
- // The adapter emits NO final text — empty-final promotion is host/pump territory
7329
- // (this fixture pins that boundary).
7330
- name: "empty-final (no closing message)",
7331
- request: makeRequest3(),
7332
- nativeStream: [
7333
- threadStarted(NEW_THREAD_ID),
7334
- toolFrame("item.started", {
7335
- id: "t1",
7336
- type: "file_change",
7337
- changes: [{ path: "a.md", kind: "add" }],
7338
- status: "in_progress"
7339
- }),
7340
- toolFrame("item.completed", {
7341
- id: "t1",
7342
- type: "file_change",
7343
- changes: [{ path: "a.md", kind: "add" }],
7344
- status: "completed"
7345
- }),
7346
- turnCompleted()
7347
- ],
7348
- expected: [
7349
- startedSession(NEW_THREAD_ID),
7350
- {
7351
- type: "tool",
7352
- id: "t1",
7353
- name: "apply_patch",
7354
- phase: "start",
7355
- summary: "",
7356
- input: { changes: [{ path: "a.md", kind: "add" }] }
7357
- },
7358
- {
7359
- type: "tool",
7360
- id: "t1",
7361
- name: "apply_patch",
7362
- phase: "done",
7363
- summary: "",
7364
- input: { changes: [{ path: "a.md", kind: "add" }] },
7365
- result: [{ path: "a.md", kind: "add" }]
7366
- },
7367
- closingSession(NEW_THREAD_ID),
7368
- { type: "result", ok: true }
7369
- ]
7370
- },
7371
- {
7372
- // Thinking: a reasoning item seals as a transient `thinking`, then the reply
7373
- // message is the final.
7374
- name: "thinking then reply",
7375
- request: makeRequest3(),
7376
- nativeStream: [
7377
- threadStarted(NEW_THREAD_ID),
7378
- reasoning("r1", "Let me check the notes."),
7379
- agentMessage("m1", "All set."),
7380
- turnCompleted()
7381
- ],
7382
- expected: [
7383
- startedSession(NEW_THREAD_ID),
7384
- { type: "thinking", text: "Let me check the notes." },
7385
- { type: "text", body: "All set.", terminal: true },
7386
- closingSession(NEW_THREAD_ID),
7387
- { type: "result", ok: true }
7388
- ]
7389
- },
7390
- {
7391
- // Codex reports no model in-stream, so the resolved model and reasoning effort come from what the
7392
- // thread was started with (the run spec) and ride the terminal `result`: a real model and a
7393
- // `runtimeOptions.codex.modelReasoningEffort` are asserted through to `resolvedModel` (bare,
7394
- // `openai/`-stripped) and `resolvedConfig.reasoningEffort`.
7395
- name: "resolved model + reasoning effort captured from run spec",
7396
- request: makeRequest3({
7397
- config: {
7398
- model: "openai/gpt-5-codex",
7399
- runtimeOptions: { codex: { modelReasoningEffort: "high" } }
7400
- }
7401
- }),
7402
- nativeStream: [threadStarted(NEW_THREAD_ID), agentMessage("m1", "Done."), turnCompleted()],
7403
- expected: [
7404
- startedSession(NEW_THREAD_ID),
7405
- { type: "text", body: "Done.", terminal: true },
7406
- closingSession(NEW_THREAD_ID),
7407
- {
7408
- type: "result",
7409
- ok: true,
7410
- resolvedModel: "gpt-5-codex",
7411
- resolvedConfig: { reasoningEffort: "high" }
7412
- }
7413
- ]
7414
- },
7415
- {
7416
- // Session resume: the stored state matches the directory, so the thread id is resumed and
7417
- // `thread.started` echoes it — no session event fires. This is what carries cross-turn memory.
7418
- //
7419
- // The stored state also says the thread already holds the composed prompt, so this turn
7420
- // withholds the preamble and the state it would write is byte-identical: the silence proves both
7421
- // an unchanged id and a prompt that needed no re-send.
7422
- name: "session resume (no re-emit on echo)",
7423
- request: makeRequest3({
7424
- session: encodeSession3({
7425
- threadId: "th1",
7426
- cwd: DIR2,
7427
- promptFingerprint: fingerprintPrompt(PROMPT),
7428
- usageBaseline: null,
7429
- compactions: 0
7430
- })
7431
- }),
7432
- nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
7433
- expected: [
7434
- { type: "text", body: "Back again.", terminal: true },
7435
- { type: "result", ok: true }
7436
- ]
7437
- },
7438
- {
7439
- // A resumed thread whose stored state carries no `promptFingerprint` field. The adapter reads the
7440
- // absence as "can't say which prompt this thread holds", sends the full prompt, and re-emits the
7441
- // state with the fingerprint — on the same thread id. That re-emit is the upgrade path: without
7442
- // it a long-lived thread would re-send the whole prompt forever.
7443
- //
7444
- // This fixture fails if the standing half is withheld on a thread we have no evidence about.
7445
- name: "session resume with a pre-CT1075 state re-emits with the prompt fingerprint",
7446
- request: makeRequest3({
7447
- session: JSON.stringify({ threadId: "th1", cwd: DIR2 })
7448
- }),
7449
- nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
7450
- expected: [
7451
- startedSession("th1"),
7452
- { type: "text", body: "Back again.", terminal: true },
7453
- closingSession("th1"),
7454
- { type: "result", ok: true }
7455
- ]
7456
- },
7457
- {
7458
- // The thread carries a different prompt than this turn composed — a charter edit, a renamed
7459
- // agent, a redeployed prompt body. The fingerprint doesn't match, so the new prompt is sent as on
7460
- // a fresh thread and the state re-recorded.
7461
- //
7462
- // This fixture fails if a stale prompt is treated as good enough; a boolean flag would pass the
7463
- // resume fixture above and withhold every future charter edit.
7464
- name: "session resume with a stale prompt fingerprint re-sends the prompt",
7465
- request: makeRequest3({
7466
- session: encodeSession3({
7467
- threadId: "th1",
7468
- cwd: DIR2,
7469
- promptFingerprint: fingerprintPrompt("an older composed prompt"),
7470
- usageBaseline: null
7471
- })
7472
- }),
7473
- nativeStream: [threadStarted("th1"), agentMessage("m1", "Back again."), turnCompleted()],
7474
- expected: [
7475
- startedSession("th1"),
7476
- { type: "text", body: "Back again.", terminal: true },
7477
- closingSession("th1"),
7478
- { type: "result", ok: true }
7479
- ]
7480
- },
7481
- {
7482
- // Directory-mismatch degrade: the stored state was created under another directory, so the
7483
- // adapter starts fresh — `thread.started` reports a new id, and the session event records the
7484
- // current directory with `degraded: true` so the host rewinds the catch-up mark.
7485
- //
7486
- // The refused resume means the new thread is empty, so the full prompt goes in even though the
7487
- // stored state vouched for a copy in a thread we're no longer in.
7488
- name: "session directory-mismatch degrades to fresh",
7489
- request: makeRequest3({
7490
- session: encodeSession3({
7491
- threadId: "old",
7492
- cwd: "/env/gone",
7493
- promptFingerprint: fingerprintPrompt(PROMPT),
7494
- usageBaseline: null
7495
- })
7496
- }),
7497
- nativeStream: [
7498
- threadStarted(NEW_THREAD_ID),
7499
- agentMessage("m1", "Fresh start."),
7500
- turnCompleted()
7501
- ],
7502
- expected: [
7503
- startedSession(NEW_THREAD_ID, DIR2, true),
7504
- { type: "text", body: "Fresh start.", terminal: true },
7505
- closingSession(NEW_THREAD_ID),
7506
- { type: "result", ok: true }
7507
- ]
7508
- }
7509
- ];
7510
-
7511
5284
  // packages/agent-runtime/src/claude-code/sdk.ts
7512
5285
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
7513
5286
 
@@ -7647,47 +5420,47 @@ function classifyStopError(message) {
7647
5420
  }
7648
5421
 
7649
5422
  // src/runner/protocol.ts
7650
- import { z as z14 } from "zod";
5423
+ import { z as z13 } from "zod";
7651
5424
 
7652
5425
  // src/run-config.ts
7653
- import { z as z13 } from "zod";
7654
- var mcpStdioServerSchema = z13.object({
7655
- type: z13.literal("stdio").optional(),
7656
- command: z13.string().min(1),
7657
- args: z13.array(z13.string()).optional(),
7658
- env: z13.record(z13.string(), z13.string()).optional(),
5426
+ import { z as z12 } from "zod";
5427
+ var mcpStdioServerSchema = z12.object({
5428
+ type: z12.literal("stdio").optional(),
5429
+ command: z12.string().min(1),
5430
+ args: z12.array(z12.string()).optional(),
5431
+ env: z12.record(z12.string(), z12.string()).optional(),
7659
5432
  // Variable → the name of a `secretEnv` entry holding its value. Set by the server on a
7660
5433
  // room's shared browser, whose connect URL is a credential.
7661
- envFromEnv: z13.record(z13.string(), z13.string()).optional()
5434
+ envFromEnv: z12.record(z12.string(), z12.string()).optional()
7662
5435
  });
7663
- var mcpHttpServerSchema = z13.object({
7664
- type: z13.literal("http"),
7665
- url: z13.string().url(),
7666
- headers: z13.record(z13.string(), z13.string()).optional(),
5436
+ var mcpHttpServerSchema = z12.object({
5437
+ type: z12.literal("http"),
5438
+ url: z12.string().url(),
5439
+ headers: z12.record(z12.string(), z12.string()).optional(),
7667
5440
  // Header → the name of a `secretEnv` entry holding its value. Set by
7668
5441
  // the server on a room connection's entry, and by `resolveMcpSecrets` on a
7669
5442
  // header whose `${VAR}` it resolved.
7670
- headersFromEnv: z13.record(z13.string(), z13.string()).optional()
5443
+ headersFromEnv: z12.record(z12.string(), z12.string()).optional()
7671
5444
  });
7672
- var mcpSseServerSchema = z13.object({
7673
- type: z13.literal("sse"),
7674
- url: z13.string().url(),
7675
- headers: z13.record(z13.string(), z13.string()).optional(),
5445
+ var mcpSseServerSchema = z12.object({
5446
+ type: z12.literal("sse"),
5447
+ url: z12.string().url(),
5448
+ headers: z12.record(z12.string(), z12.string()).optional(),
7676
5449
  // Header → the name of a `secretEnv` entry holding its value. Set by
7677
5450
  // the server on a room connection's entry, and by `resolveMcpSecrets` on a
7678
5451
  // header whose `${VAR}` it resolved.
7679
- headersFromEnv: z13.record(z13.string(), z13.string()).optional()
5452
+ headersFromEnv: z12.record(z12.string(), z12.string()).optional()
7680
5453
  });
7681
- var mcpServerDefSchema = z13.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
7682
- var thinkingConfigSchema = z13.discriminatedUnion("type", [
7683
- z13.object({ type: z13.literal("adaptive") }),
7684
- z13.object({ type: z13.literal("enabled"), budgetTokens: z13.number().int().positive().optional() }),
7685
- z13.object({ type: z13.literal("disabled") })
5454
+ var mcpServerDefSchema = z12.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
5455
+ var thinkingConfigSchema = z12.discriminatedUnion("type", [
5456
+ z12.object({ type: z12.literal("adaptive") }),
5457
+ z12.object({ type: z12.literal("enabled"), budgetTokens: z12.number().int().positive().optional() }),
5458
+ z12.object({ type: z12.literal("disabled") })
7686
5459
  ]);
7687
- var effortSchema = z13.enum(["low", "medium", "high", "xhigh", "max"]);
7688
- var runConfigSchema = z13.object({
7689
- mcpServers: z13.record(z13.string(), mcpServerDefSchema).optional(),
7690
- model: z13.string().min(1).optional(),
5460
+ var effortSchema = z12.enum(["low", "medium", "high", "xhigh", "max"]);
5461
+ var runConfigSchema = z12.object({
5462
+ mcpServers: z12.record(z12.string(), mcpServerDefSchema).optional(),
5463
+ model: z12.string().min(1).optional(),
7691
5464
  thinking: thinkingConfigSchema.optional(),
7692
5465
  effort: effortSchema.optional()
7693
5466
  });
@@ -7715,61 +5488,61 @@ var RUNNER_ENV = {
7715
5488
  // Everything else the turn is constructed from, as JSON: see `RunnerSpec`.
7716
5489
  spec: "CABANE_RUNNER_SPEC"
7717
5490
  };
7718
- var runtimeHealthSchema = z14.object({
7719
- quotaState: z14.enum(["ok", "limited", "auth_expired", "unknown"]),
7720
- limitedUntil: z14.string().nullable(),
7721
- lastError: z14.string().nullable()
5491
+ var runtimeHealthSchema = z13.object({
5492
+ quotaState: z13.enum(["ok", "limited", "auth_expired", "unknown"]),
5493
+ limitedUntil: z13.string().nullable(),
5494
+ lastError: z13.string().nullable()
7722
5495
  });
7723
- var runnerSpecSchema = z14.object({
7724
- deviceId: z14.string().optional(),
5496
+ var runnerSpecSchema = z13.object({
5497
+ deviceId: z13.string().optional(),
7725
5498
  // The launcher's boot id: the one its heartbeat carries, so a runner's lease
7726
5499
  // matches the device that holds it.
7727
- bootId: z14.string(),
7728
- workspaceId: z14.string(),
7729
- workspaceSlug: z14.string(),
7730
- agentId: z14.string(),
7731
- agentUsername: z14.string(),
7732
- agentName: z14.string(),
5500
+ bootId: z13.string(),
5501
+ workspaceId: z13.string(),
5502
+ workspaceSlug: z13.string(),
5503
+ agentId: z13.string(),
5504
+ agentUsername: z13.string(),
5505
+ agentName: z13.string(),
7733
5506
  local: localAgentConfigSchema,
7734
5507
  runConfig: runConfigSchema,
7735
5508
  // The device's `root`, resolved by the launcher: the pair directory the turn
7736
5509
  // stands in when nothing else names one lives under it (`work-dir.ts`).
7737
- workRoot: z14.string(),
7738
- opencodeServerUrl: z14.string().optional(),
7739
- claudeCodeAvailable: z14.boolean(),
7740
- codexAvailable: z14.boolean(),
7741
- idleTimeoutMs: z14.number().int().positive().optional(),
7742
- totalTimeoutMs: z14.number().int().positive().optional(),
7743
- leaseRenewalMs: z14.number().int().positive().optional(),
7744
- preparingRowDelayMs: z14.number().int().nonnegative().optional(),
5510
+ workRoot: z13.string(),
5511
+ opencodeServerUrl: z13.string().optional(),
5512
+ claudeCodeAvailable: z13.boolean(),
5513
+ codexAvailable: z13.boolean(),
5514
+ idleTimeoutMs: z13.number().int().positive().optional(),
5515
+ totalTimeoutMs: z13.number().int().positive().optional(),
5516
+ leaseRenewalMs: z13.number().int().positive().optional(),
5517
+ preparingRowDelayMs: z13.number().int().nonnegative().optional(),
7745
5518
  // Whether the turn writes a transcript, into the device's own directory
7746
5519
  // (`transcriptsDir()`).
7747
- transcripts: z14.boolean(),
7748
- logLevel: z14.string(),
5520
+ transcripts: z13.boolean(),
5521
+ logLevel: z13.string(),
7749
5522
  // The launcher's connector-health observations, by runtime, as of the start.
7750
- connectorHealth: z14.record(z14.string(), runtimeHealthSchema),
5523
+ connectorHealth: z13.record(z13.string(), runtimeHealthSchema),
7751
5524
  // True when this (conversation, agent) pair was already told its session
7752
5525
  // could not be saved: the notice is once per pair, not once per process.
7753
- sessionWriteNoticeClaimed: z14.boolean(),
5526
+ sessionWriteNoticeClaimed: z13.boolean(),
7754
5527
  // The runner's identity, minted with its credential. The start
7755
5528
  // PATCH carries it so the lease knows which runner holds the span.
7756
- runnerId: z14.string(),
5529
+ runnerId: z13.string(),
7757
5530
  // Whether the server took gzip request bodies at the latest heartbeat, as of
7758
5531
  // the start. The launcher writes `gzip_bodies` when that changes mid-turn.
7759
- gzipBodies: z14.boolean().optional()
5532
+ gzipBodies: z13.boolean().optional()
7760
5533
  });
7761
- var dispatchSchema = z14.object({
7762
- type: z14.literal("device:dispatch_requested"),
7763
- agentId: z14.string(),
7764
- deviceId: z14.string(),
7765
- conversationId: z14.string(),
7766
- messageId: z14.string(),
7767
- episode: z14.number().int().optional()
5534
+ var dispatchSchema = z13.object({
5535
+ type: z13.literal("device:dispatch_requested"),
5536
+ agentId: z13.string(),
5537
+ deviceId: z13.string(),
5538
+ conversationId: z13.string(),
5539
+ messageId: z13.string(),
5540
+ episode: z13.number().int().optional()
7768
5541
  });
7769
- var credentialsSchema = z14.object({
7770
- credential: z14.string().startsWith("cabrn_"),
7771
- secrets: z14.record(z14.string(), z14.string()).optional(),
7772
- grants: z14.record(z14.string(), z14.record(z14.string(), z14.string())).optional()
5542
+ var credentialsSchema = z13.object({
5543
+ credential: z13.string().startsWith("cabrn_"),
5544
+ secrets: z13.record(z13.string(), z13.string()).optional(),
5545
+ grants: z13.record(z13.string(), z13.record(z13.string(), z13.string())).optional()
7773
5546
  });
7774
5547
  function runnerEnv(input) {
7775
5548
  return {
@@ -8273,12 +6046,12 @@ import { randomUUID as randomUUID2 } from "crypto";
8273
6046
 
8274
6047
  // src/turn-execution.ts
8275
6048
  import { createHash as createHash2, randomUUID as randomUUID4 } from "crypto";
8276
- import { existsSync as existsSync12, mkdirSync as mkdirSync9 } from "fs";
6049
+ import { existsSync as existsSync11, mkdirSync as mkdirSync9 } from "fs";
8277
6050
 
8278
6051
  // src/grants.ts
8279
- import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
8280
- import { join as join13 } from "path";
8281
- import { z as z15 } from "zod";
6052
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
6053
+ import { join as join11 } from "path";
6054
+ import { z as z14 } from "zod";
8282
6055
  var VARIABLE_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
8283
6056
  function allowedGrantVariable(name) {
8284
6057
  return name.length <= 100 && VARIABLE_NAME_RE.test(name) && !name.startsWith("CABANE_");
@@ -8287,9 +6060,9 @@ function environmentSafe(value) {
8287
6060
  return !value.includes("\0");
8288
6061
  }
8289
6062
  function grantsPath() {
8290
- return join13(cabaneDir(), "grants.json");
6063
+ return join11(cabaneDir(), "grants.json");
8291
6064
  }
8292
- var grantsFileSchema = z15.record(z15.string(), z15.record(z15.string(), z15.string()));
6065
+ var grantsFileSchema = z14.record(z14.string(), z14.record(z14.string(), z14.string()));
8293
6066
  function readGrants(keys, report) {
8294
6067
  if (keys.length === 0) return {};
8295
6068
  const file = loadGrantsFile(report);
@@ -8307,7 +6080,7 @@ function readGrants(keys, report) {
8307
6080
  }
8308
6081
  function loadGrantsFile(report) {
8309
6082
  const path = grantsPath();
8310
- if (!existsSync9(path)) return {};
6083
+ if (!existsSync8(path)) return {};
8311
6084
  let raw;
8312
6085
  try {
8313
6086
  raw = readFileSync7(path, "utf8");
@@ -8336,11 +6109,11 @@ function loadGrantsFile(report) {
8336
6109
 
8337
6110
  // src/work-dir.ts
8338
6111
  import { statSync as statSync3 } from "fs";
8339
- import { join as join14 } from "path";
6112
+ import { join as join12 } from "path";
8340
6113
 
8341
6114
  // src/turn-control-tools.ts
8342
6115
  import { randomUUID as randomUUID3 } from "crypto";
8343
- import { z as z16 } from "zod";
6116
+ import { z as z15 } from "zod";
8344
6117
  var COMPANION_LOCAL_MCP_SERVER = TURN_CONTROL_MCP_SERVER;
8345
6118
  var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
8346
6119
  var ASK_TOOL = "ask";
@@ -8353,39 +6126,39 @@ var CANCEL_WAKE_TOOL = "cancel_wake";
8353
6126
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
8354
6127
 
8355
6128
  // src/codex-config.ts
8356
- import { readFile as readFile2 } from "fs/promises";
6129
+ import { readFile } from "fs/promises";
8357
6130
  import { homedir as homedir6 } from "os";
8358
- import { join as join15 } from "path";
6131
+ import { join as join13 } from "path";
8359
6132
  import { parse as parse2 } from "smol-toml";
8360
6133
 
8361
6134
  // src/codex-images.ts
8362
6135
  import { mkdtemp, rm, writeFile } from "fs/promises";
8363
6136
  import { tmpdir } from "os";
8364
- import { join as join16 } from "path";
6137
+ import { join as join14 } from "path";
8365
6138
 
8366
6139
  // src/codex-compactions.ts
8367
6140
  import { createReadStream } from "fs";
8368
6141
  import { readdir } from "fs/promises";
8369
6142
  import { homedir as homedir7 } from "os";
8370
- import { join as join17 } from "path";
6143
+ import { join as join15 } from "path";
8371
6144
  import { createInterface as createInterface2 } from "readline";
8372
6145
 
8373
6146
  // src/prepared.ts
8374
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync10 } from "fs";
8375
- import { join as join18 } from "path";
6147
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync9 } from "fs";
6148
+ import { join as join16 } from "path";
8376
6149
 
8377
6150
  // src/secrets.ts
8378
- import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
8379
- import { join as join19 } from "path";
8380
- import { z as z17 } from "zod";
6151
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
6152
+ import { join as join17 } from "path";
6153
+ import { z as z16 } from "zod";
8381
6154
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8382
6155
  function secretsPath() {
8383
- return join19(cabaneDir(), "secrets.json");
6156
+ return join17(cabaneDir(), "secrets.json");
8384
6157
  }
8385
- var secretStoreSchema = z17.record(z17.string(), z17.string());
6158
+ var secretStoreSchema = z16.record(z16.string(), z16.string());
8386
6159
  function loadSecretValues() {
8387
6160
  const path = secretsPath();
8388
- if (!existsSync11(path)) return {};
6161
+ if (!existsSync10(path)) return {};
8389
6162
  let raw;
8390
6163
  try {
8391
6164
  raw = readFileSync9(path, "utf8");
@@ -8570,9 +6343,9 @@ function runBounded(path, args, env) {
8570
6343
 
8571
6344
  // src/transcript-writer.ts
8572
6345
  import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync6 } from "fs";
8573
- import { basename as basename3, dirname as dirname7, join as join20 } from "path";
6346
+ import { basename as basename3, dirname as dirname6, join as join18 } from "path";
8574
6347
  function transcriptsDir() {
8575
- return join20(cabaneDir(), "transcripts");
6348
+ return join18(cabaneDir(), "transcripts");
8576
6349
  }
8577
6350
 
8578
6351
  // src/turn-execution.ts
@@ -8682,7 +6455,7 @@ var Dispatcher = class {
8682
6455
  };
8683
6456
 
8684
6457
  // src/host-report.ts
8685
- import { existsSync as existsSync13, readFileSync as readFileSync10 } from "fs";
6458
+ import { existsSync as existsSync12, readFileSync as readFileSync10 } from "fs";
8686
6459
  import { userInfo as userInfo2 } from "os";
8687
6460
  function inContainer(signals) {
8688
6461
  if (signals.dockerenv) return true;
@@ -8697,7 +6470,7 @@ function readContainerSignals() {
8697
6470
  } catch {
8698
6471
  cgroup = null;
8699
6472
  }
8700
- return { dockerenv: existsSync13("/.dockerenv"), cgroup, env: process.env };
6473
+ return { dockerenv: existsSync12("/.dockerenv"), cgroup, env: process.env };
8701
6474
  }
8702
6475
  function osUser() {
8703
6476
  try {
@@ -8934,7 +6707,7 @@ function checkReason(check) {
8934
6707
 
8935
6708
  // src/harness-copy.ts
8936
6709
  import { homedir as homedir8 } from "os";
8937
- import { join as join21, win32 } from "path";
6710
+ import { join as join19, win32 } from "path";
8938
6711
  var HARNESS_LABELS = {
8939
6712
  "claude-code": "Claude Code",
8940
6713
  codex: "Codex",
@@ -8948,7 +6721,7 @@ function failureText(runtime, reason) {
8948
6721
  case "spawn":
8949
6722
  return `Couldn't check whether ${label} is signed in: Cabane's bundled ${label} wouldn't start. Repair it with ${repairSteps()} and try again.${spaced(repairCost())}`;
8950
6723
  case "config":
8951
- return `Couldn't check whether ${label} is signed in: its configuration file didn't load${runtime === "codex" ? ` (${tildePath(join21(homedir8(), ".codex", "config.toml"))})` : ""}. Fix it, then try again.`;
6724
+ return `Couldn't check whether ${label} is signed in: its configuration file didn't load${runtime === "codex" ? ` (${tildePath(join19(homedir8(), ".codex", "config.toml"))})` : ""}. Fix it, then try again.`;
8952
6725
  case "unreadable":
8953
6726
  return `Couldn't check whether ${label} is signed in: it answered in a way Cabane couldn't read. Try again, and if it keeps happening, update Cabane.`;
8954
6727
  }
@@ -8999,12 +6772,12 @@ async function detectHarnesses(cfg, deps = {}) {
8999
6772
  );
9000
6773
  return version !== null ? { state: "usable", via: "serve", version, url } : { state: "not_running", url };
9001
6774
  };
9002
- const [claude, codex, serve2] = await Promise.all([
6775
+ const [claude, codex, serve] = await Promise.all([
9003
6776
  bundled("claude-code"),
9004
6777
  bundled("codex"),
9005
6778
  opencode()
9006
6779
  ]);
9007
- return { "claude-code": claude, codex, opencode: serve2 };
6780
+ return { "claude-code": claude, codex, opencode: serve };
9008
6781
  }
9009
6782
  function usableRuntimes(detection) {
9010
6783
  return HARNESS_RUNTIMES.filter((runtime) => detection[runtime].state === "usable");
@@ -9205,7 +6978,7 @@ var DispatchClaims = class {
9205
6978
 
9206
6979
  // src/outbox.ts
9207
6980
  import {
9208
- existsSync as existsSync14,
6981
+ existsSync as existsSync13,
9209
6982
  mkdirSync as mkdirSync10,
9210
6983
  readdirSync as readdirSync2,
9211
6984
  readFileSync as readFileSync11,
@@ -9213,7 +6986,7 @@ import {
9213
6986
  rmSync as rmSync7,
9214
6987
  writeFileSync as writeFileSync6
9215
6988
  } from "fs";
9216
- import { join as join22 } from "path";
6989
+ import { join as join20 } from "path";
9217
6990
  var MAX_ENTRIES = 2e3;
9218
6991
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
9219
6992
  var Outbox = class {
@@ -9226,10 +6999,10 @@ var Outbox = class {
9226
6999
  // Resolved lazily (per call) so tests that swap HOME between
9227
7000
  // cases route writes at the right tmpdir.
9228
7001
  dir() {
9229
- return join22(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
7002
+ return join20(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
9230
7003
  }
9231
7004
  fileFor(turnId, seq) {
9232
- return join22(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
7005
+ return join20(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
9233
7006
  }
9234
7007
  // Persist a commit for later draining. Atomic (temp file + rename) so a
9235
7008
  // concurrent `list()` never reads a half-written entry, then enforces the
@@ -9261,7 +7034,7 @@ var Outbox = class {
9261
7034
  // wedging the drain.
9262
7035
  list() {
9263
7036
  const dir2 = this.dir();
9264
- if (!existsSync14(dir2)) return [];
7037
+ if (!existsSync13(dir2)) return [];
9265
7038
  let names;
9266
7039
  try {
9267
7040
  names = readdirSync2(dir2);
@@ -9271,7 +7044,7 @@ var Outbox = class {
9271
7044
  const entries = [];
9272
7045
  for (const name of names) {
9273
7046
  if (!name.endsWith(".json")) continue;
9274
- const full = join22(dir2, name);
7047
+ const full = join20(dir2, name);
9275
7048
  try {
9276
7049
  const parsed = JSON.parse(readFileSync11(full, "utf8"));
9277
7050
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -9307,7 +7080,7 @@ var Outbox = class {
9307
7080
  // different: it is positive evidence that nothing was ever queued here.
9308
7081
  maxSeqForTurn(turnId) {
9309
7082
  const dir2 = this.dir();
9310
- if (!existsSync14(dir2)) return 0;
7083
+ if (!existsSync13(dir2)) return 0;
9311
7084
  const names = readdirSync2(dir2);
9312
7085
  const prefix = `${encodeURIComponent(turnId)}__`;
9313
7086
  let max = 0;
@@ -9327,7 +7100,7 @@ var Outbox = class {
9327
7100
  }
9328
7101
  size() {
9329
7102
  const dir2 = this.dir();
9330
- if (!existsSync14(dir2)) return 0;
7103
+ if (!existsSync13(dir2)) return 0;
9331
7104
  try {
9332
7105
  return readdirSync2(dir2).filter((n) => n.endsWith(".json")).length;
9333
7106
  } catch {
@@ -9494,8 +7267,8 @@ var SseSubscriber = class {
9494
7267
 
9495
7268
  // src/updater.ts
9496
7269
  import { spawn as spawn5 } from "child_process";
9497
- import { copyFileSync as copyFileSync2, existsSync as existsSync16, mkdirSync as mkdirSync14, renameSync as renameSync9, rmSync as rmSync10 } from "fs";
9498
- import { dirname as dirname11, join as join26 } from "path";
7270
+ import { copyFileSync as copyFileSync2, existsSync as existsSync15, mkdirSync as mkdirSync14, renameSync as renameSync9, rmSync as rmSync10 } from "fs";
7271
+ import { dirname as dirname10, join as join24 } from "path";
9499
7272
 
9500
7273
  // src/boot-rule.ts
9501
7274
  import {
@@ -9507,7 +7280,7 @@ import {
9507
7280
  symlinkSync,
9508
7281
  writeFileSync as writeFileSync7
9509
7282
  } from "fs";
9510
- import { dirname as dirname8, join as join23 } from "path";
7283
+ import { dirname as dirname7, join as join21 } from "path";
9511
7284
  var UNCONFIRMED_WINDOW_MS = 10 * 6e4;
9512
7285
  var MAX_STAGE_ATTEMPTS = 3;
9513
7286
  function superseded(pending, ...running) {
@@ -9530,7 +7303,7 @@ function rolledBackState(state, pending, reason, now) {
9530
7303
 
9531
7304
  // src/install-layout.ts
9532
7305
  import {
9533
- existsSync as existsSync15,
7306
+ existsSync as existsSync14,
9534
7307
  lstatSync,
9535
7308
  mkdirSync as mkdirSync12,
9536
7309
  readFileSync as readFileSync13,
@@ -9540,26 +7313,26 @@ import {
9540
7313
  rmSync as rmSync9,
9541
7314
  symlinkSync as symlinkSync2
9542
7315
  } from "fs";
9543
- import { dirname as dirname9, join as join24 } from "path";
9544
- import { fileURLToPath as fileURLToPath5 } from "url";
7316
+ import { dirname as dirname8, join as join22 } from "path";
7317
+ import { fileURLToPath as fileURLToPath4 } from "url";
9545
7318
  function versionsDir() {
9546
- return join24(cabaneDir(), "versions");
7319
+ return join22(cabaneDir(), "versions");
9547
7320
  }
9548
7321
  function currentLink() {
9549
- return join24(cabaneDir(), "current");
7322
+ return join22(cabaneDir(), "current");
9550
7323
  }
9551
7324
  function bootGuardPath() {
9552
- return join24(cabaneDir(), "boot-guard.mjs");
7325
+ return join22(cabaneDir(), "boot-guard.mjs");
9553
7326
  }
9554
7327
  function versionPackageRoot(version) {
9555
- return join24(versionsDir(), version, "node_modules", "@cabane", "companion");
7328
+ return join22(versionsDir(), version, "node_modules", "@cabane", "companion");
9556
7329
  }
9557
7330
  function ownPackageRoot(moduleUrl = import.meta.url) {
9558
- return dirname9(dirname9(fileURLToPath5(moduleUrl)));
7331
+ return dirname8(dirname8(fileURLToPath4(moduleUrl)));
9559
7332
  }
9560
7333
  function packageVersion(root) {
9561
7334
  try {
9562
- const pkg2 = JSON.parse(readFileSync13(join24(root, "package.json"), "utf8"));
7335
+ const pkg2 = JSON.parse(readFileSync13(join22(root, "package.json"), "utf8"));
9563
7336
  return typeof pkg2.version === "string" ? pkg2.version : null;
9564
7337
  } catch {
9565
7338
  return null;
@@ -9568,14 +7341,14 @@ function packageVersion(root) {
9568
7341
  function currentRoot() {
9569
7342
  try {
9570
7343
  const root = realpathSync5(currentLink());
9571
- return existsSync15(join24(root, "dist", "cli.js")) ? root : null;
7344
+ return existsSync14(join22(root, "dist", "cli.js")) ? root : null;
9572
7345
  } catch {
9573
7346
  return null;
9574
7347
  }
9575
7348
  }
9576
7349
  function pointCurrentAt(root) {
9577
7350
  const link = currentLink();
9578
- mkdirSync12(dirname9(link), { recursive: true });
7351
+ mkdirSync12(dirname8(link), { recursive: true });
9579
7352
  const fresh = `${link}.${process.pid}.${Date.now()}`;
9580
7353
  symlinkSync2(root, fresh);
9581
7354
  try {
@@ -9587,11 +7360,11 @@ function pointCurrentAt(root) {
9587
7360
  }
9588
7361
  function pruneVersions(keep) {
9589
7362
  const dir2 = versionsDir();
9590
- if (!existsSync15(dir2)) return [];
7363
+ if (!existsSync14(dir2)) return [];
9591
7364
  const removed = [];
9592
7365
  for (const name of readdirSync3(dir2)) {
9593
7366
  if (keep.includes(name)) continue;
9594
- rmSync9(join24(dir2, name), { recursive: true, force: true });
7367
+ rmSync9(join22(dir2, name), { recursive: true, force: true });
9595
7368
  removed.push(name);
9596
7369
  }
9597
7370
  return removed;
@@ -9610,31 +7383,31 @@ function compareVersions(a, b) {
9610
7383
 
9611
7384
  // src/update-state.ts
9612
7385
  import { mkdirSync as mkdirSync13, readFileSync as readFileSync14, renameSync as renameSync8, writeFileSync as writeFileSync8 } from "fs";
9613
- import { dirname as dirname10, join as join25 } from "path";
9614
- import { z as z18 } from "zod";
9615
- var resultSchema = z18.object({
9616
- kind: z18.enum(["updated", "rolled_back", "failed"]),
9617
- from: z18.string(),
9618
- to: z18.string(),
9619
- at: z18.string(),
9620
- reason: z18.string().optional()
7386
+ import { dirname as dirname9, join as join23 } from "path";
7387
+ import { z as z17 } from "zod";
7388
+ var resultSchema = z17.object({
7389
+ kind: z17.enum(["updated", "rolled_back", "failed"]),
7390
+ from: z17.string(),
7391
+ to: z17.string(),
7392
+ at: z17.string(),
7393
+ reason: z17.string().optional()
9621
7394
  });
9622
- var pendingSchema = z18.object({
9623
- from: z18.string(),
7395
+ var pendingSchema = z17.object({
7396
+ from: z17.string(),
9624
7397
  // The package root `current` pointed at before the swap, so a rollback can point it back.
9625
- fromRoot: z18.string(),
9626
- to: z18.string(),
9627
- at: z18.string(),
9628
- boots: z18.number().int().nonnegative()
7398
+ fromRoot: z17.string(),
7399
+ to: z17.string(),
7400
+ at: z17.string(),
7401
+ boots: z17.number().int().nonnegative()
9629
7402
  });
9630
- var stateSchema = z18.object({
9631
- server: z18.object({ latest: z18.string(), minimum: z18.string(), at: z18.string() }).optional(),
7403
+ var stateSchema = z17.object({
7404
+ server: z17.object({ latest: z17.string(), minimum: z17.string(), at: z17.string() }).optional(),
9632
7405
  pending: pendingSchema.optional(),
9633
7406
  lastResult: resultSchema.optional(),
9634
- failed: z18.object({ version: z18.string(), attempts: z18.number().int(), at: z18.string() }).optional()
7407
+ failed: z17.object({ version: z17.string(), attempts: z17.number().int(), at: z17.string() }).optional()
9635
7408
  });
9636
7409
  function updateStatePath(dir2 = cabaneDir()) {
9637
- return join25(dir2, "update.json");
7410
+ return join23(dir2, "update.json");
9638
7411
  }
9639
7412
  function readUpdateState(path = updateStatePath()) {
9640
7413
  try {
@@ -9645,7 +7418,7 @@ function readUpdateState(path = updateStatePath()) {
9645
7418
  }
9646
7419
  }
9647
7420
  function writeUpdateState(state, path = updateStatePath()) {
9648
- mkdirSync13(dirname10(path), { recursive: true });
7421
+ mkdirSync13(dirname9(path), { recursive: true });
9649
7422
  const tmp = `${path}.${process.pid}.tmp`;
9650
7423
  writeFileSync8(tmp, `${JSON.stringify(state, null, 2)}
9651
7424
  `, { mode: 384 });
@@ -9679,14 +7452,14 @@ function versionToStage(input) {
9679
7452
  return latest;
9680
7453
  }
9681
7454
  function rollBack(pending, reason, now = /* @__PURE__ */ new Date()) {
9682
- if (existsSync16(pending.fromRoot)) pointCurrentAt(pending.fromRoot);
7455
+ if (existsSync15(pending.fromRoot)) pointCurrentAt(pending.fromRoot);
9683
7456
  return updateUpdateState((state) => rolledBackState(state, pending, reason, now));
9684
7457
  }
9685
7458
  function writeBootGuard(root = ownPackageRoot()) {
9686
- const built = join26(root, "dist", "boot-guard.js");
9687
- if (!existsSync16(built)) return false;
7459
+ const built = join24(root, "dist", "boot-guard.js");
7460
+ if (!existsSync15(built)) return false;
9688
7461
  const target = bootGuardPath();
9689
- mkdirSync14(dirname11(target), { recursive: true });
7462
+ mkdirSync14(dirname10(target), { recursive: true });
9690
7463
  const tmp = `${target}.${process.pid}.tmp`;
9691
7464
  copyFileSync2(built, tmp);
9692
7465
  renameSync9(tmp, target);
@@ -9723,17 +7496,17 @@ var runProcess = (command, args, opts = {}) => new Promise((resolve) => {
9723
7496
  async function stageVersion(version, harnesses, deps = {}) {
9724
7497
  const run = deps.run ?? runProcess;
9725
7498
  const node = deps.execPath ?? process.execPath;
9726
- const target = join26(versionsDir(), version);
7499
+ const target = join24(versionsDir(), version);
9727
7500
  const placed = versionPackageRoot(version);
9728
- if (existsSync16(placed)) {
7501
+ if (existsSync15(placed)) {
9729
7502
  if (!await verifyInstall(placed, version, harnesses, { run, execPath: node }))
9730
7503
  return { ok: true, root: placed };
9731
7504
  }
9732
- const npm = join26(dirname11(node), "npm");
9733
- if (!existsSync16(npm))
9734
- return { ok: false, reason: `there is no npm beside Node at ${dirname11(node)}` };
7505
+ const npm = join24(dirname10(node), "npm");
7506
+ if (!existsSync15(npm))
7507
+ return { ok: false, reason: `there is no npm beside Node at ${dirname10(node)}` };
9735
7508
  mkdirSync14(versionsDir(), { recursive: true });
9736
- const staging = join26(versionsDir(), `.${version}.staging-${process.pid}`);
7509
+ const staging = join24(versionsDir(), `.${version}.staging-${process.pid}`);
9737
7510
  rmSync10(staging, { recursive: true, force: true });
9738
7511
  const installed = await run(
9739
7512
  npm,
@@ -9750,7 +7523,7 @@ async function stageVersion(version, harnesses, deps = {}) {
9750
7523
  ],
9751
7524
  {
9752
7525
  // npm's install scripts run on this Node, not whichever `node` is first on PATH.
9753
- env: { ...process.env, PATH: `${dirname11(node)}:${process.env.PATH ?? ""}` },
7526
+ env: { ...process.env, PATH: `${dirname10(node)}:${process.env.PATH ?? ""}` },
9754
7527
  timeoutMs: 15 * 6e4
9755
7528
  }
9756
7529
  );
@@ -9762,7 +7535,7 @@ async function stageVersion(version, harnesses, deps = {}) {
9762
7535
  return { ok: false, reason: `version ${version} isn't published yet`, unpublished: true };
9763
7536
  return { ok: false, reason: `npm couldn't install it: ${lastLine(installed.stderr)}` };
9764
7537
  }
9765
- const root = join26(staging, "node_modules", "@cabane", "companion");
7538
+ const root = join24(staging, "node_modules", "@cabane", "companion");
9766
7539
  const verdict = await verifyInstall(root, version, harnesses, { run, execPath: node });
9767
7540
  if (verdict) {
9768
7541
  rmSync10(staging, { recursive: true, force: true });
@@ -9774,7 +7547,7 @@ async function stageVersion(version, harnesses, deps = {}) {
9774
7547
  }
9775
7548
  async function verifyInstall(root, version, harnesses, deps) {
9776
7549
  if (packageVersion(root) !== version) return `the installed package isn't version ${version}`;
9777
- const answered = await deps.run(deps.execPath, [join26(root, "dist", "bin.js"), "--version"], {
7550
+ const answered = await deps.run(deps.execPath, [join24(root, "dist", "bin.js"), "--version"], {
9778
7551
  timeoutMs: 6e4,
9779
7552
  env: { ...process.env, CABANE_LAUNCH_SELF: "1" }
9780
7553
  });
@@ -9782,7 +7555,7 @@ async function verifyInstall(root, version, harnesses, deps) {
9782
7555
  return `it doesn't start: ${lastLine(answered.stderr) || "no version printed"}`;
9783
7556
  }
9784
7557
  if (harnesses.length === 0) return null;
9785
- const probed = await deps.run(deps.execPath, [join26(root, "dist", "postinstall.js"), "--json"], {
7558
+ const probed = await deps.run(deps.execPath, [join24(root, "dist", "postinstall.js"), "--json"], {
9786
7559
  timeoutMs: 6e4
9787
7560
  });
9788
7561
  let resolutions = {};
@@ -11555,10 +9328,10 @@ function handleUncaught(log, err, origin) {
11555
9328
  }
11556
9329
 
11557
9330
  // src/crash-marker.ts
11558
- import { existsSync as existsSync17, mkdirSync as mkdirSync15, readFileSync as readFileSync15, rmSync as rmSync11, writeFileSync as writeFileSync9 } from "fs";
11559
- import { join as join27 } from "path";
9331
+ import { existsSync as existsSync16, mkdirSync as mkdirSync15, readFileSync as readFileSync15, rmSync as rmSync11, writeFileSync as writeFileSync9 } from "fs";
9332
+ import { join as join25 } from "path";
11560
9333
  function crashMarkerPath() {
11561
- return join27(cabaneDir(), "last-error.json");
9334
+ return join25(cabaneDir(), "last-error.json");
11562
9335
  }
11563
9336
  function recordCrash(rec) {
11564
9337
  try {
@@ -11570,7 +9343,7 @@ function recordCrash(rec) {
11570
9343
  function clearCrash() {
11571
9344
  try {
11572
9345
  const path = crashMarkerPath();
11573
- if (existsSync17(path)) rmSync11(path, { force: true });
9346
+ if (existsSync16(path)) rmSync11(path, { force: true });
11574
9347
  } catch {
11575
9348
  }
11576
9349
  }
@@ -11578,7 +9351,7 @@ function clearCrash() {
11578
9351
  // src/stray-companion.ts
11579
9352
  import { spawnSync } from "child_process";
11580
9353
  import { readFileSync as readFileSync16 } from "fs";
11581
- import { join as join28 } from "path";
9354
+ import { join as join26 } from "path";
11582
9355
  var COMPANION_ENTRY = /@cabane\/companion\/|cabane-companion|(^|[\s/])cabane\s|\.cabane\/(versions|current|boot-guard)/;
11583
9356
  var START = /\sstart(\s|$)/;
11584
9357
  function companionStarts(psOutput) {
@@ -11595,7 +9368,7 @@ function companionStarts(psOutput) {
11595
9368
  }
11596
9369
  function stateDirOf(env) {
11597
9370
  const home = env.CABANE_COMPANION_HOME || env.HOME;
11598
- return home ? join28(home, ".cabane") : null;
9371
+ return home ? join26(home, ".cabane") : null;
11599
9372
  }
11600
9373
  function envFromPsE(output, command) {
11601
9374
  const line = output.replace(/\n$/, "");
@@ -11667,7 +9440,7 @@ function strayCompanionLines(strays, retry) {
11667
9440
  // src/runtime.ts
11668
9441
  function embeddedRunner(host) {
11669
9442
  if (!host.electron) return null;
11670
- const cli = fileURLToPath6(new URL("./cli.js", host.moduleUrl));
9443
+ const cli = fileURLToPath5(new URL("./cli.js", host.moduleUrl));
11671
9444
  return {
11672
9445
  runnerCommand: [host.execPath, cli, "run-turn"],
11673
9446
  runnerEnv: { ...host.env, ELECTRON_RUN_AS_NODE: "1" }
@@ -11762,11 +9535,12 @@ async function createCompanionRuntime(opts = {}) {
11762
9535
  update: (auto) => active2.update(auto),
11763
9536
  stop: () => void stop().then(() => active2.requestStop())
11764
9537
  });
11765
- if (opts.dashboard) {
11766
- const preferredPort = opts.port ?? cfg.dashboardPort;
11767
- dashboard = await startDashboard({
9538
+ if (opts.startDashboard) {
9539
+ const preferredPort = cfg.dashboardPort;
9540
+ dashboard = await opts.startDashboard({
11768
9541
  supervisor,
11769
9542
  hub,
9543
+ logPath: companionLogPath(),
11770
9544
  ...preferredPort !== void 0 ? { port: preferredPort } : {}
11771
9545
  });
11772
9546
  hub.setDashboardUrl(dashboard.url);
@@ -11826,7 +9600,10 @@ async function createCompanionRuntime(opts = {}) {
11826
9600
  };
11827
9601
  }
11828
9602
  export {
9603
+ ApiError,
9604
+ CompanionError,
11829
9605
  createCompanionRuntime,
9606
+ dispatchToWire,
11830
9607
  embeddedRunner,
11831
9608
  strayCompanionLines
11832
9609
  };