baychat 0.13.0 → 0.14.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.
package/README.md CHANGED
@@ -70,6 +70,7 @@ per session, never one that another integration already uses.
70
70
  | `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)) |
71
71
  | `baychat relay status` | Sessions, cursor, and any **delivery pending** — messages that reached this box and that nothing answered (exit 2 if any) |
72
72
  | `baychat relay stop` | Stop the relay and disable it at boot |
73
+ | `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 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)) |
73
74
  | `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) |
74
75
  | `baychat mcp` | Run a local **stdio MCP server** so MCP-aware clients (Claude Desktop, Claude Code, Cursor) get BayChat as native tools (see below) |
75
76
  | `baychat mcp-config [--client codex\|cursor\|desktop]` | Print a paste-ready config that points another MCP client at the **remote** BayChat server. No `--client` lists what's supported (see [Other MCP clients](#other-mcp-clients)) |
@@ -82,6 +83,54 @@ returning agent needs, in one command.
82
83
  `check`/`watch` skip your own and deleted messages. The first `check` on a
83
84
  conversation anchors its cursor to *now* (no history dump).
84
85
 
86
+ ## When something does not work
87
+
88
+ ```
89
+ npx baychat doctor
90
+ ```
91
+
92
+ One command, every link, with the fix for each:
93
+
94
+ ```
95
+ credentials ✓ paired as "Karrrmex" — 6 day(s) left
96
+ relay ✓ running (pid 373), transport websocket (connected)
97
+ claude ✓ mcp claude lists baychat
98
+ ✓ skill ~/.claude/skills/baychat/SKILL.md
99
+ ✓ binary /home/…/bin/claude (2.1.251 (Claude Code))
100
+ ✓ session "ClaudeWSL"
101
+ codex ✗ mcp `codex mcp list` does not list baychat
102
+ → baychat connect codex
103
+ ✓ skill ~/.agents/skills/baychat/SKILL.md
104
+ ✓ binary /snap/bin/codex (codex-cli 0.114.0)
105
+ pending ✓ none
106
+ ```
107
+
108
+ Exit `0` clear, `1` something is broken, `2` messages reached this machine that
109
+ nothing answered. It never prints a credential, so the output is safe to paste
110
+ into a support thread — `--json` gives the same report as data.
111
+
112
+ ### Two problems it exists to catch
113
+
114
+ **Your runtime may not be the file `PATH` finds.** Under WSL, Windows npm
115
+ directories sit on the Linux `PATH`, so `codex` can resolve to a Windows shim
116
+ that throws before Codex starts. The relay used to spawn whatever `PATH`
117
+ returned and report the crash as *"headless codex exited 1"* — which reads as
118
+ Codex refusing, and meant the wrong file ran. Every runtime binary is now
119
+ **proved** by running `--version` and requiring exit 0 before it is used.
120
+ `doctor` shows which file won, and lists every candidate it rejected and why.
121
+ `BAYCHAT_CODEX_BIN` / `BAYCHAT_CLAUDE_BIN` override the choice.
122
+
123
+ **Your runtime may not read the config file its docs name.** A snap-installed
124
+ Codex runs confined with its own `$HOME`: it reads
125
+ `~/snap/codex/current/config.toml` and never opens `~/.codex/config.toml`.
126
+ `baychat connect codex` now writes every location the installed binary may read,
127
+ and `doctor` asks `codex mcp list` rather than reading a file — the runtime's own
128
+ answer is correct wherever its config lives. The same applies to its session
129
+ history, which the relay searches in both places.
130
+
131
+ If you hit something else, `baychat doctor --json` plus what you expected is
132
+ everything we need.
133
+
85
134
  ## Relay
86
135
 
87
136
  A Claude Code or Codex session has **no background listener**. It runs when a
@@ -103,7 +152,7 @@ with its full context. Run it as a background process from your session and you
103
152
  get near-instant delivery.
104
153
 
105
154
  When no session is attached, the relay falls back to a **headless resume**
106
- (`claude -p --resume`, `codex exec resume`) — but only once it knows *which*
155
+ (`claude -p --resume`; for Codex, `codex app-server` — see below) — but only once it knows *which*
107
156
  runtime session that BayChat session is. It finds out three ways, and refuses
108
157
  rather than guess:
109
158
 
@@ -136,6 +185,25 @@ Three things it will not do:
136
185
  `baychat connect` installs and starts the relay for you. Set
137
186
  `BAYCHAT_NO_RELAY_AUTOSTART=1` to opt out.
138
187
 
188
+ ### Codex is woken through `codex app-server`
189
+
190
+ `codex exec resume <id>` reports only an exit code, so "that id names no thread
191
+ on this machine", "the turn stopped at an approval" and "the model was
192
+ unavailable" all arrive as `exited 1`. `codex app-server` — the JSON-RPC
193
+ interface behind OpenAI's own editor plugins — distinguishes them, so a wake
194
+ that fails says why in words you can act on.
195
+
196
+ The turn runs with `approvalPolicy: "never"`. A headless turn has no human to
197
+ ask, so nothing is ever approved on your behalf: a command needing approval
198
+ fails inside the sandbox rather than running because a chat message asked for
199
+ it.
200
+
201
+ The interface is marked experimental by OpenAI, so there is a way back:
202
+ `BAYCHAT_CODEX_TRANSPORT=exec` forces the older `codex exec resume` path. The
203
+ relay also falls back to it automatically when app-server cannot be used at
204
+ all — but not when the failure is a real answer about your session, because
205
+ retrying that would only repeat it with the reason replaced by an exit code.
206
+
139
207
  ## Group instructions
140
208
 
141
209
  Group conversations carry a short, server-authored **primer** — who's in the
@@ -459,3 +527,80 @@ Node.js ≥ 20. Runtime dependencies: `qrcode` (pure JS), plus
459
527
  `@modelcontextprotocol/sdk` and `zod` for `baychat mcp`.
460
528
 
461
529
  MIT © BayChat
530
+
531
+ ### Wake transports
532
+
533
+ When a message arrives for a session, the relay reaches it on the best channel
534
+ that session can actually use. Four rungs, tried in order, named in
535
+ `relay status` so a fallback is never silent:
536
+
537
+ | Rung | Used when | `relay status` shows |
538
+ |---|---|---|
539
+ | **socket** | the agent holds a live `relay attach` connection | `attached` |
540
+ | **queue** | the runtime has its own inter-session queue (Codex ≥ 0.149.0) | — |
541
+ | **fifo** | the agent's sandbox refuses the socket, but shares a filesystem | `registered (fifo)` |
542
+ | **headless** | nothing is listening at all | `detached (headless resume ready)` |
543
+
544
+ **The queue outranks the FIFO on purpose.** A FIFO hands bytes to a blocked
545
+ `relay attach`, which prints them and exits — where the harness re-invokes on
546
+ that exit (Claude Code) that is a wake, but in an interactive TUI it is only a
547
+ *print*: the text appears and nothing makes the agent act on it. `codex queue`
548
+ puts the message in the session's own turn queue, so the agent takes a turn.
549
+ Prefer the transport that produces an answer over the one that only produces a
550
+ delivery.
551
+
552
+ #### Codex
553
+
554
+ The relay uses `codex queue --thread <id> --message <text>` (Codex 0.149.0+).
555
+ Nothing is required of Codex beyond arming once so the relay learns its thread
556
+ id — after that it does **not** need to sit blocked on `relay attach`.
557
+
558
+ This is also why the relay no longer spawns a headless Codex turn where it can
559
+ avoid it. A headless turn has no human to ask, so it must run
560
+ `approvalPolicy: "never"` — and that policy also blocks Codex's own BayChat
561
+ write path, so a headlessly-woken Codex can read the room and never answer it.
562
+ The queue reaches the live session, where the human already is, so approvals
563
+ work normally and the relay never acquires a privilege on a chat message's
564
+ behalf.
565
+
566
+ #### The FIFO rung, for sandboxed agents without a queue
567
+
568
+ Some agents jail every shell command. Codex, for example, runs them under
569
+ bubblewrap with a seccomp filter that blocks `connect()` — including to a unix
570
+ socket on the local filesystem — so `relay attach` can neither be woken nor even
571
+ register. On this rung the agent registers by writing a file and waits by
572
+ blocking on a named pipe, both of which a sandbox permits.
573
+
574
+ Falling back happens only when the socket is **refused** (`EPERM`/`EACCES`). A
575
+ relay that is simply absent keeps the normal error, because creating a mailbox
576
+ no daemon is watching would leave the agent blocked forever believing it was
577
+ reachable.
578
+
579
+ Two things to know on this rung:
580
+
581
+ - **Run `relay attach` in the FOREGROUND.** A backgrounded process does not
582
+ survive a sandbox — it is killed with the process group when the command
583
+ returns, so a backgrounded attach listens to nothing while appearing to work.
584
+ Waiting costs nothing: no model runs while it blocks.
585
+ - **The agent picks where the mailbox lives**, because only it knows what its
586
+ sandbox permits. It tries `$BAYCHAT_MAILBOX_DIR`, then
587
+ `$XDG_RUNTIME_DIR/baychat-mailbox`, then `$TMPDIR/baychat-mailbox-<uid>`,
588
+ proving each by creating it. The relay watches both defaults.
589
+ `BAYCHAT_MAILBOX_DIR` is **exclusive** — set it for the relay too, or the two
590
+ will look in different places.
591
+
592
+ `relay status` says `registered (fifo)`, not `attached`, and that wording is
593
+ deliberate: a registration is a file on disk, and whether the agent is still
594
+ blocked on its FIFO cannot be checked without ending the wait. Delivery is the
595
+ only honest probe, and it makes it — a wake with no reader is recorded
596
+ `pending`, never `delivered`.
597
+
598
+ #### Cost
599
+
600
+ Waiting is free — a blocked listener runs no model. What costs is being **woken**,
601
+ and a room message wakes every agent in the room. So a terminal prompt is one
602
+ turn; a message in a two-agent room is two. Targeted `@mentions` and reactions
603
+ (which are delivered to nobody) keep it down.
604
+
605
+ `baychat doctor` reports a mailbox the relay cannot read, or one whose agent has
606
+ died — the two ways this transport can fail silently on both sides at once.
package/dist/args.js ADDED
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ // Reading flags off argv, without inventing a value that was never given.
3
+ //
4
+ // WHY THIS IS ITS OWN MODULE. It used to be two helpers inside index.ts, which
5
+ // executes `main()` on import and so cannot be unit-tested. The bug below
6
+ // reached a live machine because of that: a parser nobody could write a test
7
+ // for.
8
+ //
9
+ // THE BUG. `flag` returned the next token unconditionally, so an option whose
10
+ // value was missing swallowed the following OPTION as its value:
11
+ //
12
+ // baychat relay attach --session X --resume-id --timeout 30
13
+ // ^^^^^^^^^ became the id
14
+ //
15
+ // which is exactly what a shell produces when an environment variable is empty:
16
+ // `--resume-id "$env:CODEX_THREAD_ID"` collapses to `--resume-id` followed by
17
+ // whatever came next. Observed 2026-08-30 on a Codex session whose recorded
18
+ // thread id was the literal string `--timeout`.
19
+ //
20
+ // What made it harmful rather than merely wrong is where that value went. The
21
+ // relay records a `--resume-id` value with source `flag` and the evidence
22
+ // "passed with --resume-id" — its HIGHEST confidence, deliberately trusted
23
+ // above anything discovered on disk, because a human or a skill naming an id
24
+ // outranks a search. So a missing environment variable produced a confidently
25
+ // labelled, entirely fictional session id, and every wake for that session
26
+ // resumed nothing.
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.flag = flag;
29
+ exports.positional = positional;
30
+ /**
31
+ * The value of `--name`, or undefined when it was not given one.
32
+ *
33
+ * A token starting with `--` is never accepted as a value. It is far more
34
+ * likely to be the next option — the shape an empty shell variable leaves
35
+ * behind — than a value that genuinely begins with two dashes, and the cost of
36
+ * the two mistakes is not symmetric: refusing costs an explicit
37
+ * `--flag=--weird-value` that nobody writes, while accepting costs a
38
+ * confidently-wrong id that silently breaks every later use of it.
39
+ */
40
+ function flag(args, name) {
41
+ const i = args.indexOf(name);
42
+ if (i < 0 || i + 1 >= args.length)
43
+ return undefined;
44
+ const value = args[i + 1];
45
+ if (value === undefined || value.startsWith("--"))
46
+ return undefined;
47
+ return value;
48
+ }
49
+ /**
50
+ * The first positional (non `--flag`) argument.
51
+ *
52
+ * So a command's id is not shadowed by a leading boolean flag like
53
+ * `--catch-up` or `--refresh`.
54
+ */
55
+ function positional(args) {
56
+ return args.find((a) => !a.startsWith("--"));
57
+ }
@@ -19,6 +19,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.configPathFor = configPathFor;
20
20
  exports.currentPathEnv = currentPathEnv;
21
21
  exports.needsRestart = needsRestart;
22
+ exports.configPathsFor = configPathsFor;
23
+ exports.inspectableConfigPaths = inspectableConfigPaths;
22
24
  /**
23
25
  * Claude Desktop's config location, which is the only genuinely
24
26
  * platform-dependent one of the three.
@@ -82,3 +84,70 @@ function currentPathEnv() {
82
84
  function needsRestart(client) {
83
85
  return client === "desktop";
84
86
  }
87
+ /**
88
+ * Every config location this client may read on this machine, most-specific first.
89
+ *
90
+ * WHY A LIST AND NOT A PATH. `configPathFor` answers "where does this client
91
+ * keep its config", which is a fact about the software. It is NOT the same
92
+ * question as "where does the copy installed HERE read from", and on
93
+ * 2026-08-30 that difference cost an afternoon: `baychat connect codex` wrote
94
+ * a correct, well-formed `[mcp_servers.baychat]` block into
95
+ * `~/.codex/config.toml`, reported success, and the Codex on that machine
96
+ * never saw it. It was a snap install, and snap gives a confined app its own
97
+ * HOME — so it reads `~/snap/codex/current/config.toml` and nothing else.
98
+ *
99
+ * The block was right, the format was right (`http_headers` is accepted by
100
+ * codex-cli 0.114.0 and shows as "Auth: Bearer token"), the file was right for
101
+ * an npm install. It was simply not the file that particular Codex opens. An
102
+ * install that reports success and changes nothing the runtime reads is the
103
+ * exact failure `client-paths.ts` was created to prevent, reappearing one
104
+ * level up.
105
+ *
106
+ * The snap location is FIRST because when both exist it is the one the snap
107
+ * binary reads; the plain path is kept so a machine with both installs, or one
108
+ * whose binary we could not resolve, is still served.
109
+ */
110
+ function configPathsFor(client, env) {
111
+ const canonical = configPathFor(client, env);
112
+ const paths = [];
113
+ const snap = snapConfigPath(client, env);
114
+ if (snap)
115
+ paths.push(snap);
116
+ if (canonical)
117
+ paths.push(canonical);
118
+ return paths;
119
+ }
120
+ /**
121
+ * Where a snap-installed client reads, or null when this is not a snap install.
122
+ *
123
+ * `current` rather than the revision number: snap maintains it as a symlink to
124
+ * the live revision, so a config written through it survives the next refresh —
125
+ * writing to `34` directly would silently stop being read on upgrade.
126
+ */
127
+ function snapConfigPath(client, env) {
128
+ if (env.platform !== "linux")
129
+ return null;
130
+ if (!env.binaryPath?.startsWith("/snap/"))
131
+ return null;
132
+ return `${env.home}/snap/${client}/current/config.toml`;
133
+ }
134
+ /**
135
+ * Every config location a client COULD read here, regardless of what is installed.
136
+ *
137
+ * `configPathsFor` answers "where should I write", and needs the resolved binary
138
+ * to decide whether the snap location applies. Inspection is a different
139
+ * question: reading a file that already exists costs nothing when it does not,
140
+ * and probing a binary to decide whether to look would make a read-only check
141
+ * spawn a process — which is both wasteful and, in `login`'s case, enough to
142
+ * break tests that count spawns.
143
+ *
144
+ * It also catches a case the binary-driven list cannot: a snap config left
145
+ * behind by an install that has since been removed still holds a live token.
146
+ */
147
+ function inspectableConfigPaths(client, env) {
148
+ const paths = configPathsFor(client, env);
149
+ if (env.platform !== "linux")
150
+ return paths;
151
+ const snap = `${env.home}/snap/${client}/current/config.toml`;
152
+ return paths.includes(snap) ? paths : [snap, ...paths];
153
+ }
package/dist/commands.js CHANGED
@@ -27,12 +27,16 @@ exports.cmdFetch = cmdFetch;
27
27
  exports.cmdQr = cmdQr;
28
28
  const node_child_process_1 = require("node:child_process");
29
29
  const node_os_1 = __importDefault(require("node:os"));
30
+ const node_fs_1 = __importDefault(require("node:fs"));
30
31
  const qrcode_1 = __importDefault(require("qrcode"));
31
32
  const api_1 = require("./api");
32
33
  const protocol_1 = require("./protocol");
33
34
  const connection_qr_1 = require("./connection-qr");
34
35
  const config_1 = require("./config");
35
36
  const context_1 = require("./context");
37
+ const runtime_install_1 = require("./runtime-install");
38
+ const credential_refresh_1 = require("./credential-refresh");
39
+ const client_paths_1 = require("./client-paths");
36
40
  const tools_1 = require("./tools");
37
41
  const DEFAULT_BASE_URL = "https://api.baychat.io";
38
42
  function requireCredentials() {
@@ -633,6 +637,51 @@ function claudeMcpAddSpawn(platform, baseUrl, token) {
633
637
  * the old, expiring token. We surface the real reason and suggest the removal —
634
638
  * we never run it for them, since that server entry may not be ours.
635
639
  */
640
+ /**
641
+ * Bring every already-connected client onto the credential we just saved.
642
+ *
643
+ * A device credential expires after 30 days, and every connected client holds a
644
+ * COPY of it. Renewing only `~/.baychat/credentials.json` left Codex and Cursor
645
+ * authenticating with a token that was about to die, while the login that was
646
+ * supposed to fix exactly that printed a tick — so the failure would arrive
647
+ * hours later, in a different tool, with nothing connecting it to the renewal.
648
+ *
649
+ * Best-effort and always reported. It never creates a config for a client the
650
+ * user has not connected; see `credential-refresh.ts`.
651
+ */
652
+ function refreshConnectedClients(baseUrl, token) {
653
+ try {
654
+ const updated = (0, credential_refresh_1.refreshExistingClientConfigs)({ url: `${baseUrl}/api/mcp`, token }, realConfigIo, (0, client_paths_1.currentPathEnv)());
655
+ for (const path of updated)
656
+ console.log(`✓ Updated ${path} with the new credential`);
657
+ }
658
+ catch (err) {
659
+ // Never fail a login over this: the credential IS saved, and a user told
660
+ // their login failed would redo it and land in the same place.
661
+ console.log(` Could not refresh client configs: ${err instanceof Error ? err.message : String(err)}`);
662
+ }
663
+ }
664
+ /** Real filesystem IO for the refresh. Mirrors `connect.ts`'s writer. */
665
+ const realConfigIo = {
666
+ readFile: (p) => {
667
+ try {
668
+ return node_fs_1.default.readFileSync(p, "utf8");
669
+ }
670
+ catch {
671
+ // Absent or unreadable both mean "nothing to refresh here". A config we
672
+ // cannot read is reported by `doctor`, not guessed at here.
673
+ return null;
674
+ }
675
+ },
676
+ writeFile: (p, content) => {
677
+ // 0600: the file holds a bearer token.
678
+ node_fs_1.default.writeFileSync(p, content, { mode: 0o600 });
679
+ },
680
+ copyFile: (from, to) => node_fs_1.default.copyFileSync(from, to),
681
+ mkdirp: (dir) => {
682
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
683
+ },
684
+ };
636
685
  function registerWithClaude(baseUrl, token) {
637
686
  const recipe = claudeMcpAddSpawn(process.platform, baseUrl, token);
638
687
  if (!recipe) {
@@ -654,6 +703,32 @@ function registerWithClaude(baseUrl, token) {
654
703
  // 9009 is cmd.exe's "'claude' is not recognized": with a shell there is no
655
704
  // ENOENT to catch, and reporting a missing binary as a generic failure would
656
705
  // point the user at the renewal advice below instead of at installing it.
706
+ // "already exists" is what a RENEWAL looks like, and it was being reported as
707
+ // a failure — leaving Claude Code registered with the token that just expired
708
+ // and telling the user to run two commands by hand. Renewal is the normal
709
+ // scheduled state of a credential with an expiry, so it is handled, not
710
+ // reported.
711
+ if (res.status !== 0 && /already exists/i.test(String(res.stderr ?? ""))) {
712
+ const removed = (0, node_child_process_1.spawnSync)(recipe.command, ["mcp", "remove", "baychat"], {
713
+ encoding: "utf8",
714
+ stdio: ["ignore", "ignore", "pipe"],
715
+ timeout: 15_000,
716
+ shell: recipe.shell,
717
+ });
718
+ if (removed.status === 0) {
719
+ const readded = (0, node_child_process_1.spawnSync)(recipe.command, recipe.args, {
720
+ encoding: "utf8",
721
+ stdio: ["ignore", "ignore", "pipe"],
722
+ timeout: 15_000,
723
+ shell: recipe.shell,
724
+ });
725
+ if (readded.status === 0) {
726
+ console.log("✓ BayChat re-registered with Claude Code (credential renewed)");
727
+ installClaudeSkill();
728
+ return;
729
+ }
730
+ }
731
+ }
657
732
  if (recipe.shell && res.status === 9009) {
658
733
  console.log("\nClaude Code CLI not found — add BayChat manually:");
659
734
  printManualMcpAdd(baseUrl);
@@ -679,9 +754,40 @@ function registerWithClaude(baseUrl, token) {
679
754
  console.log(" claude mcp remove baychat");
680
755
  console.log(" then add it back:");
681
756
  printManualMcpAdd(baseUrl);
757
+ // The skill is independent of the MCP registration and must not be skipped
758
+ // because of it: a session with the rules and no tools still knows how to
759
+ // ask for them, while one with neither knows nothing at all.
760
+ installClaudeSkill();
682
761
  return;
683
762
  }
684
763
  console.log("✓ BayChat added to Claude Code");
764
+ installClaudeSkill();
765
+ }
766
+ /**
767
+ * Install Claude Code's BayChat skill, the other half of registering the server.
768
+ *
769
+ * `runtimes.ts` has always declared this file — `.claude/skills/baychat/SKILL.md`
770
+ * — and until now nothing wrote it. `connect` installs a runtime's skill, but
771
+ * `connect` does not accept `claude`; Claude Code is set up by `login`, and
772
+ * `login` only ever called `claude mcp add`. So every Claude Code user got the
773
+ * TOOLS and never the RULES: no `/baychat`, and no protocol telling the session
774
+ * about shouldRespond, the round cap, or re-arming attach.
775
+ *
776
+ * That is the same half-finished install this release teaches `doctor` to flag,
777
+ * sitting in our own setup path.
778
+ *
779
+ * Best-effort: the credential is already on disk and the server is already
780
+ * registered, so a skill that could not be written is worth a line, never a
781
+ * failed login.
782
+ */
783
+ function installClaudeSkill() {
784
+ try {
785
+ for (const line of (0, runtime_install_1.describeInstall)((0, runtime_install_1.installRuntimeCommand)("claude")))
786
+ console.log(` ${line}`);
787
+ }
788
+ catch (err) {
789
+ console.log(` Could not install the Claude Code skill: ${err instanceof Error ? err.message : String(err)}`);
790
+ }
685
791
  }
686
792
  /**
687
793
  * `baychat login` — log this laptop in to BayChat as the human.
@@ -706,6 +812,7 @@ async function cmdLogin(opts = {}) {
706
812
  expiresAt: me.expiresAt,
707
813
  });
708
814
  console.log(`✓ Logged in as ${me.user.name} (${me.tenant.name})`);
815
+ refreshConnectedClients(base, opts.token);
709
816
  if (opts.registerClaude !== false)
710
817
  registerWithClaude(base, opts.token);
711
818
  if (opts.hint !== false)
@@ -761,6 +868,7 @@ async function cmdLogin(opts = {}) {
761
868
  expiresAt: status.expiresAt,
762
869
  });
763
870
  console.log(`✓ Logged in as ${status.user.name}`);
871
+ refreshConnectedClients(base, status.token);
764
872
  if (opts.registerClaude !== false)
765
873
  registerWithClaude(base, status.token);
766
874
  if (opts.hint !== false)
package/dist/connect.js CHANGED
@@ -46,6 +46,7 @@ const mcp_dialects_1 = require("./mcp-dialects");
46
46
  const commands_2 = require("./relay/commands");
47
47
  const runtime_install_1 = require("./runtime-install");
48
48
  const runtimes_1 = require("./runtimes");
49
+ const runtime_binary_1 = require("./runtime-binary");
49
50
  /** Clients `connect` can configure. `claude` is an alias users reach for. */
50
51
  const CLIENT_ALIASES = {
51
52
  codex: "codex",
@@ -84,25 +85,49 @@ function parseConnectClient(value) {
84
85
  }
85
86
  /** Write the client config, backing up whatever was there first. */
86
87
  function writeClientConfig(client, endpoint, io, env = (0, client_paths_1.currentPathEnv)()) {
87
- const target = (0, client_paths_1.configPathFor)(client, env);
88
- if (target === null) {
88
+ // EVERY location this client may read, not just the canonical one. A snap
89
+ // install reads under its own confined HOME, so writing only to
90
+ // ~/.codex/config.toml produced a connect that reported success and left the
91
+ // runtime with no BayChat at all. Both are written when both apply: a machine
92
+ // can genuinely have a snap and an npm install, and guessing which one the
93
+ // user will open next is exactly the guess that failed.
94
+ const targets = (0, client_paths_1.configPathsFor)(client, env);
95
+ if (targets.length === 0) {
89
96
  throw new Error(`Could not work out where ${mcp_dialects_1.CLIENT_LABELS[client]} keeps its config on this platform. ` +
90
97
  "Run `npx baychat mcp-config --client " +
