baychat 0.34.1 → 0.35.0

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 (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -2
  3. package/dist/api.js +30 -0
  4. package/dist/bays-command.js +6 -0
  5. package/dist/channel/feed.js +48 -4
  6. package/dist/channel/log-file.js +37 -0
  7. package/dist/channel/server.js +34 -4
  8. package/dist/claude-context-flush.js +31 -8
  9. package/dist/claude-context.js +19 -5
  10. package/dist/claude-onboarding.js +21 -8
  11. package/dist/cli-help.js +3 -0
  12. package/dist/client-paths.js +6 -1
  13. package/dist/client-scope.js +34 -0
  14. package/dist/commands.js +16 -8
  15. package/dist/connect-link.js +205 -0
  16. package/dist/connect-plan.js +2 -2
  17. package/dist/connect.js +8 -0
  18. package/dist/device-login.js +19 -0
  19. package/dist/launch/command.js +6 -3
  20. package/dist/main.js +18 -2
  21. package/dist/mcp-files.js +2 -0
  22. package/dist/mcp-register.js +2 -0
  23. package/dist/protocol-content.js +1 -1
  24. package/dist/refresh-clients.js +7 -0
  25. package/dist/relay/acp/approval.js +99 -30
  26. package/dist/relay/acp/daemon-glue.js +2 -1
  27. package/dist/relay/adapters.js +5 -1
  28. package/dist/relay/bay-relays.js +18 -5
  29. package/dist/relay/codex-allowances.js +113 -0
  30. package/dist/relay/codex-app-server.js +637 -157
  31. package/dist/relay/codex-approvals.js +390 -0
  32. package/dist/relay/codex-commands.js +61 -0
  33. package/dist/relay/codex-log.js +260 -0
  34. package/dist/relay/codex-runner.js +505 -0
  35. package/dist/relay/codex-version.js +50 -0
  36. package/dist/relay/daemon.js +190 -6
  37. package/dist/relay/fresh-start.js +2 -1
  38. package/dist/relay/launcher/capabilities.js +24 -9
  39. package/dist/relay/launcher/codex-launch.js +9 -2
  40. package/dist/relay/launcher/codex-relay-launch.js +80 -0
  41. package/dist/relay/launcher/handle.js +101 -30
  42. package/dist/relay/launcher/loop.js +5 -1
  43. package/dist/relay/launcher/stop-registered.js +124 -9
  44. package/dist/relay/registry.js +30 -0
  45. package/dist/relay/runtime-limits.js +167 -0
  46. package/dist/relay/spawn-env.js +40 -3
  47. package/dist/relay/terminal-pane.js +1 -0
  48. package/dist/sea-names.js +34 -0
  49. package/dist/session-command.js +3 -1
  50. package/dist/session-name.js +25 -2
  51. package/dist/skill-bootstrap.js +2 -0
  52. package/dist/tool-defs.js +30 -0
  53. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.34.1",
3
+ "version": "0.35.0",
4
4
  "displayName": "BayChat",
5
5
  "description": "Wakes this Claude Code session the moment a BayChat message arrives: no Monitor, no 30-minute deadline. Uses the baychat CLI already on this computer (npm install -g baychat, then baychat login).",
6
6
  "author": {
package/README.md CHANGED
@@ -381,9 +381,18 @@ listener, and in whether anything reaches them once the window is closed.
381
381
  | Runtime | Who re-arms the listener | Window closed |
382
382
  |---|---|---|
383
383
  | **Claude Code (native)** | socket stays open; a closed socket needs a replacement Monitor | not reachable until resumed |
384
- | **Codex** | nobody needs to — its own queue | not reachable |
384
+ | **Codex** | nobody needs to — its own queue | resumed headlessly; its approvals come to your phone (Codex ≥ 0.160) |
385
385
  | **Cursor** | **the agent itself**, after every wake | not reachable |
386
386
 
387
+ **Codex started from the app is run by the relay** (since 2026-10-23, Codex ≥ 0.160): one
388
+ `codex app-server` turn per message, in its folder's sandbox with no network, and every approval
389
+ Codex asks for — a command, a file change with its diff, network access — comes to your phone
390
+ as a card: "Allow once", "Allow for this session" (confirmed with Face ID), "Deny". Nobody
391
+ answering within 10 minutes is a Deny. Turns woken by someone without command access are read
392
+ only and never ask you. `tmux attach -t bc-<name>` (or `baychat relay log <name> -f`) shows
393
+ what it does. With `BAYCHAT_CODEX_TRANSPORT=exec` on the relay, Codex can't answer in BayChat, so
394
+ launches from the phone are refused and messages to these sessions wait (the room is told why).
395
+
387
396
  Cursor's relay does work — it runs shell commands, so it holds the listener
388
397
  like anything else. It is simply the least robust of the three, and the only
389
398
  one where remembering to re-arm falls to the agent.
@@ -431,6 +440,7 @@ one where remembering to re-arm falls to the agent.
431
440
  | `baychat relay start [--foreground]` | Run the **relay**: one long-poll for this whole machine that wakes local sessions the moment a message arrives. Installs a systemd user unit so it returns after a reboot (see [Relay](#relay)) |
432
441
  | `baychat relay status` | Sessions, cursor, and any **delivery pending** — messages that reached this box and that nothing answered (exit 2 if any) |
433
442
  | `baychat relay stop` | Stop the relay and disable it at boot |
443
+ | `baychat relay log <session> [-f]` | What a Codex session the relay runs did — commands, file changes, answers, approval cards. `-f` keeps following it (works on Windows too) |
434
444
  | `baychat claude [args…]` | Start Claude Code in this terminal with the BayChat channel on (see [No Monitor deadline with the channel](#coding-sessions-one-short-command)). Every argument is passed to Claude Code, `--help` included |
435
445
  | `baychat doctor [--json]` | **Run this when something does not work.** Checks every link between this machine and BayChat — credential, relay, and per runtime its MCP registration, skill, executable and live session (and for Claude Code the channel: a plugin that carries it, on Node 22+) — and prints exactly what to type for each thing that is wrong. Exit 0 clear, 1 broken, 2 messages nothing answered. `--json` for pasting into a support thread (see [When something does not work](#when-something-does-not-work)) |
436
446
  | `baychat relay attach --session <name> [--runtime claude\|codex\|cursor\|hermes] [--resume-id <id>] [--conversation <id>] [--timeout <sec>]` | Register this session with the relay and block until it is woken (exit 0) or the wait lapses (exit 2) |
@@ -1071,7 +1081,8 @@ Two refusals worth knowing in advance:
1071
1081
  | `BAYCHAT_TOKEN` | Use this agent API token instead of the credentials file (headless/CI) |
1072
1082
  | `BAYCHAT_DEVICE_TOKEN` | Use this `bay_u_*` device token (from `baychat login`) instead of the credentials file (headless/CI) |
1073
1083
  | `BAYCHAT_API_URL` | API origin (default `https://api.baychat.io`) |
1074
- | `BAYCHAT_CONFIG_DIR` | Credentials/cursor directory (default `~/.baychat`) |
1084
+ | `BAYCHAT_CONFIG_DIR` | Credentials/cursor directory (default `~/.baychat`). A login made with it set — or against a server other than `https://api.baychat.io` — leaves the computer's agent clients (Claude Code, Claude Desktop, Codex, Cursor) as they are; connect one on purpose with `baychat connect <client>` |
1085
+ | `BAYCHAT_REFRESH_CLIENTS=1` | Refresh every connected agent client after such a login anyway |
1075
1086
 
1076
1087
  ## Security
1077
1088
 
package/dist/api.js CHANGED
@@ -9,6 +9,8 @@ exports.createLinkRequest = createLinkRequest;
9
9
  exports.pollLinkRequest = pollLinkRequest;
10
10
  exports.createDeviceLink = createDeviceLink;
11
11
  exports.pollDeviceLink = pollDeviceLink;
12
+ exports.redeemConnectLink = redeemConnectLink;
13
+ exports.mySessionNames = mySessionNames;
12
14
  exports.deviceMe = deviceMe;
13
15
  const attachments_1 = require("./attachments");
14
16
  const DEVICE_AUTH_TIMEOUT_MS = 15_000;
@@ -218,6 +220,34 @@ async function pollDeviceLink(baseUrl, id, pollSecret) {
218
220
  throw await parseError(res);
219
221
  return (await res.json());
220
222
  }
223
+ /**
224
+ * Spend a connect link (`baychat connect <link>`, docs/features/CONNECT_MY_AGENT.md). Single use:
225
+ * a 404 covers unknown, expired, already used and tried too often alike.
226
+ */
227
+ async function redeemConnectLink(baseUrl, token, deviceName,
228
+ /** The agent's own name, for a link the person left unnamed. */
229
+ ownName) {
230
+ const res = await fetch(`${baseUrl}/api/device-links/connect/redeem`, {
231
+ signal: AbortSignal.timeout(DEVICE_AUTH_TIMEOUT_MS),
232
+ method: "POST",
233
+ headers: { "Content-Type": "application/json" },
234
+ body: JSON.stringify({ token, ...(deviceName ? { deviceName } : {}), ...(ownName ? { agentName: ownName } : {}) }),
235
+ });
236
+ if (!res.ok)
237
+ throw await parseError(res);
238
+ return (await res.json());
239
+ }
240
+ /** The names of this person's sessions in the token's Bay, ended ones included (`GET /api/device-api/sessions`). */
241
+ async function mySessionNames(baseUrl, token) {
242
+ const res = await fetch(`${baseUrl}/api/device-api/sessions`, {
243
+ signal: AbortSignal.timeout(DEVICE_AUTH_TIMEOUT_MS),
244
+ headers: { Authorization: `Bearer ${token}` },
245
+ });
246
+ if (!res.ok)
247
+ throw await parseError(res);
248
+ const body = (await res.json());
249
+ return (body.sessions ?? []).map((s) => s.name).filter((n) => typeof n === "string");
250
+ }
221
251
  /** Verify a device token and learn whose Bay it opens (`baychat login --token`). */
222
252
  async function deviceMe(baseUrl, token) {
223
253
  const res = await fetch(`${baseUrl}/api/device-credentials/me`, {
@@ -7,6 +7,7 @@ exports.cmdBays = cmdBays;
7
7
  const config_1 = require("./config");
8
8
  const bays_1 = require("./bays");
9
9
  const bay_mcp_1 = require("./bay-mcp");
10
+ const client_scope_1 = require("./client-scope");
10
11
  const bay_deleted_1 = require("./bay-deleted");
11
12
  const commands_1 = require("./commands");
12
13
  const client_paths_1 = require("./client-paths");
@@ -50,6 +51,11 @@ function cmdBays(args) {
50
51
  console.log(`✓ ${target.bayName} is now this computer's default Bay.`);
51
52
  // The default is the `baychat` entry: every connected client gets the new token there, and
52
53
  // the Bay that WAS the default moves to its own `baychat-<bay>` entry.
54
+ const isolated = (0, client_scope_1.isolatedLoginReason)(next.device.baseUrl);
55
+ if (isolated) {
56
+ console.log((0, client_scope_1.isolatedLoginNotice)(isolated));
57
+ return 0;
58
+ }
53
59
  (0, commands_1.refreshConnectedClients)(next.device.baseUrl, next.device.token);
54
60
  (0, commands_1.registerWithClaude)(next.device.baseUrl, next.device.token);
55
61
  (0, bay_mcp_1.applyBayEntries)(next.device.baseUrl, bays, next.bays, {
@@ -1,11 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ChannelFeed = exports.BACKOFF_MS = exports.CLOSE_STOPPED = void 0;
3
+ exports.ChannelFeed = exports.MAX_FAILED_RECONNECTS = exports.BACKOFF_MS = exports.CLOSE_STOPPED = void 0;
4
4
  /** Close code the server sends when a feed was stopped or replaced on purpose. */
5
5
  exports.CLOSE_STOPPED = 4000;
6
6
  /** Seconds between reconnect attempts: quick at first, then no more than once a minute. */
7
7
  exports.BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 30_000, 60_000];
8
8
  const READY_TIMEOUT_MS = 10_000;
9
+ /**
10
+ * Consecutive reconnects that fail before the feed gives up and SAYS so (about six minutes of
11
+ * backoff — longer than a deploy). Before 2026-10-23 it retried forever without a word, and a
12
+ * feed that never came back looked exactly like a quiet Bay.
13
+ */
14
+ exports.MAX_FAILED_RECONNECTS = 10;
9
15
  const WATCHDOG_MS = 2 * 60_000;
10
16
  /** A timer that fires this much later than asked means the computer slept: reconnect at once. */
11
17
  const SLEEP_GAP_MS = 60_000;
@@ -131,6 +137,25 @@ class ChannelFeed {
131
137
  const current = () => generation === this.generation && this.state !== "stopped";
132
138
  let ready = false;
133
139
  this.state = "connecting";
140
+ // A RECONNECT waits for `ready` too (the first open has `start`'s own timer): a socket that
141
+ // opens and never says ready used to leave the feed "connecting" for good, hearing nothing.
142
+ const readyTimer = first
143
+ ? undefined
144
+ : this.setTimer(() => {
145
+ if (!current() || ready)
146
+ return;
147
+ this.log(`feed for ${this.deps.session} did not say ready within ${(this.deps.readyTimeoutMs ?? READY_TIMEOUT_MS) / 1000}s`);
148
+ const socket = this.socket;
149
+ this.generation++;
150
+ this.socket = undefined;
151
+ try {
152
+ socket?.close(1000, "No ready");
153
+ }
154
+ catch {
155
+ /* Already gone. */
156
+ }
157
+ this.scheduleReconnect("did not say ready");
158
+ }, this.deps.readyTimeoutMs ?? READY_TIMEOUT_MS);
134
159
  try {
135
160
  this.socket = this.deps.connect(ticket.url, ticket.protocols, {
136
161
  onOpen: () => { },
@@ -144,8 +169,11 @@ class ChannelFeed {
144
169
  this.cursor = frame.cursor;
145
170
  if (frame.type === "ready") {
146
171
  ready = true;
172
+ if (readyTimer !== undefined)
173
+ this.clearTimer(readyTimer);
147
174
  this.state = "open";
148
175
  this.attempt = 0;
176
+ this.log(`feed for ${this.deps.session} ready`);
149
177
  first?.onReady();
150
178
  return;
151
179
  }
@@ -158,7 +186,10 @@ class ChannelFeed {
158
186
  onClose: (code, reason) => {
159
187
  if (!current())
160
188
  return;
189
+ if (readyTimer !== undefined)
190
+ this.clearTimer(readyTimer);
161
191
  this.socket = undefined;
192
+ this.log(`feed for ${this.deps.session} closed ${code}${reason ? ` (${reason})` : ""}`);
162
193
  if (!ready && first) {
163
194
  first.onFailBeforeReady(`The BayChat feed closed before it was ready (${code}${reason ? `: ${reason}` : ""}).`);
164
195
  return;
@@ -168,6 +199,7 @@ class ChannelFeed {
168
199
  // listener for this session. Reconnecting would fight whichever replaced it.
169
200
  this.log(`feed for ${this.deps.session} stopped by the server (${reason || code})`);
170
201
  this.stop();
202
+ void this.pushStopped(`the server stopped this feed (${reason || code}) — another listener for this session replaced it, or it was ended`);
171
203
  return;
172
204
  }
173
205
  this.scheduleReconnect(`closed ${code}${reason ? `: ${reason}` : ""}`);
@@ -185,6 +217,12 @@ class ChannelFeed {
185
217
  scheduleReconnect(why) {
186
218
  if (this.state === "stopped")
187
219
  return;
220
+ if (this.attempt >= exports.MAX_FAILED_RECONNECTS) {
221
+ this.log(`feed for ${this.deps.session} gave up after ${this.attempt} failed reconnects (${why})`);
222
+ this.stop();
223
+ void this.pushStopped(`${this.attempt} reconnects in a row failed (last: ${why})`);
224
+ return;
225
+ }
188
226
  this.state = "retrying";
189
227
  const delay = exports.BACKOFF_MS[Math.min(this.attempt, exports.BACKOFF_MS.length - 1)];
190
228
  this.attempt++;
@@ -204,14 +242,14 @@ class ChannelFeed {
204
242
  if (this.stopped())
205
243
  return;
206
244
  if (minted.ok) {
245
+ this.log(`feed for ${this.deps.session} minted a ticket; opening`);
207
246
  this.open(minted.ticket);
208
247
  return;
209
248
  }
249
+ this.log(`feed for ${this.deps.session} could not mint a ticket${minted.fatal ? " (final)" : ""}: ${minted.detail}`);
210
250
  if (minted.fatal) {
211
251
  this.stop();
212
- await this.deps.push(`BayChat stopped listening for session "${this.deps.session}": ${minted.detail} ` +
213
- `If the session expired, call join_session again with the same name and room, then call ` +
214
- `this BayChat channel's listen tool again. Until then this session does not hear the Bay.`, this.meta({ event: "stopped" }));
252
+ await this.pushStopped(minted.detail);
215
253
  return;
216
254
  }
217
255
  this.scheduleReconnect(`could not reconnect (${minted.detail})`);
@@ -309,6 +347,12 @@ class ChannelFeed {
309
347
  message_ids: c.messageId,
310
348
  }));
311
349
  }
350
+ /** The feed is over and will not come back by itself: say so, and what to do. */
351
+ async pushStopped(detail) {
352
+ await this.deps.push(`BayChat stopped listening for session "${this.deps.session}": ${detail}. ` +
353
+ `If the session expired, call join_session again with the same name and room, then call ` +
354
+ `this BayChat channel's listen tool again. Until then this session does not hear the Bay.`, this.meta({ event: "stopped" }));
355
+ }
312
356
  async pushReset() {
313
357
  await this.deps.push(`BayChat reset the feed for session "${this.deps.session}". Catch up on your joined conversations with get_messages; ` +
314
358
  `a reset never means there were no messages. The channel stays open.`, this.meta({ event: "reset" }));
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.channelLogFile = channelLogFile;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const config_1 = require("../config");
10
+ /**
11
+ * The channel's own log, `~/.baychat/channel.log` (2026-10-23). Claude Code swallows a channel
12
+ * server's stderr, so when a feed went quiet there was nothing to read afterwards. Every state
13
+ * change, close code and reason, and ticket mint is written here with the time and process id.
14
+ *
15
+ * Kept small: past `maxBytes` the file moves to `channel.log.1` (one old file, replaced each
16
+ * time). Owner-only (0600): it names sessions and Bays. Never throws — a log that cannot be
17
+ * written must not take the channel down with it.
18
+ */
19
+ function channelLogFile(dir = (0, config_1.configDir)(), maxBytes = 1_000_000) {
20
+ const file = node_path_1.default.join(dir, "channel.log");
21
+ return (line) => {
22
+ try {
23
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
24
+ try {
25
+ if (node_fs_1.default.statSync(file).size > maxBytes)
26
+ node_fs_1.default.renameSync(file, `${file}.1`);
27
+ }
28
+ catch {
29
+ /* No file yet. */
30
+ }
31
+ node_fs_1.default.appendFileSync(file, `${new Date().toISOString()} [${process.pid}] ${line}\n`, { mode: 0o600 });
32
+ }
33
+ catch {
34
+ /* Best effort: stderr still has the line. */
35
+ }
36
+ };
37
+ }
@@ -71,6 +71,7 @@ const ws_1 = require("../relay/ws");
71
71
  const claude_link_1 = require("./claude-link");
72
72
  const detect_1 = require("./detect");
73
73
  const feed_1 = require("./feed");
74
+ const log_file_1 = require("./log-file");
74
75
  const permission_1 = require("./permission");
75
76
  const relay_link_1 = require("./relay-link");
76
77
  /** Delivered to Claude as context when this server connects. */
@@ -216,7 +217,19 @@ async function mintTicket(deps, { bay, session, cursor, commands }) {
216
217
  // The Bay requires two-step login and this person has not set it up (API
217
218
  // mfa-policy.service.ts, 403 MFA_REQUIRED). Logging in again would not help; setting it up
218
219
  // does, and the same computer login works again the moment it is done.
219
- if (status === 403 && /MFA_REQUIRED/.test(err instanceof Error ? err.message : String(err))) {
220
+ const said = err instanceof Error ? err.message : String(err);
221
+ // Bay rules about terminal agents (API docs/features/TERMINAL_RULES.md, AGENTS_PAGE.md):
222
+ // each says what to do, none of them is fixed by logging in again.
223
+ if (status === 403 && /TERMINAL_AGENTS_PAUSED/.test(said)) {
224
+ return { ok: false, fatal: true, detail: "Stopped: the admins of this Bay paused terminal agents. Ask them to switch it back on, then start this session again." };
225
+ }
226
+ if (status === 403 && /DEVICE_PENDING_APPROVAL/.test(said)) {
227
+ return { ok: false, fatal: true, detail: "Stopped: this computer is waiting for a Bay admin to approve it (Settings → Agents in this Bay). Start this session again once it is approved." };
228
+ }
229
+ if (status === 403 && /AGENT_DISABLED/.test(said)) {
230
+ return { ok: false, fatal: true, detail: "Stopped: this session was switched off in BayChat. You or a Bay admin can switch it on in Settings → Agents in this Bay." };
231
+ }
232
+ if (status === 403 && /MFA_REQUIRED/.test(said)) {
220
233
  return {
221
234
  ok: false,
222
235
  fatal: true,
@@ -224,6 +237,15 @@ async function mintTicket(deps, { bay, session, cursor, commands }) {
224
237
  "then start this session again.",
225
238
  };
226
239
  }
240
+ // "Company sign-in only" (API sso-gate.service.ts): this computer's login was not approved
241
+ // from the Bay's company sign-in. Logging in again, approved from one, fixes it.
242
+ if (status === 403 && /SSO_REQUIRED/.test(said)) {
243
+ return {
244
+ ok: false,
245
+ fatal: true,
246
+ detail: "Stopped: this Bay requires company sign-in. Run `baychat login` again and approve it while signed in with your company account.",
247
+ };
248
+ }
227
249
  if (status === 401 || status === 403) {
228
250
  return { ok: false, fatal: true, detail: "BayChat refused this computer's login. Run `baychat login`." };
229
251
  }
@@ -270,7 +292,9 @@ class ChannelService {
270
292
  bayName = this.deps.login(bay).bayName;
271
293
  }
272
294
  catch (err) {
273
- return toolResult("failed", `Failed: ${err instanceof Error ? err.message : String(err)} Use listen_messages and a native Monitor instead.`, { session });
295
+ const detail = err instanceof Error ? err.message : String(err);
296
+ this.deps.log(`listen ${session}: sign-in failed: ${detail}`);
297
+ return toolResult("failed", `Failed: ${detail} Use listen_messages and a native Monitor instead.`, { session, stage: "login", detail });
274
298
  }
275
299
  const key = `${bayName ?? ""}\u0000${session}`;
276
300
  this.feeds.get(key)?.stop();
@@ -315,7 +339,8 @@ class ChannelService {
315
339
  this.closeCommandLink(key, link);
316
340
  if (this.last?.key === key)
317
341
  this.last = undefined;
318
- return toolResult("failed", `Failed: ${started.detail} Use listen_messages and a native Monitor instead.`, { session });
342
+ this.deps.log(`listen ${session}: the feed did not start: ${started.detail}`);
343
+ return toolResult("failed", `Failed: ${started.detail} Use listen_messages and a native Monitor instead.`, { session, stage: "feed", detail: started.detail });
319
344
  }
320
345
  this.last = { key, session, bay };
321
346
  // Approvals for `baychat claude` / `baychat start` (approve-hook --session-from-claude): this
@@ -409,7 +434,12 @@ const PermissionRequestSchema = zod_1.z.object({
409
434
  });
410
435
  /** Boot on stdio and block on the transport. */
411
436
  async function startChannelServer(deps) {
412
- const log = deps?.log ?? ((line) => console.error(`[baychat channel] ${line}`));
437
+ const toFile = (0, log_file_1.channelLogFile)();
438
+ const log = deps?.log ??
439
+ ((line) => {
440
+ console.error(`[baychat channel] ${line}`);
441
+ toFile(line);
442
+ });
413
443
  const mcp = new index_js_1.Server({ name: detect_1.CHANNEL_MCP_ENTRY, version: "1" }, {
414
444
  // `claude/channel/permission`: Claude Code also offers this channel its tool-approval
415
445
  // dialogs (permission.ts). Safe to declare because only the session's owner can answer the
@@ -45,6 +45,8 @@ const claude_context_state_1 = require("./claude-context-state");
45
45
  const config_1 = require("./config");
46
46
  const context_report_1 = require("./context-report");
47
47
  const registry_1 = require("./relay/registry");
48
+ const runtime_limits_1 = require("./relay/runtime-limits");
49
+ const api_1 = require("./api");
48
50
  /**
49
51
  * The most recent BayChat tool call in the transcript tail that names a session.
50
52
  *
@@ -121,15 +123,26 @@ function acquireLock(file, now) {
121
123
  return false;
122
124
  }
123
125
  }
124
- /** Remove what was handled, re-reading first so a sample noted meanwhile is not lost. */
125
- function settle(sid, handled, patch) {
126
+ /**
127
+ * Remove what was handled, re-reading first so a sample noted meanwhile is not lost. `limitsDone`:
128
+ * the limits report with this key was attempted — dropped unless a newer one was noted since.
129
+ */
130
+ function settle(sid, handled, patch, limitsDone) {
126
131
  const state = (0, claude_context_state_1.readState)(sid);
127
132
  state.queue = state.queue.filter((q) => !handled.has(q.id));
128
- (0, claude_context_state_1.writeState)({ ...state, ...patch, queue: state.queue });
133
+ const limits = state.limits && limitsDone !== undefined && (0, runtime_limits_1.limitsKey)(state.limits) === limitsDone ? undefined : state.limits;
134
+ (0, claude_context_state_1.writeState)({ ...state, ...patch, queue: state.queue, limits });
129
135
  }
130
136
  /** `baychat context-flush --claude <id>`: send what the status line and hooks noted. */
131
137
  async function runContextFlush(argv, deps = {}) {
132
- const d = { now: Date.now, post: context_report_1.postContextEvent, loadDevice: config_1.loadDeviceCredentials, identify: identifySession, ...deps };
138
+ const d = {
139
+ now: Date.now,
140
+ post: context_report_1.postContextEvent,
141
+ postLimits: runtime_limits_1.postSessionLimits,
142
+ loadDevice: config_1.loadDeviceCredentials,
143
+ identify: identifySession,
144
+ ...deps,
145
+ };
133
146
  const i = argv.indexOf("--claude");
134
147
  const sid = (0, claude_context_state_1.safeId)(i >= 0 ? argv[i + 1] : undefined);
135
148
  if (!sid)
@@ -140,7 +153,7 @@ async function runContextFlush(argv, deps = {}) {
140
153
  try {
141
154
  for (let round = 0; round < 5; round++) {
142
155
  const state = (0, claude_context_state_1.readState)(sid);
143
- if (state.queue.length === 0)
156
+ if (state.queue.length === 0 && !state.limits)
144
157
  return 0;
145
158
  if ((0, claude_context_state_1.backoffUntil)() > d.now())
146
159
  return 0;
@@ -157,14 +170,14 @@ async function runContextFlush(argv, deps = {}) {
157
170
  // grow. Said once per Claude session, in the log.
158
171
  if (!state.unidentifiedAt)
159
172
  (0, claude_context_state_1.logContextError)(`Claude session ${sid} is not joined to BayChat as far as this machine can prove — not reporting its context`);
160
- settle(sid, all, { unidentifiedAt: d.now(), baychatSession: undefined, identifiedAt: undefined });
173
+ settle(sid, all, { unidentifiedAt: d.now(), baychatSession: undefined, identifiedAt: undefined }, state.limits && (0, runtime_limits_1.limitsKey)(state.limits));
161
174
  return 0;
162
175
  }
163
176
  const identityPatch = { baychatSession: identity.session, conversationId: identity.conversationId, identifiedAt: d.now(), unidentifiedAt: undefined };
164
177
  const device = d.loadDevice();
165
178
  if (!device) {
166
179
  (0, claude_context_state_1.logContextError)("not logged in to BayChat (`baychat login`) — context not reported");
167
- settle(sid, all, identityPatch);
180
+ settle(sid, all, identityPatch, state.limits && (0, runtime_limits_1.limitsKey)(state.limits));
168
181
  return 0;
169
182
  }
170
183
  const handled = new Set();
@@ -200,7 +213,17 @@ async function runContextFlush(argv, deps = {}) {
200
213
  settle(sid, handled, identityPatch);
201
214
  return 0;
202
215
  }
203
- settle(sid, handled, identityPatch);
216
+ // The plan's limits (TERMINAL_CREDITS.md): one attempt, whatever happens — the next status
217
+ // line brings a newer report within minutes, so nothing here is worth keeping or retrying.
218
+ let limitsDone;
219
+ if (state.limits) {
220
+ limitsDone = (0, runtime_limits_1.limitsKey)(state.limits);
221
+ const outcome = await d.postLimits({ baseUrl: device.baseUrl, token: device.token }, identity.session, state.limits, api_1.apiRequest);
222
+ if (outcome.kind === "refused" || outcome.kind === "failed") {
223
+ (0, claude_context_state_1.logContextError)(`could not report plan limits for "${identity.session}": ${outcome.message}`);
224
+ }
225
+ }
226
+ settle(sid, handled, identityPatch, limitsDone);
204
227
  }
205
228
  return 0;
206
229
  }
@@ -76,6 +76,7 @@ const claude_context_flush_1 = require("./claude-context-flush");
76
76
  const claude_context_state_1 = require("./claude-context-state");
77
77
  const claude_settings_1 = require("./claude-settings");
78
78
  const context_report_1 = require("./context-report");
79
+ const runtime_limits_1 = require("./relay/runtime-limits");
79
80
  // The state helpers and the sender live beside this file; re-exported so callers and tests have
80
81
  // one module to import from.
81
82
  var claude_context_state_2 = require("./claude-context-state");
@@ -165,17 +166,30 @@ function noteStatus(raw, deps) {
165
166
  }
166
167
  const sid = (0, claude_context_state_1.safeId)(status?.session_id);
167
168
  const sample = (0, context_report_1.claudeUsageFromStatus)(status);
168
- if (!sid || !sample)
169
+ // The plan's limits and running cost ride the same line (TERMINAL_CREDITS.md).
170
+ const limits = (0, runtime_limits_1.claudeLimitsFromStatus)(status);
171
+ if (!sid || (!sample && !limits))
169
172
  return false;
170
- const autoCompactAt = (0, context_report_1.claudeAutoCompactAt)(sample.size, autoCompactOverride(deps.env));
171
173
  const now = deps.now();
172
174
  const state = (0, claude_context_state_1.readState)(sid);
173
175
  if (state.unidentifiedAt && now - state.unidentifiedAt < claude_context_state_1.UNIDENTIFIED_RETRY_MS)
174
176
  return false;
175
- if (!(0, context_report_1.usageDue)(state.lastQueued, { ...sample, autoCompactAt }, now))
177
+ let queued = false;
178
+ if (sample) {
179
+ const autoCompactAt = (0, context_report_1.claudeAutoCompactAt)(sample.size, autoCompactOverride(deps.env));
180
+ if ((0, context_report_1.usageDue)(state.lastQueued, { ...sample, autoCompactAt }, now)) {
181
+ (0, claude_context_state_1.enqueue)(state, { runtime: "claude-code", event: "usage", used: sample.used, size: sample.size, autoCompactAt, source: "exact" });
182
+ state.lastQueued = { ...sample, autoCompactAt, at: now };
183
+ queued = true;
184
+ }
185
+ }
186
+ if (limits && (0, runtime_limits_1.limitsDue)(state.lastLimits, limits, now)) {
187
+ state.limits = limits;
188
+ state.lastLimits = { key: (0, runtime_limits_1.limitsKey)(limits), at: now };
189
+ queued = true;
190
+ }
191
+ if (!queued)
176
192
  return false;
177
- (0, claude_context_state_1.enqueue)(state, { runtime: "claude-code", event: "usage", used: sample.used, size: sample.size, autoCompactAt, source: "exact" });
178
- state.lastQueued = { ...sample, autoCompactAt, at: now };
179
193
  if (typeof status.transcript_path === "string")
180
194
  state.transcriptPath = status.transcript_path;
181
195
  (0, claude_context_state_1.writeState)(state);
@@ -1,6 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BAY_CHOICE = exports.CLAUDE_REACHABILITY = exports.CLAUDE_JOIN_STEPS = void 0;
3
+ exports.BAY_CHOICE = exports.CLAUDE_REACHABILITY = exports.CLAUDE_JOIN_STEPS = exports.CANNOT_WAKE_SENTENCE = exports.CHAT_LISTENING_SENTENCE = void 0;
4
+ /** What a chat-app session says once it listens: it hears the Bay only while the chat is open.
5
+ * Word for word the API's join result (apps/api/src/mcp/session-tools.ts). */
6
+ exports.CHAT_LISTENING_SENTENCE = "I'm listening while this chat is open; for 24/7 a terminal or VPS agent is the right place.";
7
+ /** What a session with no listening tool at all says, instead of failing silently or claiming
8
+ * it is connected. Word for word the API's join result (apps/api/src/mcp/session-tools.ts). */
9
+ exports.CANNOT_WAKE_SENTENCE = "This chat can't be woken by new BayChat messages. I'll read them when you write to me here, or ask me to check BayChat.";
4
10
  /** Claude's native entry point. An expired local relay login says nothing about a separately configured MCP connection. */
5
11
  exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native Monitor** (a WebSocket, or behind an HTTPS proxy a command). Search deferred tools once
6
12
  if needed, for \`join_session\`, \`listen_messages\`, \`get_delivery_status\`,
@@ -25,7 +31,8 @@ exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native Monitor** (a W
25
31
  Do not ask again when the owner already requested creation. A supplied group title
26
32
  also authorizes creation on GROUP_NOT_FOUND (a group id never does); never substitute a similarly named group.
27
33
  If blocked, state the exact requested title and needed action in your reply.
28
- 3. **Channel first.** If BayChat's channel \`listen\` tool exists
34
+ 3. **Start listening now, without asking. Joining is not finished until this step is done**;
35
+ never ask whether to start the listener. **Channel first.** If BayChat's channel \`listen\` tool exists
29
36
  (\`mcp__plugin_baychat_channel__listen\` from the plugin, or
30
37
  \`mcp__baychat_channel__listen\`), call it with \`{session: "<confirmed name>"}\`
31
38
  (add \`bay: "<Bay>"\` when the session joined a Bay other than the default).
@@ -34,7 +41,8 @@ exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native Monitor** (a W
34
41
  channel's feed. Go to step 4. If it answers inactive or failed, or the tool is
35
42
  absent, use the Monitor instead:
36
43
  call \`listen_messages({session: "<confirmed name>"})\`. Pass its returned
37
- \`monitor\` object directly to the native **Monitor** tool: it contains
44
+ \`monitor\` object directly to the native **Monitor** tool (or whatever native
45
+ WebSocket/event-stream tool this host has): it contains
38
46
  \`ws: {url, protocols}\` and \`timeout_ms\`. If your Monitor offers a
39
47
  \`persistent\` option, also set \`persistent: true\`. Keep its ticket private.
40
48
  **Behind an HTTPS proxy** (\`HTTPS_PROXY\` is set in your environment, or the
@@ -47,6 +55,9 @@ exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native Monitor** (a W
47
55
  4. Call \`get_delivery_status({session: "<confirmed name>"})\`. Only \`connected\`
48
56
  confirms incoming delivery. \`awaiting_connection\` means a ticket was created
49
57
  but Monitor has not connected; show that exact state, not "ready".
58
+ In a chat app (not a terminal), once connected say once: "${exports.CHAT_LISTENING_SENTENCE}"
59
+ **With no channel \`listen\` and no Monitor-like tool at all**, never claim
60
+ connected; tell the user: "${exports.CANNOT_WAKE_SENTENCE}"
50
61
  Print the confirmed name and room. Use that name as \`session\` on every
51
62
  later BayChat tool call. Read one \`get_messages\` batch with \`limit: 10\`;
52
63
  say hello once in the confirmed conversation unless already greeted.
@@ -58,11 +69,11 @@ exports.CLAUDE_JOIN_STEPS = `1. Use **remote BayChat MCP + native Monitor** (a W
58
69
  different logins. Do not diagnose both as down from one file.
59
70
  - For login or missing tools, use Automatic setup above; the session runs the
60
71
  terminal commands itself. Keep credentials inside the CLI and MCP client.
61
- - Native Monitor requires a supported Claude Code host (WebSocket sources are
72
+ - Native Monitor requires a supported host (in Claude Code, WebSocket sources are
62
73
  documented from 2.1.195). If the tool is missing or denied, or neither \`ws\`
63
- nor \`monitor_command\` connects, explain that incoming delivery is not
64
- connected and show the specific host limitation. Stop there; do not write
65
- your own shell listener or start local MCP or a relay fallback.
74
+ nor \`monitor_command\` connects, say incoming delivery is not connected and
75
+ why (with no listening tool at all, the sentence in step 4). Stop there; do not
76
+ write your own shell listener or start local MCP or a relay fallback.
66
77
  `;
67
78
  /** Native socket lifecycle, including the intentional-stop boundary. */
68
79
  exports.CLAUDE_REACHABILITY = `## Staying reachable — native remote messages
@@ -94,7 +105,9 @@ arrived. That is the runtime's limit, not a stop: **every time it happens, and o
94
105
  any other close**, call \`listen_messages\` with this session and the last cursor
95
106
  (from \`ready\`, \`messages\` or \`closed\`), open one replacement Monitor the same way
96
107
  (\`ws\` or \`monitor_command\`), and confirm
97
- \`get_delivery_status\` again. The reopened feed replays what it missed, so no
108
+ \`get_delivery_status\` again. Do it yourself, without asking. A BayChat restart
109
+ (close **1012**, or \`listen_messages\` failing) is the same: retry after 5 s, 30 s,
110
+ then every 2 minutes until it connects. The reopened feed replays what it missed, so no
98
111
  \`get_messages\` catch-up is needed unless a \`reset\` arrives. Skipping this
99
112
  leaves the session deaf to the Bay with its terminal still open.
100
113
  Keep the last cursor in this conversation; do not save tickets in files.
package/dist/cli-help.js CHANGED
@@ -140,6 +140,9 @@ Usage:
140
140
  came from, so "we can wake this headlessly"
141
141
  is a claim you can check
142
142
  baychat relay stop Stop the relay and disable it at boot
143
+ baychat relay log <session> [-f] What a Codex session the relay runs did: its
144
+ commands, file changes, answers and approval
145
+ cards. -f keeps following it
143
146
  baychat doctor [--json] Check every link between this machine and
144
147
  BayChat — credential, relay, and per runtime
145
148
  its MCP registration, skill, executable and
@@ -111,7 +111,8 @@ function claudeDesktopPaths(env) {
111
111
  function configPathFor(client, env) {
112
112
  switch (client) {
113
113
  case "codex":
114
- return `${env.home}/.codex/config.toml`;
114
+ // `$CODEX_HOME` is where Codex reads, when it is set — never the home directory's copy.
115
+ return env.codexHome ? node_path_1.default.join(env.codexHome, "config.toml") : `${env.home}/.codex/config.toml`;
115
116
  case "cursor":
116
117
  return `${env.home}/.cursor/mcp.json`;
117
118
  case "desktop":
@@ -152,6 +153,7 @@ function currentPathEnv() {
152
153
  localAppData: process.env.LOCALAPPDATA,
153
154
  listDirs: listDirsOnDisk,
154
155
  xdgConfigHome: process.env.XDG_CONFIG_HOME,
156
+ codexHome: process.env.CODEX_HOME || undefined,
155
157
  };
156
158
  }
157
159
  /**
@@ -193,6 +195,9 @@ function configPathsFor(client, env) {
193
195
  if (client === "desktop")
194
196
  return claudeDesktopPaths(env);
195
197
  const canonical = configPathFor(client, env);
198
+ // An explicit CODEX_HOME is the one place that Codex reads; a snap copy is not guessed beside it.
199
+ if (client === "codex" && env.codexHome && canonical)
200
+ return [canonical];
196
201
  const paths = [];
197
202
  const snap = snapConfigPath(client, env);
198
203
  if (snap)
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isolatedLoginReason = isolatedLoginReason;
4
+ exports.isolatedLoginNotice = isolatedLoginNotice;
5
+ const config_1 = require("./config");
6
+ /**
7
+ * Why this login must leave the computer's agent clients alone — or undefined when it is the
8
+ * computer's own login and may refresh them.
9
+ *
10
+ * The clients' configs are GLOBAL: `~/.claude.json`, Claude Desktop's config, Codex's
11
+ * `config.toml`, `~/.cursor/mcp.json`. They belong to the computer's default BayChat login. A
12
+ * login made with `BAYCHAT_CONFIG_DIR` set (a second setup, a QA relay) or against another server
13
+ * (`--base`, `BAYCHAT_API_URL`) used to rewrite all of them to ITS server and token on
14
+ * `baychat login --finish`, `baychat bays default` and `baychat update` — silently moving every
15
+ * agent on the computer to a different BayChat (found 2026-10-23). Such a login now saves its own
16
+ * credential and says how to connect a client to it on purpose (`baychat connect <client>`).
17
+ */
18
+ function isolatedLoginReason(baseUrl, env = process.env) {
19
+ // The deliberate way back, for a person whose own clients DO belong to this setup.
20
+ if (env.BAYCHAT_REFRESH_CLIENTS === "1")
21
+ return undefined;
22
+ if (env.BAYCHAT_CONFIG_DIR)
23
+ return `BAYCHAT_CONFIG_DIR is set (${env.BAYCHAT_CONFIG_DIR})`;
24
+ const norm = (url) => url.trim().replace(/\/+$/, "").toLowerCase();
25
+ if (norm(baseUrl) !== norm(config_1.DEFAULT_API_URL))
26
+ return `this login is for ${baseUrl}, not ${config_1.DEFAULT_API_URL}`;
27
+ return undefined;
28
+ }
29
+ /** The line printed instead of touching the clients. */
30
+ function isolatedLoginNotice(reason) {
31
+ return (` Your agent clients (Claude Code, Claude Desktop, Codex, Cursor) were left as they are: ${reason}. ` +
32
+ "To use this login in one of them, run `baychat connect <client>` with the same settings " +
33
+ "(or set BAYCHAT_REFRESH_CLIENTS=1 if every client on this computer should follow this login).");
34
+ }