baychat 0.34.0 → 0.34.2

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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -2
  3. package/dist/bays-command.js +6 -0
  4. package/dist/channel/feed.js +48 -4
  5. package/dist/channel/log-file.js +37 -0
  6. package/dist/channel/server.js +44 -3
  7. package/dist/claude-context-flush.js +31 -8
  8. package/dist/claude-context.js +19 -5
  9. package/dist/claude-onboarding.js +23 -10
  10. package/dist/cli-help.js +3 -0
  11. package/dist/client-paths.js +6 -1
  12. package/dist/client-scope.js +34 -0
  13. package/dist/codex-onboarding.js +2 -2
  14. package/dist/commands.js +16 -8
  15. package/dist/launch/command.js +6 -3
  16. package/dist/main.js +14 -1
  17. package/dist/mcp-files.js +2 -0
  18. package/dist/mcp-register.js +2 -0
  19. package/dist/protocol-content.js +1 -1
  20. package/dist/refresh-clients.js +7 -0
  21. package/dist/relay/acp/approval.js +99 -30
  22. package/dist/relay/acp/daemon-glue.js +2 -1
  23. package/dist/relay/adapters.js +5 -1
  24. package/dist/relay/bay-relays.js +18 -5
  25. package/dist/relay/codex-allowances.js +113 -0
  26. package/dist/relay/codex-app-server.js +637 -157
  27. package/dist/relay/codex-approvals.js +390 -0
  28. package/dist/relay/codex-commands.js +61 -0
  29. package/dist/relay/codex-log.js +260 -0
  30. package/dist/relay/codex-runner.js +505 -0
  31. package/dist/relay/codex-version.js +50 -0
  32. package/dist/relay/daemon.js +190 -6
  33. package/dist/relay/fresh-start.js +2 -1
  34. package/dist/relay/launcher/capabilities.js +24 -9
  35. package/dist/relay/launcher/codex-launch.js +9 -2
  36. package/dist/relay/launcher/codex-relay-launch.js +80 -0
  37. package/dist/relay/launcher/handle.js +101 -30
  38. package/dist/relay/launcher/loop.js +5 -1
  39. package/dist/relay/launcher/stop-registered.js +124 -9
  40. package/dist/relay/registry.js +30 -0
  41. package/dist/relay/runtime-limits.js +167 -0
  42. package/dist/relay/spawn-env.js +40 -3
  43. package/dist/relay/terminal-pane.js +1 -0
  44. package/dist/runtimes.js +4 -3
  45. package/dist/session-command.js +7 -4
  46. package/dist/skill-bootstrap.js +2 -0
  47. package/dist/tool-defs.js +30 -0
  48. package/package.json +1 -1
@@ -39,8 +39,10 @@ exports.commandTypedReply = commandTypedReply;
39
39
  exports.splitSessionCommands = splitSessionCommands;
40
40
  const child_process_1 = require("child_process");
41
41
  const fs = __importStar(require("fs"));
42
+ const os = __importStar(require("os"));
42
43
  const net = __importStar(require("net"));
43
44
  const path = __importStar(require("path"));
45
+ const api_1 = require("../api");
44
46
  const config_1 = require("../config");
45
47
  const bay_deleted_1 = require("../bay-deleted");
46
48
  const context_report_1 = require("../context-report");
@@ -58,11 +60,15 @@ const daemon_glue_1 = require("./acp/daemon-glue");
58
60
  const presence_1 = require("./acp/presence");
59
61
  const runner_1 = require("./acp/runner");
60
62
  const turn_queue_1 = require("./acp/turn-queue");
63
+ const codex_commands_1 = require("./codex-commands");
64
+ const codex_runner_1 = require("./codex-runner");
61
65
  const session_reply_1 = require("./session-reply");
62
66
  const channel_sessions_1 = require("./channel-sessions");
63
67
  const runtime_binary_1 = require("../runtime-binary");
64
68
  const adapters_1 = require("./adapters");
65
69
  const parent_watch_1 = require("./parent-watch");
70
+ const codex_app_server_1 = require("./codex-app-server");
71
+ const runtime_limits_1 = require("./runtime-limits");
66
72
  const queue_1 = require("./queue");
67
73
  const held_1 = require("./held");
68
74
  const provider_env_1 = require("./provider-env");
@@ -79,6 +85,10 @@ const loop_1 = require("./launcher/loop");
79
85
  const log_line_1 = require("./log-line");
80
86
  const handle_1 = require("./launcher/handle");
