@cabane/companion 0.6.48 → 0.6.50

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.
Files changed (3) hide show
  1. package/dist/cli.js +232 -176
  2. package/dist/runtime.js +1127 -1081
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -644,40 +644,58 @@ async function ping(path) {
644
644
  }
645
645
 
646
646
  // src/harness-check.ts
647
- import { spawn as spawn4 } from "child_process";
647
+ import { spawn as spawn3 } from "child_process";
648
648
 
649
649
  // src/harness-versions.ts
650
650
  import { spawn as spawn2 } from "child_process";
651
651
  var EMPTY = { claudeCode: null, opencode: null, codex: null };
652
+ var CLI_PROBE_TIMEOUT_MS = 4e3;
652
653
  function parseVersionToken(raw) {
653
654
  if (!raw) return null;
654
655
  const m = raw.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
655
656
  return m ? m[0] : null;
656
657
  }
657
- async function probeCliVersion(command, spawnImpl = spawn2) {
658
+ function probeCliPresence(command, spawnImpl = spawn2) {
658
659
  return new Promise((resolve) => {
659
660
  let settled = false;
660
- const done = (v) => {
661
+ let timer = null;
662
+ const done = (result) => {
661
663
  if (!settled) {
662
664
  settled = true;
663
- resolve(v);
665
+ if (timer) clearTimeout(timer);
666
+ resolve(result);
664
667
  }
665
668
  };
666
669
  let child;
667
670
  try {
668
671
  child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
669
672
  } catch {
670
- done(null);
673
+ done({ status: "absent" });
671
674
  return;
672
675
  }
673
676
  let out = "";
674
677
  child.stdout?.on("data", (chunk) => {
675
678
  if (out.length < 4096) out += chunk.toString();
676
679
  });
677
- child.once("error", () => done(null));
678
- child.once("exit", (code) => done(code === 0 ? parseVersionToken(out) : null));
680
+ timer = setTimeout(() => {
681
+ child.kill?.("SIGKILL");
682
+ done({ status: "unusable" });
683
+ }, CLI_PROBE_TIMEOUT_MS);
684
+ timer.unref?.();
685
+ child.once("error", () => done({ status: "absent" }));
686
+ child.once("exit", (code) => {
687
+ const version = code === 0 ? parseVersionToken(out) : null;
688
+ done(version ? { status: "present", version } : { status: "unusable" });
689
+ });
679
690
  });
680
691
  }
692
+ function probeHarnessPresence(runtime, spawnImpl = spawn2) {
693
+ return probeCliPresence(runtime === "codex" ? "codex" : "claude", spawnImpl);
694
+ }
695
+ async function probeCliVersion(command, spawnImpl = spawn2) {
696
+ const result = await probeCliPresence(command, spawnImpl);
697
+ return result.status === "present" ? result.version : null;
698
+ }
681
699
  async function probeOpencodeVersion(serverUrl, fetchImpl = fetch) {
682
700
  try {
683
701
  const base = serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl;
@@ -741,81 +759,6 @@ function buildCompanionManifest(opts) {
741
759
  return { runtimes, capabilities: { ...DEVICE_MANIFEST.capabilities } };
742
760
  }
743
761
 
744
- // src/prereqs.ts
745
- import { spawn as spawn3 } from "child_process";
746
- async function claudeOnPath() {
747
- return new Promise((resolve) => {
748
- let settled = false;
749
- const child = spawn3("claude", ["--version"], { stdio: "ignore" });
750
- child.once("error", () => {
751
- if (!settled) {
752
- settled = true;
753
- resolve(false);
754
- }
755
- });
756
- child.once("exit", (code) => {
757
- if (!settled) {
758
- settled = true;
759
- resolve(code === 0);
760
- }
761
- });
762
- });
763
- }
764
- var CODEX_PROBE_TIMEOUT_MS = 4e3;
765
- async function codexOnPath() {
766
- const version = await Promise.race([
767
- probeCliVersion("codex"),
768
- new Promise((resolve) => {
769
- const timer = setTimeout(() => resolve(null), CODEX_PROBE_TIMEOUT_MS);
770
- timer.unref?.();
771
- })
772
- ]);
773
- return version !== null;
774
- }
775
- async function requireStartConfig(deps = {}) {
776
- const requireCfg = deps.requireCfg ?? requireConfig;
777
- const probeClaude = deps.probeClaude ?? claudeOnPath;
778
- const save2 = deps.save ?? saveConfig;
779
- const cfg = requireCfg();
780
- const claudeOnPathResult = await probeClaude();
781
- const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
782
- if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
783
- save2(migrated);
784
- deps.onMigrated?.(migrated, claudeOnPathResult);
785
- return { cfg: migrated, claudeOnPath: claudeOnPathResult };
786
- }
787
- async function warnAboutHarnessReadiness(cfg, deps = {}) {
788
- const probeClaude = deps.probeClaude ?? claudeOnPath;
789
- const probeCodex = deps.probeCodex ?? codexOnPath;
790
- const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
791
- `));
792
- const connected = [
793
- ...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
794
- ...isCodexEnabled(cfg) ? ["Codex"] : [],
795
- ...cfg.opencode ? ["opencode"] : []
796
- ];
797
- if (connected.length > 0) {
798
- if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
799
- warn(
800
- "warning: Claude Code is connected on this device but `claude` isn\u2019t on your PATH, so it advertises nothing and a Claude-model agent won\u2019t be routed here. Install it (`npm i -g @anthropic-ai/claude-code`) and log in, or disconnect it."
801
- );
802
- }
803
- return;
804
- }
805
- const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
806
- const installed = [
807
- ...claudeInstalled ? ["Claude Code"] : [],
808
- ...codexInstalled ? ["Codex"] : []
809
- ];
810
- const connectCommands = [
811
- ...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
812
- ...codexInstalled ? ["`cabane-companion connect codex`"] : []
813
- ];
814
- warn(
815
- "No harness is connected on this device yet, so no agent turn can run here. " + (installed.length > 0 ? `We found ${installed.join(" and ")} on this machine \u2014 connect ${installed.length > 1 ? "one with " : "it with "}${connectCommands.join(" or ")} and turns start routing here.` : "Install a harness and sign in \u2014 Claude Code (`npm i -g @anthropic-ai/claude-code`), the Codex CLI (`codex login`), or `opencode serve` \u2014 then connect it with `cabane-companion connect <harness>` (pass `--url <url>` for opencode).")
816
- );
817
- }
818
-
819
762
  // src/harness-status.ts
820
763
  var HARNESS_LABELS = {
821
764
  "claude-code": "Claude Code",
@@ -976,20 +919,19 @@ async function resolveOpencodeServerUrl(configuredServerUrl, requestedServerUrl,
976
919
  return await probeDefault() !== null ? DEFAULT_OPENCODE_SERVER_URL : null;
977
920
  }
978
921
  async function probeHarnessSignals(cfg, deps = {}) {
979
- const probeClaudePresence = deps.probeClaudePresence ?? claudeOnPath;
980
- const probeClaudeVersion = deps.probeClaudeVersion ?? (() => probeCliVersion("claude"));
981
- const probeCodexVersion = deps.probeCodexVersion ?? (() => probeCliVersion("codex"));
922
+ const probePresence = deps.probePresence ?? probeHarnessPresence;
982
923
  const probeOpencode = deps.probeOpencode ?? ((url) => probeOpencodeVersion(url));
983
924
  const configuredServerUrl = cfg.opencode?.serverUrl;
984
925
  const opencodeProbeUrl = configuredServerUrl ?? DEFAULT_OPENCODE_SERVER_URL;
985
- const [claudeOnPathResult, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
986
- withTimeout(probeClaudePresence(), false),
987
- withTimeout(probeClaudeVersion(), null),
988
- withTimeout(probeCodexVersion(), null),
926
+ const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
927
+ deps.presence?.["claude-code"] ?? withTimeout(probePresence("claude-code"), { status: "absent" }),
928
+ deps.presence?.codex ?? withTimeout(probePresence("codex"), { status: "absent" }),
989
929
  configuredServerUrl ? withTimeout(probeOpencode(opencodeProbeUrl), null) : probeDefaultOpencodeVersion(probeOpencode)
990
930
  ]);
931
+ const claudeVersion = claudePresence.status === "present" ? claudePresence.version : null;
932
+ const codexVersion = codexPresence.status === "present" ? codexPresence.version : null;
991
933
  return {
992
- claudeOnPath: claudeOnPathResult,
934
+ claudeOnPath: claudePresence.status === "present",
993
935
  claudeVersion,
994
936
  // CT1082: the user's opt-in. Presence alone exposes nothing now, so this is
995
937
  // the manifest gate and the probe above is only a suggestion.
@@ -1041,16 +983,13 @@ async function shakeOutHarness(runtime, cfg, deps = {}) {
1041
983
  if (!url) return "absent";
1042
984
  return await probeOpencode(url) !== null ? "ok" : "failed";
1043
985
  }
1044
- const { auth, presence } = runtime === "codex" ? {
1045
- auth: ["codex", ["login", "status"]],
1046
- presence: ["codex", ["--version"]]
986
+ const { auth } = runtime === "codex" ? {
987
+ auth: ["codex", ["login", "status"]]
1047
988
  } : {
1048
- auth: ["claude", ["auth", "status"]],
1049
- presence: ["claude", ["--version"]]
989
+ auth: ["claude", ["auth", "status"]]
1050
990
  };
1051
- const presenceRun = await run(presence[0], [...presence[1]]);
1052
- if (presenceRun.error === "spawn") return "absent";
1053
- if (presenceRun.code !== 0) return "unverified";
991
+ const presence = deps.presence ?? await (deps.probePresence ?? probeHarnessPresence)(runtime);
992
+ if (presence.status !== "present") return presence.status;
1054
993
  const authRun = await run(auth[0], [...auth[1]]);
1055
994
  if (authRun.code === 0) return "ok";
1056
995
  if (looksUnsupported(authRun.output)) {
@@ -1062,7 +1001,9 @@ async function shakeOutHarness(runtime, cfg, deps = {}) {
1062
1001
  }
1063
1002
  }
1064
1003
  function connectedLine(runtime, verdict) {
1065
- if (verdict === "absent") throw new Error("an absent harness cannot be connected");
1004
+ if (verdict === "absent" || verdict === "unusable") {
1005
+ throw new Error("an unavailable harness cannot be connected");
1006
+ }
1066
1007
  const label = HARNESS_LABELS[runtime];
1067
1008
  if (verdict !== "failed") return `${label} connected.`;
1068
1009
  return `${label} connected \u2014 ${FAILED_SUFFIX[runtime]}`;
@@ -1072,13 +1013,18 @@ var FAILED_SUFFIX = {
1072
1013
  codex: "it doesn\u2019t look signed in yet. Run `codex login` once, then it\u2019s ready.",
1073
1014
  opencode: "its server isn\u2019t answering. Start `opencode serve`, then it\u2019s ready."
1074
1015
  };
1075
- function absentLine(runtime) {
1016
+ function presenceReason(runtime, status2) {
1076
1017
  if (runtime === "opencode") {
1077
1018
  return "No opencode server is configured \u2014 run `opencode serve` and connect with `--url`.";
1078
1019
  }
1079
1020
  const label = HARNESS_LABELS[runtime];
1080
- const login = runtime === "codex" ? "`codex login`" : "`claude`";
1081
- return `${label} isn\u2019t installed on this machine \u2014 install it and sign in (${login}), then run this again.`;
1021
+ return status2 === "absent" ? `${label} isn't installed on this machine.` : `${label} is installed here but didn't report a version Cabane can read.`;
1022
+ }
1023
+ var CLI_PRESENCE_ACTION = "Install it and run this again.";
1024
+ var DASHBOARD_PRESENCE_ACTION = "Install it, then refresh.";
1025
+ function presenceRefusal(runtime, status2, action) {
1026
+ const reason = presenceReason(runtime, status2);
1027
+ return runtime === "opencode" ? reason : `${reason} ${action}`;
1082
1028
  }
1083
1029
  function looksUnsupported(output) {
1084
1030
  return /unrecognized|unknown (sub)?command|unexpected argument|invalid (sub)?command|no such (sub)?command|usage:|did you mean/i.test(
@@ -1096,7 +1042,7 @@ function runBounded(command, args) {
1096
1042
  };
1097
1043
  let child;
1098
1044
  try {
1099
- child = spawn4(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1045
+ child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
1100
1046
  } catch {
1101
1047
  resolve({ code: null, output: "", error: "spawn" });
1102
1048
  return;
@@ -1299,18 +1245,31 @@ async function confirm(question) {
1299
1245
  }
1300
1246
 
1301
1247
  // src/commands/connect.ts
1302
- async function connect2(raw, opts = {}) {
1248
+ async function connect2(raw, opts = {}, deps = {}) {
1303
1249
  const runtime = parseHarnessRuntime(raw);
1304
1250
  if (!runtime) {
1305
1251
  throw new CompanionError(
1306
1252
  `unknown harness "${raw}". Connectable harnesses are: claude-code, codex, opencode.`
1307
1253
  );
1308
1254
  }
1309
- const live = await liveSocket();
1255
+ const loadConfig2 = deps.loadConfig ?? requireConfig;
1256
+ const request = deps.request ?? controlRequest;
1257
+ const save2 = deps.save ?? saveConfig;
1258
+ const output = deps.output ?? write;
1259
+ const probePresence = deps.probePresence ?? probeHarnessPresence;
1260
+ const shakeOut = deps.shakeOut ?? shakeOutHarness;
1261
+ const resolveServerUrl = deps.resolveServerUrl ?? resolveOpencodeServerUrl;
1262
+ const probeOpencode = deps.probeOpencode ?? probeOpencodeVersion;
1263
+ const cfg = loadConfig2();
1264
+ if (!opts.serverUrl?.trim() && alreadyConnected(runtime, cfg)) {
1265
+ output(`${INDENT}${HARNESS_LABELS[runtime]} is already connected on this device.`);
1266
+ return;
1267
+ }
1268
+ const live = await (deps.findLiveSocket ?? liveSocket)();
1310
1269
  if (live) {
1311
1270
  let result;
1312
1271
  try {
1313
- result = await controlRequest(
1272
+ result = await request(
1314
1273
  live,
1315
1274
  {
1316
1275
  cmd: "connect",
@@ -1326,42 +1285,38 @@ async function connect2(raw, opts = {}) {
1326
1285
  );
1327
1286
  }
1328
1287
  if (!result.ok) throw new CompanionError(result.message);
1329
- write(INDENT + tick(result.message));
1330
- return;
1331
- }
1332
- const cfg = requireConfig();
1333
- if (alreadyConnected(runtime, cfg)) {
1334
- write(`${INDENT}${HARNESS_LABELS[runtime]} is already connected on this device.`);
1288
+ output(INDENT + tick(result.message));
1335
1289
  return;
1336
1290
  }
1337
- if (runtime === "claude-code" && !await claudeOnPath()) {
1338
- throw new CompanionError(
1339
- "Couldn\u2019t find `claude` on this machine\u2019s PATH. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in, then connect it."
1340
- );
1341
- }
1342
1291
  let next = cfg;
1292
+ const presence = runtime === "opencode" ? void 0 : await probePresence(runtime);
1293
+ if (presence && presence.status !== "present") {
1294
+ throw new CompanionError(presenceRefusal(runtime, presence.status, CLI_PRESENCE_ACTION));
1295
+ }
1343
1296
  if (runtime === "claude-code") next = { ...cfg, claudeCode: { enabled: true } };
1344
1297
  else if (runtime === "codex") next = { ...cfg, codex: { enabled: true } };
1345
1298
  else {
1346
1299
  const requestedServerUrl = opts.serverUrl?.trim();
1347
- const serverUrl = await resolveOpencodeServerUrl(void 0, requestedServerUrl);
1300
+ const serverUrl = await resolveServerUrl(cfg.opencode?.serverUrl, requestedServerUrl);
1348
1301
  if (!serverUrl) {
1349
1302
  throw new CompanionError(
1350
1303
  "opencode is addressed by URL \u2014 pass it: `cabane-companion connect opencode --url http://127.0.0.1:4096`."
1351
1304
  );
1352
1305
  }
1353
- if (requestedServerUrl && await probeOpencodeVersion(serverUrl) === null) {
1306
+ if (requestedServerUrl && await probeOpencode(serverUrl) === null) {
1354
1307
  throw new CompanionError(
1355
1308
  `Couldn\u2019t reach an opencode server at ${serverUrl}. Start \`opencode serve\` and check the URL.`
1356
1309
  );
1357
1310
  }
1358
1311
  next = { ...cfg, opencode: { serverUrl } };
1359
1312
  }
1360
- const verdict = await shakeOutHarness(runtime, next);
1361
- if (verdict === "absent") throw new CompanionError(absentLine(runtime));
1362
- saveConfig(next);
1363
- write(INDENT + tick(connectedLine(runtime, verdict)));
1364
- write(`${INDENT}Not running yet \u2014 start it with: cabane-companion start`);
1313
+ const verdict = await shakeOut(runtime, next, { ...presence ? { presence } : {} });
1314
+ if (verdict === "absent" || verdict === "unusable") {
1315
+ throw new CompanionError(presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION));
1316
+ }
1317
+ save2(next);
1318
+ output(INDENT + tick(connectedLine(runtime, verdict)));
1319
+ output(`${INDENT}Not running yet \u2014 start it with: cabane-companion start`);
1365
1320
  }
1366
1321
  async function liveSocket() {
1367
1322
  const state = readLiveRuntimeState();
@@ -2082,12 +2037,18 @@ function registerRoutes(app, deps) {
2082
2037
  const runtime = body.runtime;
2083
2038
  if (runtime === "claude-code") {
2084
2039
  const result = await supervisor.enableHarness({ runtime: "claude-code" });
2085
- if (!result.ok) return c.json({ error: result.error }, 400);
2040
+ if (!result.ok) {
2041
+ const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
2042
+ return c.json({ error }, 400);
2043
+ }
2086
2044
  return c.json({ ok: true, status: hub.statusJson() });
2087
2045
  }
2088
2046
  if (runtime === "codex") {
2089
2047
  const result = await supervisor.enableHarness({ runtime: "codex" });
2090
- if (!result.ok) return c.json({ error: result.error }, 400);
2048
+ if (!result.ok) {
2049
+ const error = result.presence ? `${result.error} ${DASHBOARD_PRESENCE_ACTION}` : result.error;
2050
+ return c.json({ error }, 400);
2051
+ }
2091
2052
  return c.json({ ok: true, status: hub.statusJson() });
2092
2053
  }
2093
2054
  if (runtime === "opencode") {
@@ -2246,6 +2207,57 @@ function resolveStaticDir() {
2246
2207
  return join7(dirname4(fileURLToPath(import.meta.url)), "static");
2247
2208
  }
2248
2209
 
2210
+ // src/prereqs.ts
2211
+ async function claudeOnPath() {
2212
+ return (await probeHarnessPresence("claude-code")).status === "present";
2213
+ }
2214
+ async function codexOnPath() {
2215
+ return (await probeHarnessPresence("codex")).status === "present";
2216
+ }
2217
+ async function requireStartConfig(deps = {}) {
2218
+ const requireCfg = deps.requireCfg ?? requireConfig;
2219
+ const probeClaude = deps.probeClaude ?? claudeOnPath;
2220
+ const save2 = deps.save ?? saveConfig;
2221
+ const cfg = requireCfg();
2222
+ const claudeOnPathResult = await probeClaude();
2223
+ const migrated = migrateConnectedHarnesses(cfg, claudeOnPathResult);
2224
+ if (!migrated) return { cfg, claudeOnPath: claudeOnPathResult };
2225
+ save2(migrated);
2226
+ deps.onMigrated?.(migrated, claudeOnPathResult);
2227
+ return { cfg: migrated, claudeOnPath: claudeOnPathResult };
2228
+ }
2229
+ async function warnAboutHarnessReadiness(cfg, deps = {}) {
2230
+ const probeClaude = deps.probeClaude ?? claudeOnPath;
2231
+ const probeCodex = deps.probeCodex ?? codexOnPath;
2232
+ const warn = deps.warn ?? ((message) => process.stderr.write(`${message}
2233
+ `));
2234
+ const connected = [
2235
+ ...isClaudeCodeConnected(cfg) ? ["Claude Code"] : [],
2236
+ ...isCodexEnabled(cfg) ? ["Codex"] : [],
2237
+ ...cfg.opencode ? ["opencode"] : []
2238
+ ];
2239
+ if (connected.length > 0) {
2240
+ if (isClaudeCodeConnected(cfg) && !await probeClaude()) {
2241
+ warn(
2242
+ "warning: Claude Code is connected on this device but `claude` isn\u2019t on your PATH, so it advertises nothing and a Claude-model agent won\u2019t be routed here. Install it (`npm i -g @anthropic-ai/claude-code`) and log in, or disconnect it."
2243
+ );
2244
+ }
2245
+ return;
2246
+ }
2247
+ const [claudeInstalled, codexInstalled] = await Promise.all([probeClaude(), probeCodex()]);
2248
+ const installed = [
2249
+ ...claudeInstalled ? ["Claude Code"] : [],
2250
+ ...codexInstalled ? ["Codex"] : []
2251
+ ];
2252
+ const connectCommands = [
2253
+ ...claudeInstalled ? ["`cabane-companion connect claude-code`"] : [],
2254
+ ...codexInstalled ? ["`cabane-companion connect codex`"] : []
2255
+ ];
2256
+ warn(
2257
+ "No harness is connected on this device yet, so no agent turn can run here. " + (installed.length > 0 ? `We found ${installed.join(" and ")} on this machine \u2014 connect ${installed.length > 1 ? "one with " : "it with "}${connectCommands.join(" or ")} and turns start routing here.` : "Install a harness and sign in \u2014 Claude Code (`npm i -g @anthropic-ai/claude-code`), the Codex CLI (`codex login`), or `opencode serve` \u2014 then connect it with `cabane-companion connect <harness>` (pass `--url <url>` for opencode).")
2258
+ );
2259
+ }
2260
+
2249
2261
  // src/api.ts
2250
2262
  var RETRY_BACKOFF_MS = [250, 750];
2251
2263
  var ACTIVE_RUN_OUTBOX_SEQ = 0;
@@ -3599,7 +3611,7 @@ var TurnPump = class {
3599
3611
  };
3600
3612
  }
3601
3613
  opts;
3602
- // `finalEmitted` gates the end-of-turn empty-final promotion;
3614
+ // `finalEmitted` gates the end-of-turn progress promotion;
3603
3615
  // `lastProgressBody` is what we promote to `final` when a clean turn ended on
3604
3616
  // a tool call with no closing text.
3605
3617
  emittedFinal = false;
@@ -3614,28 +3626,28 @@ var TurnPump = class {
3614
3626
  // frozen committer test — drive. Each callback applies the choreography and
3615
3627
  // commits through the injected sink.
3616
3628
  sink;
3617
- // End-of-turn empty-final promotion. A clean turn that ended on a tool call
3618
- // with no closing text still needs exactly one `final` row (so the user gets
3619
- // one notification and the drawer closes the turn). Promote the last progress
3620
- // body when we have one (the drawer collapses the dup), else the host's
3621
- // minimal note. Skipped when cancelled or already final. The held-text flush
3622
- // that precedes it is a classification concern, driven by the caller before
3623
- // this runs.
3629
+ // End-of-turn progress promotion. A clean turn that emitted interim text but
3630
+ // no closing text promotes the agent's own last progress body (the drawer
3631
+ // collapses the duplicate). If the agent emitted no words, emit no message:
3632
+ // Cabane never attributes host-authored text to an agent, in any turn shape,
3633
+ // for any reason. The host closes that wordless turn with a marker instead.
3634
+ // Skipped when cancelled or already final. The held-text flush that precedes
3635
+ // this is a classification concern, driven by the caller before this runs.
3624
3636
  async finalize(ok) {
3625
- if (!ok || this.opts.signal.aborted || this.emittedFinal) return;
3626
- const body = this.lastProgressBody ?? this.opts.emptyFinalBody;
3637
+ if (!ok || this.opts.signal.aborted || this.emittedFinal || !this.lastProgressBody) return;
3638
+ const body = this.lastProgressBody;
3627
3639
  const seq = this.opts.nextSeq();
3628
3640
  try {
3629
3641
  await this.opts.commit.commitMessage({ body, kind: "final", seq });
3630
3642
  this.emittedFinal = true;
3631
3643
  this.finalReplyBody = body;
3632
- this.emittedFinalSource = this.lastProgressBody ? "progress_promotion" : "host_fallback";
3644
+ this.emittedFinalSource = "progress_promotion";
3633
3645
  } catch (err) {
3634
- this.opts.onError?.(err, "empty-final");
3646
+ this.opts.onError?.(err, "progress-promotion");
3635
3647
  }
3636
3648
  }
3637
3649
  // Whether the turn has committed its `final` row — read by the host to decide
3638
- // whether the empty-final path or a marker is owed.
3650
+ // whether a marker is owed.
3639
3651
  get finalEmitted() {
3640
3652
  return this.emittedFinal;
3641
3653
  }
@@ -7274,7 +7286,6 @@ function pruneOld(dir2, retain) {
7274
7286
  }
7275
7287
 
7276
7288
  // src/turn-committer.ts
7277
- var EMPTY_FINAL_BODY = "Done \u2014 see the changes above.";
7278
7289
  var TurnCommitter = class {
7279
7290
  constructor(deps) {
7280
7291
  this.deps = deps;
@@ -7295,9 +7306,7 @@ var TurnCommitter = class {
7295
7306
  turnId: deps.turnId,
7296
7307
  seq,
7297
7308
  parentMessageId: deps.parentMessageId,
7298
- ...kind === "final" ? this.summonField() : {},
7299
- ...kind === "final" ? this.askField() : {},
7300
- ...kind === "final" ? this.wakeField() : {}
7309
+ ...kind === "final" ? this.turnControlFields() : {}
7301
7310
  },
7302
7311
  deps.signal
7303
7312
  );
@@ -7344,11 +7353,10 @@ var TurnCommitter = class {
7344
7353
  commit,
7345
7354
  signal: deps.signal,
7346
7355
  nextSeq: deps.nextSeq,
7347
- emptyFinalBody: EMPTY_FINAL_BODY,
7348
7356
  onError: (err) => {
7349
7357
  deps.log.warn(
7350
7358
  { err: err instanceof Error ? err.message : String(err) },
7351
- "dispatcher: empty-final commit failed"
7359
+ "dispatcher: progress-promotion commit failed"
7352
7360
  );
7353
7361
  }
7354
7362
  });
@@ -7374,11 +7382,11 @@ var TurnCommitter = class {
7374
7382
  this.onError(err, event.type);
7375
7383
  }
7376
7384
  }
7377
- // End-of-turn empty-final promotion. The held-text flush is now the adapter's
7385
+ // End-of-turn progress promotion. The held-text flush is now the adapter's
7378
7386
  // job (it emits the closing reply as a `text` event before `result`), so this
7379
- // only runs the pump's empty-final: a clean turn that ended on a tool call with
7380
- // no closing text still gets exactly one `final`. Guards on the abort signal, so
7381
- // on cancel no `final` is forced.
7387
+ // only asks the pump to promote agent-authored interim text. A wordless turn
7388
+ // stays wordless and the dispatcher closes it with a marker. Guards on the
7389
+ // abort signal, so on cancel no `final` is forced.
7382
7390
  async finalize(okResult) {
7383
7391
  await this.pump.finalize(okResult);
7384
7392
  }
@@ -7389,6 +7397,19 @@ var TurnCommitter = class {
7389
7397
  get finalSource() {
7390
7398
  return this.pump.finalSource;
7391
7399
  }
7400
+ get finalEmitted() {
7401
+ return this.pump.finalEmitted;
7402
+ }
7403
+ // A closing textual reply and a wordless terminal marker carry the same
7404
+ // per-turn control state. Keep this one projection so ask/wake/summon cannot
7405
+ // silently diverge when the agent ends without words.
7406
+ turnControlFields() {
7407
+ return {
7408
+ ...this.summonField(),
7409
+ ...this.askField(),
7410
+ ...this.wakeField()
7411
+ };
7412
+ }
7392
7413
  // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
7393
7414
  // commit. A self-target is stripped here (mirror of the in-app self-strip); the
7394
7415
  // server strips it again and resolves / ignores an unknown id.
@@ -7617,6 +7638,7 @@ var STOPPED_MARKER_BODY = "(stopped)";
7617
7638
  var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
7618
7639
  var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
7619
7640
  var SKIPPED_MARKER_BODY = "(skipped)";
7641
+ var SILENT_MARKER_BODY = "(no reply)";
7620
7642
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
7621
7643
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7622
7644
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
@@ -8274,6 +8296,7 @@ ${reason}`,
8274
8296
  let contentBearingEvents = 0;
8275
8297
  let latestSessionState = request.session;
8276
8298
  let settledDiagnostics = null;
8299
+ let silentMarkerEmitted = false;
8277
8300
  const committer = new TurnCommitter({
8278
8301
  api: this.opts.api,
8279
8302
  workspaceId,
@@ -8465,6 +8488,27 @@ ${reason}`,
8465
8488
  }
8466
8489
  } else {
8467
8490
  await committer.finalize(okResult);
8491
+ if (!abortController.signal.aborted && okResult && !committer.finalEmitted) {
8492
+ try {
8493
+ await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8494
+ body: SILENT_MARKER_BODY,
8495
+ kind: "silent",
8496
+ turnId,
8497
+ seq: nextSeq(),
8498
+ parentMessageId: payload.messageId,
8499
+ // ask, wake and summon must survive a wordless turn exactly as
8500
+ // they survive a textual final; dropping one can strand a person
8501
+ // or the next actor with no visible failure.
8502
+ ...committer.turnControlFields()
8503
+ });
8504
+ silentMarkerEmitted = true;
8505
+ } catch (err) {
8506
+ turnLog.warn(
8507
+ { err: err instanceof Error ? err.message : String(err) },
8508
+ "dispatcher: silent-marker commit failed"
8509
+ );
8510
+ }
8511
+ }
8468
8512
  }
