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
@@ -14,6 +14,7 @@ const argv_1 = require("./argv");
14
14
  const approval_settings_1 = require("./approval-settings");
15
15
  const capabilities_1 = require("./capabilities");
16
16
  const codex_launch_1 = require("./codex-launch");
17
+ const codex_version_1 = require("../codex-version");
17
18
  const dev_channel_screen_1 = require("./dev-channel-screen");
18
19
  const stop_registered_1 = require("./stop-registered");
19
20
  const multiplexer_1 = require("./multiplexer");
@@ -40,6 +41,15 @@ async function handleLaunch(launch, deps, io) {
40
41
  return stopSession(launch, deps);
41
42
  if ((0, acp_launch_1.isAcpLaunchRuntime)(launch.runtime))
42
43
  return startAcp(launch, deps);
44
+ if (launch.runtime === "codex" && deps.codex && (0, codex_version_1.codexExecFallback)() && inDefaultBay(launch, deps)) {
45
+ // BAYCHAT_CODEX_TRANSPORT=exec: its turns run `--ask-for-approval never`, which also blocks
46
+ // Codex's own BayChat replies — a session started from the phone would never answer. Refused
47
+ // instead of started silent (2026-10-23); a Codex at this keyboard works as before.
48
+ deps.log(`launch ${launch.id}: refused, Codex is in fallback mode here (BAYCHAT_CODEX_TRANSPORT=exec) and could not answer in BayChat`);
49
+ return refused("RUNTIME_FALLBACK_MODE");
50
+ }
51
+ if (launch.runtime === "codex" && relayRunsCodex(launch, deps))
52
+ return startCodexRelay(launch, deps);
43
53
  const started = startSession(launch, deps);
44
54
  if ("status" in started)
45
55
  return started;
@@ -64,6 +74,77 @@ function spotFor(launch, deps) {
64
74
  }
65
75
  return { spot, folder: folder.path };
66
76
  }
77
+ /**
78
+ * Sessions the relay runs itself (dsh, Cursor, Codex over app-server) and the terminal sessions
79
+ * the launcher sees, for the caps — a relay-run Codex's log viewer is a `bc-<name>` tmux session
80
+ * too, and must not be counted twice.
81
+ */
82
+ function sessionsForCaps(live, deps) {
83
+ const relayRun = [...(deps.acp?.running() ?? []), ...(deps.codex?.running() ?? [])];
84
+ const names = new Set(relayRun.map((a) => a.name));
85
+ return { terminals: live.filter((name) => !names.has(name)), relayRun };
86
+ }
87
+ /** A Codex launch is run by the relay when the daemon offers that, and the launch is in its default Bay. */
88
+ function relayRunsCodex(launch, deps) {
89
+ if (!deps.codex || (0, codex_version_1.codexExecFallback)())
90
+ return false;
91
+ return inDefaultBay(launch, deps);
92
+ }
93
+ /**
94
+ * The relay runs, keeps alive and asks cards for its sessions with its DEFAULT Bay's login; a
95
+ * Codex launched into another Bay keeps the terminal launch, which joins with that Bay's entry.
96
+ */
97
+ function inDefaultBay(launch, deps) {
98
+ const defaultBay = deps.loadBays().find((b) => b.isDefault);
99
+ return !launch.bay || !defaultBay || launch.bay.id === defaultBay.bayId;
100
+ }
101
+ /** The checks a Codex START makes, whoever runs it; undefined when it may start. */
102
+ function codexRefusal(launch, deps, spot, folder, entry) {
103
+ // Codex reads only its own config.toml: a relay on a setup of its own has no connection to
104
+ // give it there (a token in its argv would show in `ps`), so that case is not offered yet.
105
+ if (deps.ownServer) {
106
+ deps.log(`launch ${launch.id}: refused, Codex cannot use this relay's own connection yet`);
107
+ return refused("RUNTIME_NOT_YET");
108
+ }
109
+ if (!spot.unattendedCodex) {
110
+ deps.log(`launch ${launch.id}: refused, Codex may not start unattended in "${spot.label}". Run \`baychat launch add ${folder} --runtime ${spot.runtimes.join(",")} --unattended\` here to allow it.`);
111
+ return refused("UNATTENDED_NOT_ALLOWED");
112
+ }
113
+ if (launch.join && "group" in launch.join && !codex_launch_1.CODEX_ROOM_ID_RE.test(launch.join.group)) {
114
+ deps.log(`launch ${launch.id}: refused, Codex is started into an existing group only (by id)`);
115
+ return refused("ROOM_EXISTING_ONLY");
116
+ }
117
+ const problem = (0, codex_launch_1.codexLaunchProblem)(folder, () => deps.readCodexConfig?.() ?? null, entry ?? bays_1.DEFAULT_MCP_ENTRY);
118
+ if (problem) {
119
+ deps.log(`launch ${launch.id}: refused, ${problem === "RUNTIME_NOT_CONNECTED" ? "Codex has no BayChat connection here. Run `baychat connect codex`." : `Codex has not trusted ${folder}. Open Codex there once and trust it.`}`);
120
+ return refused(problem);
121
+ }
122
+ return undefined;
123
+ }
124
+ /** Codex run by the relay (codex-relay-launch.ts): the same checks and caps, no terminal of its own. */
125
+ async function startCodexRelay(launch, deps) {
126
+ const place = spotFor(launch, deps);
127
+ if ("status" in place)
128
+ return place;
129
+ const bay = launchBayEntry(launch.bay, deps.loadBays());
130
+ if (!bay) {
131
+ deps.log(`launch ${launch.id}: refused, this computer is not logged in to the Bay "${launch.bay?.name ?? ""}". Run \`baychat login\` here.`);
132
+ return refused("BAY_NOT_CONNECTED");
133
+ }
134
+ const problem = codexRefusal(launch, deps, place.spot, place.folder, bay.entry);
135
+ if (problem)
136
+ return problem;
137
+ const multiplexer = deps.detectMultiplexer();
138
+ const live = multiplexer ? (0, multiplexer_1.liveLaunchSessions)(multiplexer, deps.run) : [];
139
+ const { terminals, relayRun } = sessionsForCaps(live, deps);
140
+ const started = multiplexer ? pruneEnded((0, started_1.loadStarted)((0, started_1.startedRecordPath)(deps.launchDir)), live, deps) : [];
141
+ const inFolder = started.filter((s) => s.spotId === place.spot.id).length + relayRun.filter((a) => a.cwd === place.folder).length;
142
+ if (terminals.length + relayRun.length >= policy_1.MAX_SESSIONS_PER_COMPUTER || inFolder >= place.spot.maxSessions) {
143
+ deps.log(`launch ${launch.id}: refused, too many sessions running (${terminals.length + relayRun.length} here, the folder "${place.spot.label}" allows ${place.spot.maxSessions})`);
144
+ return refused("CAP_REACHED");
145
+ }
146
+ return deps.codex.start({ launchId: launch.id, session: launch.sessionName, folder: place.folder, join: launch.join });
147
+ }
67
148
  /** A relay-run agent (dsh, Cursor): no terminal — the daemon registers it and runs it per turn. */