81
87
  const multiplexer_1 = require("./launcher/multiplexer");
88
+ const codex_relay_launch_1 = require("./launcher/codex-relay-launch");
89
+ const relay_identity_1 = require("./launcher/relay-identity");
90
+ const spawn_env_1 = require("./spawn-env");
91
+ const codex_version_1 = require("./codex-version");
82
92
  const approval_settings_1 = require("./launcher/approval-settings");
83
93
  /**
84
94
  * How long a failed resume-id discovery is believed before it is retried.
@@ -94,6 +104,15 @@ class RelayDaemon {
94
104
  registry = new registry_1.SessionRegistry();
95
105
  acpQueue = new turn_queue_1.AcpTurnQueue();
96
106
  acp;
107
+ /** Codex sessions the relay runs itself over `codex app-server`, approvals as cards (codex-runner.ts). */
108
+ codexQueue = (0, codex_runner_1.codexTurnQueue)();
109
+ codex;
110
+ /** Relay-run Codex sessions already told, this relay run, that exec mode keeps them silent. */
111
+ codexFallbackSaid = new Set();
112
+ codexAsk;
113
+ codexEntryConfigured;
114
+ /** The app-server version gate (codex-version.ts); a seam so tests never run `codex --version`. */
115
+ codexReady;
97
116
  acpAlive;
98
117
  /** The device MCP connection the relay joins its own (ACP) sessions with (tests inject one). */
99
118
  acpCallerFn;