8469
8513
  } catch (err) {
8470
8514
  okResult = false;
@@ -8555,7 +8599,7 @@ ${reason}`,
8555
8599
  sessionFingerprint: fingerprintSessionState(latestSessionState),
8556
8600
  eventCounts,
8557
8601
  runtimeResultKind,
8558
- finalSource: outcome === "skipped" || outcome === "cancelled" ? "marker" : committer.finalSource
8602
+ finalSource: outcome === "skipped" || outcome === "cancelled" || silentMarkerEmitted ? "marker" : committer.finalSource
8559
8603
  };
8560
8604
  body.diagnostics = settledDiagnostics;
8561
8605
  if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
@@ -9033,11 +9077,9 @@ var CompanionSupervisor = class {
9033
9077
  // can never disagree with what the manifest advertises. Null until the first
9034
9078
  // probe (the heartbeat then falls back to the boot `claudeCode`).
9035
9079
  harnessSignals = null;
9036
- // CT1082: the fresh PATH probe the claude-code connect vets with. Deliberately
9037
- // NOT the cached beat signal — someone connecting right after installing Claude
9038
- // Code shouldn't be refused by a snapshot up to a heartbeat old.
9039
- probeClaudePresence;
9040
- probeCodexPresence;
9080
+ // Fresh per attempt, never the cached heartbeat signal: installing a harness
9081
+ // between attempts must be observed immediately.
9082
+ probePresence;
9041
9083
  exitFn;
9042
9084
  reexecFn;
9043
9085
  dispatcherFactory;
@@ -9069,8 +9111,7 @@ var CompanionSupervisor = class {
9069
9111
  this.log = opts.log;
9070
9112
  this.hub = opts.hub;
9071
9113
  this.claudeCode = opts.claudeCode ?? true;
9072
- this.probeClaudePresence = opts.probeClaudePresence ?? claudeOnPath;
9073
- this.probeCodexPresence = opts.probeCodexPresence ?? codexOnPath;
9114
+ this.probePresence = opts.probePresence ?? probeHarnessPresence;
9074
9115
  this.harnessVersions = opts.harnessVersions ?? emptyHarnessVersions();
9075
9116
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
9076
9117
  this.reexecFn = opts.reexec ?? defaultReexec;
@@ -9768,10 +9809,11 @@ var CompanionSupervisor = class {
9768
9809
  // over SSE). Fail-soft by construction — `probeHarnessSignals` never throws — but
9769
9810
  // wrapped anyway so a harness refresh can never take a heartbeat down. Called on
9770
9811
  // the heartbeat cadence (fresh presence for the manifest + UI) and on demand.
9771
- async refreshHarnessStatuses() {
9812
+ async refreshHarnessStatuses(presence = {}) {
9772
9813
  try {
9773
9814
  const signals = await probeHarnessSignals(this.config, {
9774
- probeClaudePresence: this.probeClaudePresence
9815
+ probePresence: this.probePresence,
9816
+ presence
9775
9817
  });
9776
9818
  this.harnessSignals = signals;
9777
9819
  this.harnessVersions = {
@@ -9828,18 +9870,24 @@ var CompanionSupervisor = class {
9828
9870
  // reachable/valid input).
9829
9871
  async enableHarness(input) {
9830
9872
  let next;
9873
+ let checkedPresence = null;
9831
9874
  if (input.runtime === "claude-code") {
9832
- if (!await this.probeClaudePresence()) {
9875
+ const presence = input.presence ?? await this.probePresence("claude-code");
9876
+ if (presence.status !== "present") {
9833
9877
  return {
9834
9878
  ok: false,
9835
- error: "Couldn\u2019t find `claude` on this machine\u2019s PATH. Install Claude Code (`npm i -g @anthropic-ai/claude-code`) and sign in, then connect it."
9879
+ error: presenceReason("claude-code", presence.status),
9880
+ presence: true
9836
9881
  };
9837
9882
  }
9883
+ checkedPresence = { runtime: "claude-code", result: presence };
9838
9884
  next = { ...this.config, claudeCode: { enabled: true } };
9839
9885
  } else if (input.runtime === "codex") {
9840
- if (!await this.probeCodexPresence()) {
9841
- return { ok: false, error: absentLine("codex") };
9886
+ const presence = input.presence ?? await this.probePresence("codex");
9887
+ if (presence.status !== "present") {
9888
+ return { ok: false, error: presenceReason("codex", presence.status), presence: true };
9842
9889
  }
9890
+ checkedPresence = { runtime: "codex", result: presence };
9843
9891
  next = { ...this.config, codex: { enabled: true } };
9844
9892
  } else {
9845
9893
  const serverUrl = input.serverUrl.trim();
@@ -9863,7 +9911,9 @@ var CompanionSupervisor = class {
9863
9911
  this.config = next;
9864
9912
  saveConfig(next);
9865
9913
  this.rebuildDispatchers();
9866
- await this.refreshHarnessStatuses();
9914
+ await this.refreshHarnessStatuses(
9915
+ checkedPresence ? { [checkedPresence.runtime]: checkedPresence.result } : {}
9916
+ );
9867
9917
  this.kickHeartbeat();
9868
9918
  return { ok: true };
9869
9919
  }
@@ -9927,9 +9977,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
9927
9977
  }
9928
9978
  function defaultReexec() {
9929
9979
  clearRuntimeState();
9930
- void import("child_process").then(({ spawn: spawn6 }) => {
9980
+ void import("child_process").then(({ spawn: spawn5 }) => {
9931
9981
  try {
9932
- const child = spawn6(process.execPath, process.argv.slice(1), {
9982
+ const child = spawn5(process.execPath, process.argv.slice(1), {
9933
9983
  stdio: "inherit",
9934
9984
  detached: false
9935
9985
  });
@@ -10092,10 +10142,15 @@ async function createCompanionRuntime(opts = {}) {
10092
10142
  const currentConfig = supervisor.currentConfig();
10093
10143
  const resolvedServerUrl = runtime === "opencode" ? await resolveOpencodeServerUrl(currentConfig.opencode?.serverUrl, serverUrl) : null;
10094
10144
  const candidate = runtime === "opencode" ? { ...currentConfig, opencode: { serverUrl: resolvedServerUrl ?? "" } } : currentConfig;
10095
- const verdict = await shakeOutHarness(runtime, candidate);
10096
- if (verdict === "absent") return { ok: false, error: absentLine(runtime) };
10145
+ const presence = runtime === "opencode" ? void 0 : await probeHarnessPresence(runtime);
10146
+ const verdict = await shakeOutHarness(runtime, candidate, {
10147
+ ...presence ? { presence } : {}
10148
+ });
10149
+ if (verdict === "absent" || verdict === "unusable") {
10150
+ return { ok: false, error: presenceRefusal(runtime, verdict, CLI_PRESENCE_ACTION) };
10151
+ }
10097
10152
  const result = await supervisor.enableHarness(
10098
- runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime }
10153
+ runtime === "opencode" ? { runtime: "opencode", serverUrl: resolvedServerUrl ?? "" } : { runtime, presence }
10099
10154
  );
10100
10155
  if (!result.ok) return { ok: false, error: result.error };
10101
10156
  return { ok: true, message: connectedLine(runtime, verdict) };
@@ -10160,7 +10215,7 @@ async function closeSurfaces(control, dashboard) {
10160
10215
  }
10161
10216
 
10162
10217
  // src/commands/daemon.ts
10163
- import { spawn as spawn5 } from "child_process";
10218
+ import { spawn as spawn4 } from "child_process";
10164
10219
  import { closeSync as closeSync3, mkdirSync as mkdirSync13, openSync as openSync3 } from "fs";
10165
10220
 
10166
10221
  // src/cli-entry.ts
@@ -10267,7 +10322,7 @@ function defaultSpawnDetached(args) {
10267
10322
  mkdirSync13(cabaneDir(), { recursive: true });
10268
10323
  const logFd = openSync3(companionLogPath(), "a");
10269
10324
  try {
10270
- return spawn5(process.execPath, [cliPath, ...args], {
10325
+ return spawn4(process.execPath, [cliPath, ...args], {
10271
10326
  detached: true,
10272
10327
  stdio: ["ignore", logFd, logFd],
10273
10328
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -10365,14 +10420,15 @@ function reportAlreadyRunning(pid) {
10365
10420
  write("Connect a harness to the running companion: cabane-companion connect claude-code");
10366
10421
  }
10367
10422
  async function collectHarnessChoices(interactive) {
10368
- const [claudePresent, claudeVersion, codexVersion, opencodeVersion] = await Promise.all([
10369
- claudeOnPath().catch(() => false),
10370
- probeCliVersion("claude").catch(() => null),
10371
- probeCliVersion("codex").catch(() => null),
10423
+ const [claudePresence, codexPresence, opencodeVersion] = await Promise.all([
10424
+ probeHarnessPresence("claude-code").catch(() => ({ status: "absent" })),
10425
+ probeHarnessPresence("codex").catch(() => ({ status: "absent" })),
10372
10426
  probeDefaultOpencodeVersion()
10373
10427
  ]);
10428
+ const claudeVersion = claudePresence.status === "present" ? claudePresence.version : null;
10429
+ const codexVersion = codexPresence.status === "present" ? codexPresence.version : null;
10374
10430
  const found = [
10375
- ...claudePresent ? [{ runtime: "claude-code", version: claudeVersion }] : [],
10431
+ ...claudeVersion ? [{ runtime: "claude-code", version: claudeVersion }] : [],
10376
10432
  ...codexVersion ? [{ runtime: "codex", version: codexVersion }] : [],
10377
10433
  ...opencodeVersion ? [
10378
10434
  {