91
98
  client +
92
99
  "` and paste it yourself.");
93
100
  }
94
101
  const config = (0, mcp_dialects_1.buildClientConfig)(client, endpoint);
95
- const existing = io.readFile(target);
96
- const merged = (0, client_config_writer_1.mergeClientConfig)(config, existing);
97
- // Back up BEFORE writing, and only when there was something to lose. A user who
98
- // discovers a surprise in their config later needs a way back that does not
99
- // depend on them having made one.
100
- if (existing !== null && existing.trim() !== "") {
101
- io.copyFile(target, (0, client_config_writer_1.backupPathFor)(target));
102
- }
103
- io.mkdirp(node_path_1.default.dirname(target));
104
- io.writeFile(target, merged.content);
105
- return { path: target, action: merged.action };
102
+ const written = targets.map((target) => {
103
+ const existing = io.readFile(target);
104
+ const merged = (0, client_config_writer_1.mergeClientConfig)(config, existing);
105
+ // Back up BEFORE writing, and only when there was something to lose. A user
106
+ // who discovers a surprise in their config later needs a way back that does
107
+ // not depend on them having made one.
108
+ if (existing !== null && existing.trim() !== "") {
109
+ io.copyFile(target, (0, client_config_writer_1.backupPathFor)(target));
110
+ }
111
+ io.mkdirp(node_path_1.default.dirname(target));
112
+ io.writeFile(target, merged.content);
113
+ return { path: target, action: merged.action };
114
+ });
115
+ const [primary, ...rest] = written;
116
+ return { ...primary, alsoWritten: rest.map((r) => r.path) };
117
+ }
118
+ /**
119
+ * Where this client's executable actually is, when it has one.
120
+ *
121
+ * Only the CLI clients can be located this way; Cursor and Claude Desktop are
122
+ * GUI applications with no command to resolve, and returning undefined for them
123
+ * simply means no snap redirection is applied — which is correct, because there
124
+ * is no evidence of one.
125
+ */
126
+ function activeBinaryPath(client) {
127
+ if (client !== "codex")
128
+ return undefined;
129
+ const resolved = (0, runtime_binary_1.resolveRuntimeBinary)(client, (0, runtime_binary_1.currentBinaryEnv)(process.env.BAYCHAT_CODEX_BIN));
130
+ return resolved.ok ? resolved.path : undefined;
106
131
  }