@@ -101,6 +120,8 @@ class RelayDaemon {
101
120
  context;
102
121
  /** Reads each Codex session's own rollout for its context numbers. */
103
122
  codexContext;
123
+ /** The Codex account's plan limits, every 10 minutes (TERMINAL_CREDITS.md). */
124
+ codexLimits;
104
125
  /** The room each session was last woken in — where its context numbers belong. */
105
126
  lastRoom = new Map();
106
127
  /** Project briefs whose own message was a session command, waiting for the next chat message. */
@@ -231,6 +252,8 @@ class RelayDaemon {
231
252
  if (relayRun)
232
253
  return this.log(`mailbox registration ignored: ${relayRun}`);
233
254
  this.registry.upsert((0, mailbox_1.registrationToTarget)(reg));
255
+ // A registration starts over: nothing allowed "for this session" before it carries on.
256
+ this.codex.forgetAllowances(reg.session);
234
257
  // A session that names itself supersedes anything discovery guessed,
235
258
  // exactly as an attach frame does.
236
259
  if (reg.resumeId)
@@ -284,6 +307,20 @@ class RelayDaemon {
284
307
  conversationFor: (session) => this.lastRoom.get(session),
285
308
  log: (line) => this.log(line),
286
309
  });
310
+ this.codexLimits = new runtime_limits_1.CodexLimitsPoller({
311
+ sessions: () => this.registry
312
+ .all()
313
+ .filter((t) => t.runtime === "codex" && (t.attached || this.liveSessions.includes(t.name)))
314
+ .map((t) => t.name),
315
+ binary: () => {
316
+ const codex = this.binaryFor("codex");
317
+ return codex.ok ? codex.path : undefined;
318
+ },
319
+ read: (binaryPath) => (0, codex_app_server_1.readCodexRateLimits)(binaryPath, { spawn: child_process_1.spawn }),
320
+ auth: () => this.auth,
321
+ request: api_1.apiRequest,
322
+ log: (line) => this.log(line),
323
+ });
287
324
  this.acpCallerFn = opts.acpCaller ?? presence_1.deviceMcpCaller;
288
325
  this.acpAlive = (0, daemon_glue_1.acpKeepAliveScheduler)(() => (0, daemon_glue_1.keepAcpSessionsAlive)({ registry: this.registry, auth: this.auth, caller: opts.acpCaller, log: this.log }));
289
326
  this.acp = new runner_1.AcpSessionRunner({
@@ -303,6 +340,36 @@ class RelayDaemon {
303
340
  binaryVersion: opts.acpBinaryVersion,
304
341
  reportContext: (session, event) => this.reportContext(session, event),
305
342
  });
343
+ this.codexAsk = opts.codexAsk;
344
+ this.codexEntryConfigured = opts.codexEntryConfigured;
345
+ this.codexReady = opts.codexReady ?? ((bin) => (0, codex_version_1.codexAppServerReady)(bin));
346
+ this.codex = new codex_runner_1.CodexSessionRunner({
347
+ registry: this.registry,
348
+ queue: this.codexQueue,
349
+ auth: () => this.auth,
350
+ binary: () => {
351
+ const codex = this.binaryFor("codex");
352
+ return codex.ok ? { ok: true, path: codex.path } : { ok: false, reason: (0, runtime_binary_1.summarizeResolutionFailure)(codex) };
353
+ },
354
+ say: async (conversationId, session, text) => {
355
+ if (this.auth)
356
+ await (0, session_reply_1.postSessionReply)(this.auth, conversationId, session, text);
357
+ },
358
+ log: (line) => this.log(line),
359
+ onDelivered: (session, batch) => this.record({ kind: "woken", via: "codex", session }, session, batch),
360
+ onPending: (session, batch, reason) => this.record({ kind: "pending", session, reason }, session, batch),
361
+ spawn: child_process_1.spawn,
362
+ runTurn: opts.codexRunTurn,
363
+ runCompact: opts.codexRunCompact,
364
+ askPhoneChoice: opts.codexAsk,
365
+ caller: opts.acpCaller,
366
+ // This relay's Bay: `baychat`, or the `baychat-<bay>` a per-Bay relay was started for.
367
+ mcpEntry: (0, bay_relays_1.relayMcpEntry)(),
368
+ entryConfigured: opts.codexEntryConfigured,
369
+ ready: this.codexReady,
370
+ });
371
+ // Whatever the relay forgets about a session, its "Allow for this session" answers go with it.
372
+ this.registry.onForget = (name) => this.codex.forgetAllowances(name);
306
373
  }
307
374
  async start() {
308
375
  // The DEVICE credential, not an agent token. A laptop has one device login
@@ -327,6 +394,7 @@ class RelayDaemon {
327
394
  // handed what the previous one was holding rather than a clean slate.
328
395
  this.held.load();
329
396
  this.acpQueue.load();
397
+ this.codexQueue.load();
330
398
  // A clear that was being typed when the previous relay stopped: the session may well have been
331
399
  // cleared, so it is reported cleared-but-not-pointed (the server re-arms it), never left to
332
400
  // time out as "not cleared".
@@ -364,6 +432,7 @@ class RelayDaemon {
364
432
  // After the socket is ours: a relay refused as a duplicate must not keep sessions alive.
365
433
  this.acpKeepAlive = (0, daemon_glue_1.startAcpUpkeep)(this.acpAlive.run);
366
434
  this.codexContext.start();
435
+ this.codexLimits.start();
367
436
  if (!(0, bay_relays_1.isBayRelayChild)()) {
368
437
  // Never awaited and never fatal: the launcher retries its own network failures until stop().
369
438
  void this.startLauncherFn({
@@ -393,6 +462,29 @@ class RelayDaemon {
393
462
  stop: (name) => (0, acp_launch_1.stopAcpSession)(name, { registry: this.registry, abort: (n) => void this.acp.stop(n), log: (line) => this.log(line) }),
394
463
  running: () => (0, acp_launch_1.launchedAcpSessions)(this.registry),
395
464
  },
465
+ // Codex started from the app: run here over app-server, its approvals as cards.
466
+ codex: {
467
+ start: (launch) => (0, codex_relay_launch_1.launchCodexRelaySession)(launch, {
468
+ registry: this.registry,
469
+ log: (line) => this.log(line),
470
+ resolveBinary: this.resolveBinary,
471
+ caller: () => (this.auth ? this.acpCallerFn(this.auth) : undefined),
472
+ ready: this.codexReady,
473
+ viewer: {
474
+ multiplexer: (0, multiplexer_1.detectMultiplexer)(multiplexer_1.realRun),
475
+ run: multiplexer_1.realRun,
476
+ cli: (0, approval_settings_1.currentCliLaunch)(),
477
+ env: { ...(0, relay_identity_1.relayIdentityEnv)(), PATH: (0, spawn_env_1.headlessSpawnEnv)().PATH ?? "" },
478
+ },
479
+ }),
480
+ stop: (name) => (0, codex_relay_launch_1.stopCodexSession)(name, {
481
+ registry: this.registry,
482
+ end: (n) => this.codex.end(n),
483
+ log: (line) => this.log(line),
484
+ viewer: { multiplexer: (0, multiplexer_1.detectMultiplexer)(multiplexer_1.realRun), run: multiplexer_1.realRun },
485
+ }),
486
+ running: () => (0, codex_relay_launch_1.launchedCodexSessions)(this.registry),
487
+ },
396
488
  }).catch((err) => this.log(`launcher stopped: ${err instanceof Error ? err.message : String(err)}`));
397
489
  }
398
490
  this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
@@ -547,10 +639,13 @@ class RelayDaemon {
547
639
  if (command.id === "compact") {
548
640
  if (compact.refusal)
549
641
  return say(compact.refusal);
550
- if (target.acp) {
642
+ if (target.acp && !target.codex) {
551
643
  return say(`"${session}" is run by the relay and tidies its own memory; /compact cannot reach it. /new starts it fresh.`);
552
644
  }
553
645
  }
646
+ // A Codex the relay runs: /compact /stop /status /new /clear /exit mean what its runner can do.
647
+ if (await (0, codex_commands_1.runCodexSessionCommand)(this.codex, session, target, message, command, say))
648
+ return;
554
649
  // `/mode-*`, `/stop`, and what `/status` `/exit` `/clear` `/new` mean with no terminal.
555
650
  if (await (0, commands_1.runAcpSessionCommand)(this.acp, session, target, message, command, say))
556
651
  return;
@@ -691,8 +786,11 @@ class RelayDaemon {
691
786
  // running now is stopped and its messages wait behind the note (acp/runner.ts).
692
787
  done();
693
788
  this.forgetCarried(session);
694
- this.acp.freshStart(session, (0, fresh_start_1.freshStartMessage)(message, fresh), fresh.handoffId);
695
- this.log(`fresh start ${fresh.handoffId}: ${session} will start a new ACP session from its note`);
789
+ if (target.codex)
790
+ this.codex.freshStart(session, (0, fresh_start_1.freshStartMessage)(message, fresh), fresh.handoffId);
791
+ else
792
+ this.acp.freshStart(session, (0, fresh_start_1.freshStartMessage)(message, fresh), fresh.handoffId);
793
+ this.log(`fresh start ${fresh.handoffId}: ${session} will start a new ${target.codex ? "Codex thread" : "ACP session"} from its note`);
696
794
  await report({ handoffId: fresh.handoffId, status: "fresh" });
697
795
  return;
698
796
  }
@@ -884,7 +982,7 @@ class RelayDaemon {
884
982
  // carried it always precedes the session's first event, but checking a thread id reads
885
983
  // the disk. Wait for it rather than drop a message for a session we are registering.
886
984
  await this.serverRegistrations.get(session);
887
- const target = this.registry.get(session);
985
+ let target = this.registry.get(session);
888
986
  if (!target) {
889
987
  // NOT reported as pending, though an earlier version of this did.
890
988
  //
@@ -969,6 +1067,34 @@ class RelayDaemon {
969
1067
  this.acp.handle(session, batch);
970
1068
  return;
971
1069
  }
1070
+ if (target.codex) {
1071
+ if (!(0, codex_version_1.codexExecFallback)()) {
1072
+ // Codex run by the relay over app-server (codex-runner.ts): the same shape as ACP — filed,
1073
+ // and run by its own drain loop, so this lock is free again for a `/stop`.
1074
+ this.codex.handle(session, batch);
1075
+ return;
1076
+ }
1077
+ // BAYCHAT_CODEX_TRANSPORT=exec: a `codex exec` turn runs `--ask-for-approval never`, which
1078
+ // also blocks Codex's own BayChat replies — measured 2026-10-23, the turn ran and nothing
1079
+ // came back. So it is not run: the room is told once why, and the messages are held
1080
+ // (pending) for when the relay is back on app-server. A Codex at its keyboard (no
1081
+ // `target.codex`) keeps the old ladder.
1082
+ const reason = `Codex is in fallback mode on ${os.hostname()} (BAYCHAT_CODEX_TRANSPORT=exec) and can't answer here`;
1083
+ this.log(`codex: ${session} not woken — ${reason}`);
1084
+ if (this.auth && !this.codexFallbackSaid.has(session)) {
1085
+ this.codexFallbackSaid.add(session);
1086
+ for (const conversationId of new Set(batch.map((m) => m.conversationId))) {
1087
+ try {
1088
+ await (0, session_reply_1.postSessionReply)(this.auth, conversationId, session, `${reason}. Messages wait until the relay there runs without BAYCHAT_CODEX_TRANSPORT=exec.`);
1089
+ }
1090
+ catch (err) {
1091
+ this.log(`codex: could not tell ${conversationId} that ${session} is in fallback mode: ${errText(err)}`);
1092
+ }
1093
+ }
1094
+ }
1095
+ this.record({ kind: "pending", session, reason }, session, batch);
1096
+ return;
1097
+ }
972
1098
  const adapter = (0, adapters_1.adapterFor)(target.runtime);
973
1099
  // ORDERED ABOVE THE FIFO, deliberately.
974
1100
  //
@@ -990,7 +1116,7 @@ class RelayDaemon {
990
1116
  // write path: observed 2026-08-31, a headlessly-woken Codex read the room
991
1117
  // and was structurally unable to answer it.
992
1118
  const resolvedForQueue = await this.resolveResume(target);
993
- if (adapter.queueMessage && resolvedForQueue.resumeId) {
1119
+ if (adapter.queueMessage && resolvedForQueue.resumeId && !target.codex) {
994
1120
  // Same rule as the headless spawn: an ABSOLUTE runtimeBin came from the
995
1121
  // session itself and is never re-resolved; only a bare name is looked up.
996
1122
  const declared = resolvedForQueue.runtimeBin ?? target.runtime;
@@ -1282,10 +1408,14 @@ class RelayDaemon {
1282
1408
  ? (input) => this.runTurn({ runtime: target.runtime, ...input })
1283
1409
  : adapter.runTurn?.bind(adapter);
1284
1410
  if (richTransport) {
1411
+ // Only a Codex turn that can ask waits for the version probe; every other turn starts as
1412
+ // soon as it always did.
1413
+ const approvals = target.runtime === "codex" && this.auth && !(0, codex_version_1.codexExecFallback)() ? await this.headlessCodexApprovals(session, batch, target, executable) : {};
1285
1414
  const outcome = await richTransport({
1286
1415
  binaryPath: executable,
1287
1416
  target: resolved,
1288
1417
  prompt,
1418
+ ...approvals,
1289
1419
  });
1290
1420
  if (outcome.kind === "completed") {
1291
1421
  this.record({ kind: "woken", via: "headless", session, exitCode: 0 }, session, batch);
@@ -1295,6 +1425,13 @@ class RelayDaemon {
1295
1425
  this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
1296
1426
  return;
1297
1427
  }
1428
+ if (approvals.policy) {
1429
+ // This turn was given a policy — read only for a sender without command access, cards
1430
+ // for everyone else. `codex exec resume` can honour neither, and a turn that may already
1431
+ // have asked (and been answered) must not run a second time with other rules. Pending.
1432
+ this.record({ kind: "pending", session, reason: `Codex stopped before the turn finished (${outcome.reason}); not retried without its approval rules` }, session, batch);
1433
+ return;
1434
+ }
1298
1435
  this.log(`${target.runtime}: falling back to a plain spawn — ${outcome.reason}`);
1299
1436
  }
1300
1437
  // A Windows .cmd needs its interpreter named explicitly, whatever produced
@@ -1336,6 +1473,48 @@ class RelayDaemon {
1336
1473
  // `.finally`, which owns it for exactly as long as this turn runs.
1337
1474
  }
1338
1475
  }
1476
+ /**
1477
+ * A terminal-registered Codex woken headlessly (`thread/resume` while its terminal is closed)
1478
+ * asks its approvals the way a relay-run one does (codex-runner.ts): the owner (or a grantee)
1479
+ * woke it → `on-request` in a no-network workspace sandbox, every approval a card in the room
1480
+ * that woke it; anyone else → read only, no cards. Nothing for another runtime, or when this
1481
+ * relay is not logged in (the turn then runs `never`, as it always did).
1482
+ */
1483
+ async headlessCodexApprovals(session, batch, target, executable) {
1484
+ const auth = this.auth;
1485
+ if (target.runtime !== "codex" || !auth || (0, codex_version_1.codexExecFallback)())
1486
+ return {};
1487
+ // A Codex too old for the 0.160 approval protocol keeps the turn it always had: `never`.
1488
+ const ready = await this.codexReady(executable);
1489
+ if (!ready.ok) {
1490
+ this.log(`codex: ${session} headless turn runs without cards — ${ready.reason}`);
1491
+ return {};
1492
+ }
1493
+ const entry = (0, bay_relays_1.relayMcpEntry)();
1494
+ const { allAuthorized, policy } = (0, codex_runner_1.codexTurnPolicyFor)(batch, entry, this.codexEntryConfigured);
1495
+ return {
1496
+ policy,
1497
+ onApproval: (0, codex_runner_1.codexApprovalHandler)({
1498
+ auth,
1499
+ session,
1500
+ room: batch.at(-1).conversationId,
1501
+ allAuthorized,
1502
+ entry,
1503
+ host: os.hostname(),
1504
+ // Keyed by the thread being resumed: another thread under this name starts with nothing.
1505
+ allow: this.codex.allowListFor(session, target.resumeId ?? ""),
1506
+ cwd: target.resumeCwd ?? target.cwd,
1507
+ log: (line) => this.log(line),
1508
+ ask: this.codexAsk,
1509
+ }),
1510
+ onNotification: (method, params) => {
1511
+ const dropped = this.codex.noteNotification(session, method, params);
1512
+ if (dropped > 0)
1513
+ this.log(`codex: ${session} files changed under ${dropped} command(s) allowed for this session — they will be asked again`);
1514
+ },
1515
+ log: (line) => this.log(`codex: ${session} ${line}`),
1516
+ };
1517
+ }
1339
1518
  /**
1340
1519
  * The proven executable for a runtime, resolved at most once per process
1341
1520
  * until something gives us reason to doubt it.
@@ -1434,6 +1613,8 @@ class RelayDaemon {
1434
1613
  * displaces any socket holding the name, exactly as a newer local attach would.
1435
1614
  */
1436
1615
  recordAttach(frame, sock) {
1616
+ // A (re-)registration starts over: nothing allowed "for this session" before it carries on.
1617
+ this.codex.forgetAllowances(frame.session);
1437
1618
  this.registry.upsert({
1438
1619
  name: frame.session,
1439
1620
  runtime: frame.runtime,
@@ -1752,7 +1933,7 @@ class RelayDaemon {
1752
1933
  return {
1753
1934
  deliveries: this.queue.activeSessions(),
1754
1935
  headless: [...this.headlessInFlight],
1755
- acp: this.acp.activeSessions(),
1936
+ acp: [...this.acp.activeSessions(), ...this.codex.activeSessions()],
1756
1937
  heldAt: this.held.summary().map((h) => h.heldAt),
1757
1938
  launching: this.launching,
1758
1939
  lastActivityAt: this.lastActivityAt,
@@ -1771,8 +1952,10 @@ class RelayDaemon {
1771
1952
  clearInterval(this.acpKeepAlive);
1772
1953
  this.launcherAbort.abort();
1773
1954
  this.codexContext.stop();
1955
+ this.codexLimits.stop();
1774
1956
  this.context.close();
1775
1957
  this.acp.shutdown();
1958
+ this.codex.shutdown();
1776
1959
  this.abort.abort();
1777
1960
  for (const sock of this.attached.values())
1778
1961
  sock.destroy();
@@ -1782,6 +1965,7 @@ class RelayDaemon {
1782
1965
  this.channels.clear();
1783
1966
  await this.queue.idle();
1784
1967
  await this.acp.idle();
1968
+ await this.codex.idle();
1785
1969
  for (const w of this.mailboxWatchers)
1786
1970
  w.stop();
1787
1971
  await new Promise((resolve) => this.server ? this.server.close(() => resolve()) : resolve());
@@ -72,7 +72,8 @@ function parseFreshStart(message) {
72
72
  }
73
73
  /** How this relay would clear this session — decided BEFORE the note is asked for. */
74
74
  function freshStartPlan(target) {
75
- if (target.acp)
75
+ // Run by the relay (ACP, or Codex over app-server): no terminal — the runner starts it fresh.
76
+ if (target.acp || target.codex)
76
77
  return { kind: "acp" };
77
78
  if (target.runtime === "claude" || target.runtime === "codex") {
78
79
  if (!target.pane)
@@ -47,25 +47,40 @@ const MACHINE_MAX = 64;
47
47
  const VERSION_MAX = 64;
48
48
  /**
49
49
  * Build the capabilities. A missing policy offers no spots; a spot whose runtimes are all
50
- * missing is left out entirely, since the app could not start anything there.
50
+ * missing is not offered — the app could not start anything there — and `onUnavailable` hears
51
+ * which one and why.
51
52
  */
52
53
  function buildCapabilities(policy, deps) {
53
54
  const resolved = new Map();
54
- const versionOf = (runtime) => {
55
- if (!resolved.has(runtime)) {
56
- const r = deps.resolveBinary(exports.RUNTIME_BINARY[runtime]);
57
- resolved.set(runtime, r.ok ? r.version.split("\n")[0].trim().slice(0, VERSION_MAX) || null : undefined);
55
+ const resolutionOf = (runtime) => {
56
+ let r = resolved.get(runtime);
57
+ if (!r) {
58
+ r = deps.resolveBinary(exports.RUNTIME_BINARY[runtime]);
59
+ resolved.set(runtime, r);
58
60
  }
59
- return resolved.get(runtime);
61
+ return r;
60
62
  };
61
63
  const spots = [];
62
64
  for (const spot of policy?.spots ?? []) {
63
65
  const runtimes = spot.runtimes.flatMap((name) => {
64
- const version = versionOf(name);
65
- return version === undefined ? [] : [{ name, version }];
66
+ const r = resolutionOf(name);
67
+ return r.ok ? [{ name, version: r.version.split("\n")[0].trim().slice(0, VERSION_MAX) || null }] : [];
66
68
  });
67
- if (runtimes.length > 0)
69
+ if (runtimes.length > 0) {
68
70
  spots.push({ id: spot.id, label: spot.label, runtimes });
71
+ continue;
72
+ }
73
+ const runtime = spot.runtimes[0];
74
+ if (!runtime)
75
+ continue;
76
+ const r = resolutionOf(runtime);
77
+ const missing = {
78
+ id: spot.id,
79
+ label: spot.label,
80
+ runtime,
81
+ reason: !r.ok && r.rejected.length > 0 ? "RUNTIME_BROKEN" : "RUNTIME_NOT_FOUND",
82
+ };
83
+ deps.onUnavailable?.(missing, r.ok ? "" : r.reason);
69
84
  }
70
85
  return {
71
86
  machine: (deps.hostname ?? os.hostname()).replace(/\.local$/, "").slice(0, MACHINE_MAX) || "computer",
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CODEX_ROOM_ID_RE = exports.CODEX_UNATTENDED_FLAGS = void 0;
37
37
  exports.codexConfigPath = codexConfigPath;
38
+ exports.codexConfigHasEntry = codexConfigHasEntry;
38
39
  exports.codexLaunchProblem = codexLaunchProblem;
39
40
  exports.readCodexConfig = readCodexConfig;
40
41
  const fs = __importStar(require("fs"));
@@ -81,12 +82,18 @@ function escapeRe(text) {
81
82
  * as text: two table headers and one key are all it needs, and a TOML parser would be a new
82
83
  * dependency for that.
83
84
  */
85
+ /** Does Codex's config.toml have an `[mcp_servers.<entry>]` table? A config override for an entry
86
+ * that is not there would CREATE a partial one — never do that. */
87
+ function codexConfigHasEntry(config, entry) {
88
+ if (!config)
89
+ return false;
90
+ return new RegExp(`^\\s*\\[mcp_servers\\.(?:${escapeRe(entry)}|"${escapeRe(entry)}")\\]\\s*$`, "m").test(config);
91
+ }
84
92
  function codexLaunchProblem(folder, readConfig,
85
93
  /** The MCP connection the prompt names (`baychat`, or `baychat-<bay>` on a computer with several). */
86
94
  entry = "baychat") {
87
95
  const config = readConfig();
88
- const server = new RegExp(`^\\s*\\[mcp_servers\\.(?:${escapeRe(entry)}|"${escapeRe(entry)}")\\]\\s*$`, "m");
89
- if (!config || !server.test(config))
96
+ if (!config || !codexConfigHasEntry(config, entry))
90
97
  return "RUNTIME_NOT_CONNECTED";
91
98
  // `[projects."/path"]` (or single-quoted) followed, before the next table, by trust_level = "trusted".
92
99
  const table = new RegExp(`^\\s*\\[projects\\.(?:"${escapeRe(folder)}"|'${escapeRe(folder)}')\\]\\s*$([^\\[]*)`, "m");
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.codexViewerArgv = codexViewerArgv;
4
+ exports.launchCodexRelaySession = launchCodexRelaySession;
5
+ exports.launchedCodexSessions = launchedCodexSessions;
6
+ exports.stopCodexSession = stopCodexSession;
7
+ const presence_1 = require("../acp/presence");
8
+ const multiplexer_launch_1 = require("../../multiplexer-launch");
9
+ const argv_1 = require("./argv");
10
+ const multiplexer_1 = require("./multiplexer");
11
+ const refused = (reason) => ({ status: "REFUSED", reason });
12
+ /** The viewer's command: our own CLI, `relay log <name> -f`, as argv for a detached tmux session. */
13
+ function codexViewerArgv(session, folder, cli, env) {
14
+ return (0, multiplexer_launch_1.multiplexerArgv)("tmux", { kind: "detached", name: (0, argv_1.multiplexerSessionName)(session), cwd: folder }, { binary: cli.node, args: [cli.entry, "relay", "log", session, "-f"], env });
15
+ }
16
+ async function launchCodexRelaySession(launch, deps) {
17
+ if (deps.registry.get(launch.session)) {
18
+ deps.log(`launch ${launch.launchId}: refused, "${launch.session}" is already a session on this computer`);
19
+ return refused(deps.registry.get(launch.session)?.codex ? "NAME_TAKEN" : "NAME_TAKEN_FOREIGN");
20
+ }
21
+ const viewer = deps.viewer;
22
+ if (viewer?.multiplexer === "tmux" && (0, multiplexer_1.launchSessionExists)("tmux", launch.session, viewer.run)) {
23
+ deps.log(`launch ${launch.launchId}: refused, a tmux session ${(0, argv_1.multiplexerSessionName)(launch.session)} is already running here`);
24
+ return refused("NAME_TAKEN_FOREIGN");
25
+ }
26
+ const binary = deps.resolveBinary("codex");
27
+ if (!binary.ok)
28
+ return refused("RUNTIME_MISSING");
29
+ const ready = await deps.ready?.(binary.path);
30
+ if (ready && !ready.ok) {
31
+ deps.log(`launch ${launch.launchId}: refused, ${ready.reason}`);
32
+ return refused("RUNTIME_TOO_OLD");
33
+ }
34
+ const caller = deps.caller();
35
+ if (!caller)
36
+ return refused("BAY_NOT_CONNECTED");
37
+ const room = !launch.join || "direct" in launch.join ? undefined : launch.join;
38
+ try {
39
+ await (0, presence_1.joinSession)(caller, launch.session, room);
40
+ }
41
+ catch (err) {
42
+ deps.log(`launch ${launch.launchId}: ${launch.session} could not join BayChat (${err instanceof Error ? err.message : String(err)})`);
43
+ return { status: "FAILED", reason: "JOIN_FAILED" };
44
+ }
45
+ deps.registry.upsert({
46
+ name: launch.session,
47
+ runtime: "codex",
48
+ runtimeBin: binary.path,
49
+ relayOwned: true,
50
+ launchedBy: launch.launchId,
51
+ // No thread yet: the first turn starts one (see the file header).
52
+ codex: { cwd: launch.folder },
53
+ });
54
+ if (viewer?.multiplexer === "tmux") {
55
+ const res = viewer.run("tmux", codexViewerArgv(launch.session, launch.folder, viewer.cli, viewer.env), { cwd: launch.folder, env: viewer.env });
56
+ if (res.error || res.status !== 0)
57
+ deps.log(`launch ${launch.launchId}: no viewer for "${launch.session}" (${res.error?.message ?? (res.stderr.trim() || `exit ${res.status}`)}); \`baychat relay log ${launch.session} -f\` shows it`);
58
+ }
59
+ deps.log(`launch ${launch.launchId}: started "${launch.session}" (Codex, run by the relay, approvals on the phone) in ${launch.folder}; its first message starts its Codex thread`);
60
+ return { status: "STARTED" };
61
+ }
62
+ /** The relay-run Codex sessions the app started that are still registered, for the launch caps. */
63
+ function launchedCodexSessions(registry) {
64
+ return registry.all().flatMap((t) => (t.relayOwned && t.codex && t.launchedBy ? [{ name: t.name, cwd: t.codex.cwd }] : []));
65
+ }
66
+ /**
67
+ * End on a relay-run Codex the app started: stop its turn, forget it (so the keep-alive never
68
+ * brings it back) and close its viewer. One registered any other way is not ours to end.
69
+ */
70
+ function stopCodexSession(name, deps) {
71
+ const target = deps.registry.get(name);
72
+ if (!target?.relayOwned || !target.codex || !target.launchedBy)
73
+ return false;
74
+ deps.end(name);
75
+ deps.registry.remove(name);
76
+ if (deps.viewer?.multiplexer === "tmux" && (0, multiplexer_1.launchSessionExists)("tmux", name, deps.viewer.run))
77
+ (0, multiplexer_1.killLaunchSession)("tmux", name, deps.viewer.run);
78
+ deps.log(`codex: ${name} ended from BayChat; the relay no longer runs it`);
79
+ return true;
80
+ }