68
149
  async function startAcp(launch, deps) {
69
150
  const place = spotFor(launch, deps);
@@ -84,12 +165,12 @@ async function startAcp(launch, deps) {
84
165
  // The same caps as a terminal session: per computer, and per folder.
85
166
  const multiplexer = deps.detectMultiplexer();
86
167
  const live = multiplexer ? (0, multiplexer_1.liveLaunchSessions)(multiplexer, deps.run) : [];
87
- const agents = deps.acp.running();
168
+ const { terminals, relayRun: agents } = sessionsForCaps(live, deps);
88
169
  // Ended terminal sessions are cleared first, as a terminal START does, so they never count.
89
170
  const started = multiplexer ? pruneEnded((0, started_1.loadStarted)((0, started_1.startedRecordPath)(deps.launchDir)), live, deps) : [];
90
171
  const inFolder = started.filter((s) => s.spotId === place.spot.id).length + agents.filter((a) => a.cwd === place.folder).length;
91
- if (live.length + agents.length >= policy_1.MAX_SESSIONS_PER_COMPUTER || inFolder >= place.spot.maxSessions) {
92
- deps.log(`launch ${launch.id}: refused, too many sessions running (${live.length + agents.length} here, the folder "${place.spot.label}" allows ${place.spot.maxSessions})`);
172
+ if (terminals.length + agents.length >= policy_1.MAX_SESSIONS_PER_COMPUTER || inFolder >= place.spot.maxSessions) {
173
+ deps.log(`launch ${launch.id}: refused, too many sessions running (${terminals.length + agents.length} here, the folder "${place.spot.label}" allows ${place.spot.maxSessions})`);
93
174
  return refused("CAP_REACHED");
94
175
  }
95
176
  return deps.acp.start({ launchId: launch.id, session: launch.sessionName, runtime: launch.runtime, folder: place.folder, join: launch.join });
@@ -110,25 +191,9 @@ function startSession(launch, deps) {
110
191
  return refused("BAY_NOT_CONNECTED");
111
192
  }
112
193
  if (codex) {
113
- // Codex reads only its own config.toml: a relay on a setup of its own has no connection to
114
- // give it there (a token in its argv would show in `ps`), so that case is not offered yet.
115
- if (deps.ownServer) {
116
- deps.log(`launch ${launch.id}: refused, Codex cannot use this relay's own connection yet`);
117
- return refused("RUNTIME_NOT_YET");
118
- }
119
- if (!spot.unattendedCodex) {
120
- deps.log(`launch ${launch.id}: refused, Codex may not start unattended in "${spot.label}". Run \`baychat launch add ${folder.path} --runtime ${spot.runtimes.join(",")} --unattended\` here to allow it.`);
121
- return refused("UNATTENDED_NOT_ALLOWED");
122
- }
123
- if (launch.join && "group" in launch.join && !codex_launch_1.CODEX_ROOM_ID_RE.test(launch.join.group)) {
124
- deps.log(`launch ${launch.id}: refused, Codex is started into an existing group only (by id)`);
125
- return refused("ROOM_EXISTING_ONLY");
126
- }
127
- const problem = (0, codex_launch_1.codexLaunchProblem)(folder.path, () => deps.readCodexConfig?.() ?? null, bay.entry ?? bays_1.DEFAULT_MCP_ENTRY);
128
- if (problem) {
129
- deps.log(`launch ${launch.id}: refused, ${problem === "RUNTIME_NOT_CONNECTED" ? "Codex has no BayChat connection here. Run `baychat connect codex`." : `Codex has not trusted ${folder.path}. Open Codex there once and trust it.`}`);
130
- return refused(problem);
131
- }
194
+ const problem = codexRefusal(launch, deps, spot, folder.path, bay.entry);
195
+ if (problem)
196
+ return problem;
132
197
  }
133
198
  else if (!deps.isFolderTrusted(folder.path)) {
134
199
  // An untrusted folder would leave claude waiting at "Is this a project you trust?" forever,
@@ -153,9 +218,9 @@ function startSession(launch, deps) {
153
218
  // code of its own and the app offers no Replace it (Mac QA 2026-10-16: a loop otherwise).
154
219
  return refused(own ? "NAME_TAKEN" : "NAME_TAKEN_FOREIGN");
155
220
  }
156
- // Relay-run agents (dsh, Cursor) count too: the caps are per computer and per folder, whatever runs.
157
- const agents = deps.acp?.running() ?? [];
158
- if (live.length + agents.length >= policy_1.MAX_SESSIONS_PER_COMPUTER ||
221
+ // Relay-run agents (dsh, Cursor, Codex) count too: the caps are per computer and per folder, whatever runs.
222
+ const { terminals, relayRun: agents } = sessionsForCaps(live, deps);
223
+ if (terminals.length + agents.length >= policy_1.MAX_SESSIONS_PER_COMPUTER ||
159
224
  started.filter((s) => s.spotId === spot.id).length + agents.filter((a) => a.cwd === folder.path).length >= spot.maxSessions) {
160
225
  deps.log(`launch ${launch.id}: refused, too many sessions running (${live.length} here, the folder "${spot.label}" allows ${spot.maxSessions})`);
161
226
  return refused("CAP_REACHED");
@@ -336,11 +401,11 @@ async function stopSession(launch, deps) {
336
401
  const entry = started.find((s) => s.name === launch.sessionName);
337
402
  if (!entry) {
338
403
  // A relay-run agent (dsh, Cursor) has no terminal: End is the relay no longer running it.
339
- if (deps.acp?.stop(launch.sessionName)) {
404
+ if (deps.acp?.stop(launch.sessionName) || deps.codex?.stop(launch.sessionName)) {
340
405
  deps.forgetSession?.(launch.sessionName);
341
406
  return { status: "STARTED" };
342
407
  }
343
- return launch.scope === "any" ? await stopRegisteredSession(launch, deps) : refused("NOT_LAUNCHED");
408
+ return launch.scope === "any" ? await stopRegisteredSession(launch, deps, started) : refused("NOT_LAUNCHED");
344
409
  }
345
410
  const outcome = stopRecorded(entry, deps);
346
411
  if (outcome === "not-running") {
@@ -354,12 +419,18 @@ async function stopSession(launch, deps) {
354
419
  deps.log(`launch ${launch.id}: stopped "${entry.name}" for ${launch.requestedBy.name} — its ${entry.multiplexer} session is gone (checked)`);
355
420
  return { status: "STARTED" };
356
421
  }
357
- /** A scope "any" STOP for a session the launcher did not start: its registered pane, checked. */
358
- async function stopRegisteredSession(launch, deps) {
422
+ /**
423
+ * A scope "any" STOP for a session the launcher did not start under that name: its registered
424
+ * pane, checked. `started` names the tmux sessions the launcher did start, so a session that joined
425
+ * under another name than its launch (B4, 2026-10-24) is still recognised as ours; a launch record
426
+ * left behind by the close is swept on the next poll (`forgetEndedLaunches`).
427
+ */
428
+ async function stopRegisteredSession(launch, deps, started) {
359
429
  if (!deps.registeredSession || !deps.stopRegisteredDeps)
360
430
  return refused("NOT_LAUNCHED");
361
431
  const name = launch.sessionName;
362
- const outcome = await (0, stop_registered_1.stopRegistered)(name, deps.registeredSession(name), deps.stopRegisteredDeps);
432
+ const launched = new Set(started.filter((s) => s.multiplexer === "tmux").map((s) => (0, argv_1.multiplexerSessionName)(s.name)));
433
+ const outcome = await (0, stop_registered_1.stopRegistered)(name, deps.registeredSession(name), deps.stopRegisteredDeps, launched);
363
434
  switch (outcome) {
364
435
  case "stopped":
365
436
  deps.forgetSession?.(name);
@@ -181,7 +181,7 @@ function idOf(raw) {
181
181
  function describe(err) {
182
182
  return err instanceof Error ? err.message : String(err);
183
183
  }
184
- function realLoopDeps(auth, log, signal, { forgetSession, registeredSession, registeredSessions, acp }) {
184
+ function realLoopDeps(auth, log, signal, { forgetSession, registeredSession, registeredSessions, acp, codex }) {
185
185
  const binaryEnv = () => (0, runtime_binary_1.currentBinaryEnv)();
186
186
  const handleDeps = () => ({
187
187
  loadPolicy: () => (0, policy_1.loadLaunchPolicy)(),
@@ -211,6 +211,7 @@ function realLoopDeps(auth, log, signal, { forgetSession, registeredSession, reg
211
211
  stopRegisteredDeps: (0, stop_registered_1.realStopRegisteredDeps)(multiplexer_1.realRun, log),
212
212
  readCodexConfig: () => (0, codex_launch_1.readCodexConfig)(),
213
213
  acp,
214
+ codex,
214
215
  });
215
216
  return {
216
217
  request: exports.deviceRequest,
@@ -226,6 +227,9 @@ function realLoopDeps(auth, log, signal, { forgetSession, registeredSession, reg
226
227
  capabilities: () => (0, capabilities_1.buildCapabilities)((0, policy_1.loadLaunchPolicy)(), {
227
228
  resolveBinary: (name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, binaryEnv()),
228
229
  multiplexer: (0, multiplexer_1.detectMultiplexer)(multiplexer_1.realRun),
230
+ // Never silent (2026-10-24): a folder that cannot start is a line in the relay's log, at
231
+ // every capabilities refresh, until it can.
232
+ onUnavailable: (spot, detail) => log(`launcher: spot "${spot.label}" left out: ${spot.runtime} ${spot.reason === "RUNTIME_BROKEN" ? "does not start" : "not found"} for the relay${detail ? ` (${detail})` : ""} — run \`baychat doctor\``),
229
233
  }),
230
234
  handle: (launch, io) => (0, handle_1.handleLaunch)(launch, handleDeps(), io),
231
235
  sleep: sleepUnlessAborted,
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.elapsedSeconds = elapsedSeconds;
36
37
  exports.ownSessionName = ownSessionName;
37
38
  exports.stopRegistered = stopRegistered;
38
39
  exports.realStopRegisteredDeps = realStopRegisteredDeps;
@@ -41,6 +42,79 @@ const fs = __importStar(require("fs"));
41
42
  const terminal_pane_1 = require("../terminal-pane");
42
43
  const argv_1 = require("./argv");
43
44
  const multiplexer_1 = require("./multiplexer");
45
+ /** `ps -o etime=` has whole seconds; the agent always starts before it registers, so this is slack, not a window. */
46
+ const START_SLACK_MS = 2_000;
47
+ /** `ps -o etime=`: `[[dd-]hh:]mm:ss`, the same on macOS and Linux and in every locale (`lstart`
48
+ * is not: "Sat 24 Oct" here, "Sa 24 Okt" on a German system). Seconds, or NaN. */
49
+ function elapsedSeconds(etime) {
50
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(etime.trim());
51
+ if (!m)
52
+ return NaN;
53
+ const [, days = "0", hours = "0", minutes, seconds] = m;
54
+ return ((Number(days) * 24 + Number(hours)) * 60 + Number(minutes)) * 60 + Number(seconds);
55
+ }
56
+ /**
57
+ * Is `pid` the process the session registered, and not a later one that reuses its number? Its
58
+ * start (now minus `ps -o etime=`) must not be after the registration.
59
+ */
60
+ function startedBeforeRegistration(pid, registeredAt, deps) {
61
+ const registered = registeredAt ? Date.parse(registeredAt) : NaN;
62
+ if (Number.isNaN(registered))
63
+ return false;
64
+ let elapsed;
65
+ try {
66
+ elapsed = elapsedSeconds(deps.check("ps", ["-o", "etime=", "-p", String(pid)]));
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ return !Number.isNaN(elapsed) && deps.now() - elapsed * 1000 <= registered + START_SLACK_MS;
72
+ }
73
+ /** How long a SIGTERMed agent gets to exit before End says it could not (Claude Code quits in < 1 s). */
74
+ const AGENT_EXIT_WAIT_MS = 5_000;
75
+ const AGENT_EXIT_POLL_MS = 100;
76
+ /**
77
+ * Ask the session's own agent process to quit (SIGTERM, never SIGKILL: it saves its state), and
78
+ * wait for it to be gone. The caller has already proven `pid` is that session's runtime.
79
+ */
80
+ async function endAgent(name, target, deps) {
81
+ const pid = target.ownerPid;
82
+ if (deps.platform === "win32") {
83
+ deps.log(`stop "${name}": ending its process is not done on Windows (it would be killed, not asked to quit); left running`);
84
+ return "not-ours";
85
+ }
86
+ if (!startedBeforeRegistration(pid, target.registeredAt, deps)) {
87
+ deps.log(`stop "${name}": process ${pid} cannot be shown to be the one that registered (started later, or no registration time); left running`);
88
+ return "not-ours";
89
+ }
90
+ try {
91
+ deps.signal(pid);
92
+ }
93
+ catch (err) {
94
+ deps.log(`stop "${name}": could not signal its process ${pid} (${err instanceof Error ? err.message : String(err)})`);
95
+ return "failed";
96
+ }
97
+ for (let waited = 0; deps.alive(pid); waited += AGENT_EXIT_POLL_MS) {
98
+ if (waited >= AGENT_EXIT_WAIT_MS) {
99
+ deps.log(`stop "${name}": its process ${pid} did not quit after SIGTERM; left running`);
100
+ return "failed";
101
+ }
102
+ await deps.sleep(AGENT_EXIT_POLL_MS);
103
+ }
104
+ deps.log(`stop "${name}": ended its agent process ${pid}; the terminal stays open`);
105
+ return "stopped";
106
+ }
107
+ /** No terminal BayChat may close (no pane we can name): end the registered agent if it is still that runtime. */
108
+ async function endAgentWithoutPane(name, target, deps) {
109
+ if (target.ownerPid === undefined)
110
+ return "not-ours";
111
+ const running = (0, terminal_pane_1.runtimeStillRunning)(target.ownerPid, target.runtime, deps.check);
112
+ if (!running.ok) {
113
+ deps.log(`stop "${name}": ${running.reason} Left alone.`);
114
+ return "not-ours";
115
+ }
116
+ return endAgent(name, target, deps);
117
+ }
44
118
  /**
45
119
  * The tmux session names our CLI has ever given, for session `name` (git history of
46
120
  * multiplexer-launch.ts and launcher/argv.ts):
@@ -71,6 +145,22 @@ function lookPane(handle, run) {
71
145
  const [paneId = "", session = "", panes = "", windows = "", pid = "", tty = ""] = res.stdout.trim().split("\t");
72
146
  return { found: { paneId, session, windowPanes: Number(panes), sessionWindows: Number(windows), serverPid: Number(pid), tty } };
73
147
  }
148
+ /**
149
+ * Is the pane gone after a kill? Asked as "is its id in the server's pane list", never through
150
+ * `display-message -t <pane>`: measured 2026-10-24 on real tmux, that answers status 0 with an
151
+ * empty line for a killed pane while other sessions remain, and "no server running" once the
152
+ * killed session was the server's last (the server exits) — both read as "still there", so a
153
+ * terminal that DID close was reported STOP_FAILED. The server answered a moment ago, so "no
154
+ * server" now means it exited with its last session.
155
+ */
156
+ function paneGone(handle, run) {
157
+ const res = run("tmux", ["-S", handle.socket, "list-panes", "-a", "-F", "#{pane_id}"]);
158
+ if (res.error)
159
+ return false;
160
+ if (res.status !== 0)
161
+ return /no server running|error connecting/i.test(res.stderr);
162
+ return !res.stdout.split("\n").some((id) => id.trim() === handle.pane);
163
+ }
74
164
  /** `lookPane`, after making the server's socket again when macOS's tmp cleanup deleted it
75
165
  * (multiplexer.ts `healTmuxSocket`): without that, a live pane would read as unreachable. */
76
166
  async function lookPaneHealing(handle, deps) {
@@ -100,13 +190,20 @@ function aliveOn(pid, tty, check) {
100
190
  return false;
101
191
  }
102
192
  }
103
- async function stopRegistered(name, target, deps) {
104
- if (!target?.pane)
193
+ /**
194
+ * @param launched the tmux sessions the relay's launcher itself started (`bc-<launch name>`, from
195
+ * started.json): a registered pane in one of them is ours whatever name the session joined under.
196
+ */
197
+ async function stopRegistered(name, target, deps, launched = new Set()) {
198
+ if (!target)
105
199
  return "not-registered";
200
+ if (!target.pane)
201
+ return target.ownerPid === undefined ? "not-registered" : endAgentWithoutPane(name, target, deps);
106
202
  const handle = target.pane;
107
- // screen and zellij cannot name the session a window is in, so check 2 is impossible: refused.
203
+ // screen and zellij cannot name the session a window is in, so check 2 is impossible: the
204
+ // terminal is never closed, only the agent itself ended.
108
205
  if (handle.kind !== "tmux")
109
- return "not-ours";
206
+ return endAgentWithoutPane(name, target, deps);
110
207
  // Without the server's pid a pane id proves nothing (it may be a reused %N): refused.
111
208
  if (!handle.serverPid)
112
209
  return "not-ours";
@@ -122,10 +219,7 @@ async function stopRegistered(name, target, deps) {
122
219
  deps.log(`stop "${name}": pane ${handle.pane} now belongs to another tmux server; left open`);
123
220
  return "not-ours";
124
221
  }
125
- if (!ownSessionName(pane.session, name, handle.session)) {
126
- deps.log(`stop "${name}": pane ${handle.pane} is in tmux session "${pane.session}", which BayChat did not make; left open`);
127
- return "not-ours";
128
- }
222
+ const ours = ownSessionName(pane.session, name, handle.session) || launched.has(pane.session);
129
223
  const running = (0, terminal_pane_1.verifyPane)(handle, target.runtime, deps.check, target.ownerPid);
130
224
  if (!running.ok) {
131
225
  deps.log(`stop "${name}": ${running.reason} Left open.`);
@@ -135,10 +229,18 @@ async function stopRegistered(name, target, deps) {
135
229
  deps.log(`stop "${name}": its process ${target.ownerPid} is not on pane ${handle.pane} any more; left open`);
136
230
  return "not-ours";
137
231
  }
232
+ if (!ours) {
233
+ // The person's own tmux session: never closed. Its agent, proven on that pane, is ended.
234
+ if (target.ownerPid === undefined) {
235
+ deps.log(`stop "${name}": pane ${handle.pane} is in tmux session "${pane.session}", which BayChat did not make, and the session recorded no process to end; left open`);
236
+ return "not-ours";
237
+ }
238
+ return endAgent(name, target, deps);
239
+ }
138
240
  const whole = pane.windowPanes === 1 && pane.sessionWindows === 1;
139
241
  deps.run("tmux", whole ? ["-S", handle.socket, "kill-session", "-t", `=${pane.session}`] : ["-S", handle.socket, "kill-pane", "-t", handle.pane]);
140
242
  // The kill's own answer is not the proof (handle.ts `stopRecorded`): the pane must be GONE.
141
- if (!("gone" in lookPane(handle, deps.run)))
243
+ if (!paneGone(handle, deps.run))
142
244
  return "failed";
143
245
  deps.log(`stop "${name}": closed ${whole ? `tmux session "${pane.session}"` : `pane ${handle.pane} of "${pane.session}"`}`);
144
246
  return "stopped";
@@ -151,5 +253,18 @@ function realStopRegisteredDeps(run, log) {
151
253
  socketExists: (socket) => fs.existsSync(socket),
152
254
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
153
255
  log,
256
+ platform: process.platform,
257
+ now: () => Date.now(),
258
+ signal: (pid) => process.kill(pid, "SIGTERM"),
259
+ alive: (pid) => {
260
+ try {
261
+ process.kill(pid, 0);
262
+ return true;
263
+ }
264
+ catch (err) {
265
+ // EPERM: it exists, it is just not ours to signal.
266
+ return err.code === "EPERM";
267
+ }
268
+ },
154
269
  };
155
270
  }
@@ -56,6 +56,12 @@ function registryPath() {
56
56
  class SessionRegistry {
57
57
  filePath;
58
58
  sessions = new Map();
59
+ /**
60
+ * Told whenever what was known about a session stops being true — it is removed, replaced,
61
+ * pruned, or its conversation is forgotten (`clearResume`, a relay-run Codex losing its thread).
62
+ * The daemon forgets that session's "Allow for this session" answers here (codex-allowances.ts).
63
+ */
64
+ onForget;
59
65
  constructor(filePath = registryPath()) {
60
66
  this.filePath = filePath;
61
67
  }
@@ -118,6 +124,7 @@ class SessionRegistry {
118
124
  replace(target) {
119
125
  const fresh = { ...stripUndefined(target), name: target.name, runtime: target.runtime, registeredAt: new Date().toISOString() };
120
126
  this.sessions.set(target.name, fresh);
127
+ this.onForget?.(target.name);
121
128
  this.save();
122
129
  return fresh;
123
130
  }
@@ -144,6 +151,7 @@ class SessionRegistry {
144
151
  const known = this.sessions.delete(name);
145
152
  if (known)
146
153
  this.save();
154
+ this.onForget?.(name);
147
155
  return known;
148
156
  }
149
157
  /**
@@ -167,6 +175,7 @@ class SessionRegistry {
167
175
  delete existing.resumeSource;
168
176
  delete existing.resumeEvidence;
169
177
  this.save();
178
+ this.onForget?.(name);
170
179
  }
171
180
  all() {
172
181
  return [...this.sessions.values()];
@@ -209,6 +218,7 @@ class SessionRegistry {
209
218
  if (!live.has(name) && !target.attached && !target.relayOwned) {
210
219
  this.sessions.delete(name);
211
220
  dropped.push(name);
221
+ this.onForget?.(name);
212
222
  }
213
223
  }
214
224
  if (dropped.length > 0)
@@ -226,6 +236,26 @@ class SessionRegistry {
226
236
  t.acp.sessionId = sessionId;
227
237
  this.save();
228
238
  }
239
+ /**
240
+ * Record (or, with `undefined`, forget) a relay-run Codex session's thread. A different thread
241
+ * is a new conversation, so it is not primed until a turn completes on it.
242
+ */
243
+ setCodexThread(name, threadId, primed = false) {
244
+ const t = this.sessions.get(name);
245
+ if (!t?.codex)
246
+ return;
247
+ if (threadId === undefined) {
248
+ delete t.codex.threadId;
249
+ this.onForget?.(name);
250
+ }
251
+ else
252
+ t.codex.threadId = threadId;
253
+ if (primed)
254
+ t.codex.primed = true;
255
+ else
256
+ delete t.codex.primed;
257
+ this.save();
258
+ }
229
259
  setAcpMode(name, mode) {
230
260
  const t = this.sessions.get(name);
231
261
  if (!t?.acp)
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ // A terminal session's plan limits and running cost, sent to BayChat (TERMINAL_CREDITS.md):
3
+ // `POST /api/device-api/session-limits`. Only the session's own person ever sees the percentages;
4
+ // the cost is the runtime's own estimate (for a Pro/Max plan the subscription is a fixed price).
5
+ //
6
+ // Claude Code from its status-line JSON (claude-context.ts notes it, claude-context-flush.ts
7
+ // sends it): `rate_limits.five_hour` / `seven_day` {used_percentage, resets_at}
8
+ // and, where it is there, `cost.total_cost_usd`.
9
+ // Codex from `codex app-server` (`readCodexRateLimits`, codex-app-server.ts) every
10
+ // 10 minutes, for each Codex session this relay hosts — `CodexLimitsPoller`.
11
+ //
12
+ // Light on purpose: the status-line path imports this, and it must not pull in the relay.
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.CodexLimitsPoller = exports.CODEX_LIMITS_POLL_MS = exports.LIMITS_MIN_INTERVAL_MS = exports.LIMITS_REFRESH_MS = void 0;
15
+ exports.claudeLimitsFromStatus = claudeLimitsFromStatus;
16
+ exports.codexLimitsReport = codexLimitsReport;
17
+ exports.limitsKey = limitsKey;
18
+ exports.limitsDue = limitsDue;
19
+ exports.postSessionLimits = postSessionLimits;
20
+ const api_1 = require("../api");
21
+ /** A report at least this often while the numbers stand still — the screen's "updated" time. */
22
+ exports.LIMITS_REFRESH_MS = 10 * 60_000;
23
+ /** …and no more often than this when they move. */
24
+ exports.LIMITS_MIN_INTERVAL_MS = 60_000;
25
+ exports.CODEX_LIMITS_POLL_MS = 10 * 60_000;
26
+ const CLAUDE_WINDOWS = [
27
+ ["five_hour", 5 * 60],
28
+ ["seven_day", 7 * 24 * 60],
29
+ ];
30
+ const pct = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.min(100, Math.round(v * 10) / 10) : undefined;
31
+ const isoFromEpochSeconds = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? new Date(v * 1000).toISOString() : undefined;
32
+ /**
33
+ * Claude Code's status-line JSON → a report, or null when it carries neither a window nor a cost
34
+ * (an API-key session before its first answer, or an older Claude Code).
35
+ */
36
+ function claudeLimitsFromStatus(status) {
37
+ const s = (status ?? {});
38
+ const limits = (s.rate_limits ?? {});
39
+ const windows = [];
40
+ for (const [kind, minutes] of CLAUDE_WINDOWS) {
41
+ const w = limits[kind];
42
+ const usedPercent = pct(w?.used_percentage);
43
+ if (usedPercent === undefined)
44
+ continue;
45
+ const resetsAt = isoFromEpochSeconds(w?.resets_at);
46
+ windows.push({ kind, usedPercent, windowMinutes: minutes, ...(resetsAt ? { resetsAt } : {}) });
47
+ }
48
+ const cost = (s.cost ?? {});
49
+ const total = cost.total_cost_usd;
50
+ const totalCostUsd = typeof total === "number" && Number.isFinite(total) && total >= 0 ? Math.round(total * 1e6) / 1e6 : undefined;
51
+ const plan = typeof s.subscription_type === "string" && s.subscription_type ? s.subscription_type.slice(0, 40) : undefined;
52
+ if (windows.length === 0 && totalCostUsd === undefined)
53
+ return null;
54
+ return { runtime: "claude-code", ...(plan ? { plan } : {}), windows, ...(totalCostUsd !== undefined ? { totalCostUsd } : {}) };
55
+ }
56
+ /** Codex's account limits → a report, or null when it has no window to show. */
57
+ function codexLimitsReport(limits) {
58
+ if (!limits || limits.windows.length === 0)
59
+ return null;
60
+ return {
61
+ runtime: "codex",
62
+ ...(limits.plan ? { plan: limits.plan.slice(0, 40) } : {}),
63
+ windows: limits.windows.map((w) => ({
64
+ kind: w.kind,
65
+ usedPercent: Math.min(100, Math.max(0, w.usedPercent)),
66
+ ...(w.windowMinutes ? { windowMinutes: w.windowMinutes } : {}),
67
+ ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}),
68
+ })),
69
+ };
70
+ }
71
+ /** The part of a report that, when it changes, is news: whole percentages and cents. */
72
+ function limitsKey(report) {
73
+ const windows = report.windows.map((w) => `${w.kind}:${Math.floor(w.usedPercent)}:${w.resetsAt ?? ""}`).join(",");
74
+ const cents = report.totalCostUsd === undefined ? "" : Math.floor(report.totalCostUsd * 100);
75
+ return `${report.plan ?? ""}|${windows}|${cents}`;
76
+ }
77
+ function limitsDue(prev, report, now) {
78
+ if (!prev)
79
+ return true;
80
+ if (now - prev.at >= exports.LIMITS_REFRESH_MS)
81
+ return true;
82
+ return now - prev.at >= exports.LIMITS_MIN_INTERVAL_MS && prev.key !== limitsKey(report);
83
+ }
84
+ /** Send one report for a named session, with the device credential. Never throws. */
85
+ async function postSessionLimits(auth, session, report, request) {
86
+ try {
87
+ await request(auth, "POST", "/api/device-api/session-limits", { session, ...report });
88
+ return { kind: "sent" };
89
+ }
90
+ catch (err) {
91
+ if (err instanceof api_1.ApiError) {
92
+ // Express's own 404 carries no code: a server from before this route.
93
+ if (err.status === 404 && !err.code)
94
+ return { kind: "not-deployed" };
95
+ if (err.status >= 400 && err.status < 500 && err.status !== 429) {
96
+ return { kind: "refused", status: err.status, code: err.code, message: err.message };
97
+ }
98
+ }
99
+ return { kind: "failed", message: err instanceof Error ? err.message : String(err) };
100
+ }
101
+ }
102
+ /**
103
+ * Every 10 minutes, while this relay hosts a Codex session: read the account's limits once
104
+ * (they are the account's, so one read serves every session) and report them for each session.
105
+ * A server without the route is asked again an hour later, not every round.
106
+ */
107
+ class CodexLimitsPoller {
108
+ deps;
109
+ timer;
110
+ running = false;
111
+ notDeployedUntil = 0;
112
+ constructor(deps) {
113
+ this.deps = deps;
114
+ }
115
+ start() {
116
+ if (this.timer)
117
+ return;
118
+ const every = this.deps.setInterval ??
119
+ ((fn, ms) => {
120
+ const t = setInterval(fn, ms);
121
+ t.unref?.();
122
+ return { cancel: () => clearInterval(t) };
123
+ });
124
+ this.timer = every(() => void this.poll(), exports.CODEX_LIMITS_POLL_MS);
125
+ }
126
+ stop() {
127
+ this.timer?.cancel();
128
+ this.timer = undefined;
129
+ }
130
+ /** One round; resolves when every report was attempted. Public for tests and a first run. */
131
+ async poll(now = Date.now()) {
132
+ if (this.running || now < this.notDeployedUntil)
133
+ return;
134
+ // In this order: no Codex session, no binary lookup (a machine without Codex logs nothing).
135
+ const sessions = this.deps.sessions();
136
+ if (sessions.length === 0)
137
+ return;
138
+ const auth = this.deps.auth();
139
+ const binary = auth ? this.deps.binary() : undefined;
140
+ if (!auth || !binary)
141
+ return;
142
+ this.running = true;
143
+ try {
144
+ const report = codexLimitsReport(await this.deps.read(binary));
145
+ if (!report)
146
+ return;
147
+ for (const session of sessions) {
148
+ const outcome = await postSessionLimits(auth, session, report, this.deps.request);
149
+ if (outcome.kind === "not-deployed") {
150
+ this.notDeployedUntil = now + 60 * 60_000;
151
+ this.deps.log("this BayChat server does not take plan limits yet — asking again in an hour");
152
+ return;
153
+ }
154
+ if (outcome.kind === "refused" || outcome.kind === "failed") {
155
+ this.deps.log(`plan limits for ${session} not sent: ${outcome.message}`);
156
+ }
157
+ }
158
+ }
159
+ catch (err) {
160
+ this.deps.log(`plan limits: ${err instanceof Error ? err.message : String(err)}`);
161
+ }
162
+ finally {
163
+ this.running = false;
164
+ }
165
+ }
166
+ }
167
+ exports.CodexLimitsPoller = CodexLimitsPoller;