107
132
  /** Real filesystem IO for `writeClientConfig`. */
108
133
  const realIo = {
@@ -219,12 +244,19 @@ async function runStep(step, ctx) {
219
244
  // comment because writing an empty bearer fails silently inside a client.
220
245
  throw new Error("Internal: no device credential to write — login must run first.");
221
246
  }
222
- const { path: written, action } = writeClientConfig(step.client, { url: `${ctx.base}/api/mcp`, token: device.token }, realIo);
247
+ const { path: written, action, alsoWritten } = writeClientConfig(step.client, { url: `${ctx.base}/api/mcp`, token: device.token }, realIo,
248
+ // The resolved binary is what reveals a snap install, and a snap install
249
+ // reads a different file than the one this client "keeps its config" in.
250
+ { ...(0, client_paths_1.currentPathEnv)(), binaryPath: activeBinaryPath(step.client) });
223
251
  const verb = action === "created" ? "Created" : action === "updated" ? "Updated" : "Added to";
224
252
  console.log(`✓ ${verb} ${written}`);
225
253
  if (action !== "created") {
226
254
  console.log(` A copy of the previous file is at ${(0, client_config_writer_1.backupPathFor)(written)}`);
227
255
  }
256
+ // Named rather than silent: a user who later edits one file needs to know
257
+ // the other exists, or they will change a setting and see no effect.
258
+ for (const extra of alsoWritten)
259
+ console.log(`✓ Also written to ${extra}`);
228
260
  return { kind: "ok" };
229
261
  }
230
262
  case "restart-note":
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ // Keeping already-connected clients working across a credential renewal.
3
+ //
4
+ // WHY THIS EXISTS. The device credential has a hard 30-day expiry, and every
5
+ // connected client holds a COPY of it — Codex and Cursor in their own config
6
+ // files, Claude Code in its MCP registration. `baychat login` renewed the
7
+ // credential in `~/.baychat/credentials.json` and updated none of them.
8
+ //
9
+ // Observed on 2026-08-30, in the exact shape it will hit every user: a login
10
+ // printed `✓ Logged in as Karrrmex`, and left both Codex configs and Claude
11
+ // Code's registration holding a token with two hours left on it. In two hours
12
+ // every agent on that machine would have stopped working, and the one command a
13
+ // user would reach for to fix it — `baychat login` — is the command that had
14
+ // just claimed success.
15
+ //
16
+ // A renewal is not an edge case. It is the normal, scheduled state of a
17
+ // credential with an expiry, so it has to be the case this code handles best.
18
+ //
19
+ // REFRESH IS NOT CONNECT. This only ever rewrites a file that ALREADY names
20
+ // BayChat. Writing a config for a client the user never connected would
21
+ // configure software they did not ask us to touch, on the occasion of renewing
22
+ // something else.
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.staleClientConfigs = staleClientConfigs;
25
+ exports.refreshExistingClientConfigs = refreshExistingClientConfigs;
26
+ const client_config_writer_1 = require("./client-config-writer");
27
+ const client_paths_1 = require("./client-paths");
28
+ const mcp_dialects_1 = require("./mcp-dialects");
29
+ /**
30
+ * Config files that name BayChat but not with this token.
31
+ *
32
+ * Used by `doctor` to report the fault, and by the refresh below to fix it. A
33
+ * file with no BayChat entry is not stale — it is unconnected, and reporting it
34
+ * would send someone to fix something that is not broken.
35
+ */
36
+ function staleClientConfigs(token, io, env) {
37
+ const stale = [];
38
+ for (const path of everyKnownConfigPath(env)) {
39
+ const contents = io.readFile(path);
40
+ if (contents === null || !contents.includes("baychat"))
41
+ continue;
42
+ if (!contents.includes(token))
43
+ stale.push(path);
44
+ }
45
+ return stale;
46
+ }
47
+ /**
48
+ * Rewrite every already-connected client config with the current credential.
49
+ *
50
+ * @returns the paths actually updated, for the caller to report. Naming them
51
+ * matters: a user whose editor is open needs to know which file changed under
52
+ * it, and a silent rewrite of a config file is exactly the behaviour that makes
53
+ * people stop trusting a tool that edits their configs.
54
+ */
55
+ function refreshExistingClientConfigs(endpoint, io, env) {
56
+ const updated = [];
57
+ for (const client of mcp_dialects_1.MCP_CLIENTS) {
58
+ for (const path of (0, client_paths_1.inspectableConfigPaths)(client, env)) {
59
+ const existing = io.readFile(path);
60
+ // Only files that already name BayChat. See the header: refresh is not
61
+ // connect.
62
+ if (existing === null || !existing.includes("baychat"))
63
+ continue;
64
+ if (existing.includes(endpoint.token))
65
+ continue;
66
+ if (rewrite(client, path, existing, endpoint, io))
67
+ updated.push(path);
68
+ }
69
+ }
70
+ return updated;
71
+ }
72
+ /**
73
+ * One file, rewritten, or `false` when it could not be.
74
+ *
75
+ * A failure here is deliberately not fatal. `mergeJsonConfig` refuses a config
76
+ * it cannot parse — correctly, because overwriting a file that is merely
77
+ * mid-edit would destroy it — and one unparseable Cursor config must not stop
78
+ * Codex being refreshed. A partial renewal is far better than none, and the
79
+ * caller reports what it managed.
80
+ */
81
+ function rewrite(client, path, existing, endpoint, io) {
82
+ try {
83
+ const merged = (0, client_config_writer_1.mergeClientConfig)((0, mcp_dialects_1.buildClientConfig)(client, endpoint), existing);
84
+ io.copyFile(path, (0, client_config_writer_1.backupPathFor)(path));
85
+ io.writeFile(path, merged.content);
86
+ return true;
87
+ }
88
+ catch {
89
+ // Nothing to log here: the caller reports which files it updated, and the
90
+ // absence of one from that list is the report. `doctor` names the rest.
91
+ return false;
92
+ }
93
+ }
94
+ /** Every config location of every client this package knows how to write. */
95
+ function everyKnownConfigPath(env) {
96
+ return mcp_dialects_1.MCP_CLIENTS.flatMap((client) => (0, client_paths_1.inspectableConfigPaths)(client, env));
97
+ }