baychat 0.16.0 → 0.17.1

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/dist/connect.js CHANGED
@@ -32,8 +32,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
32
32
  };
33
33
  Object.defineProperty(exports, "__esModule", { value: true });
34
34
  exports.renderConnectMenu = renderConnectMenu;
35
+ exports.isClaudeConnectTarget = isClaudeConnectTarget;
35
36
  exports.parseConnectClient = parseConnectClient;
36
37
  exports.writeClientConfig = writeClientConfig;
38
+ exports.cmdConnectClaude = cmdConnectClaude;
37
39
  exports.cmdConnect = cmdConnect;
38
40
  const node_fs_1 = __importDefault(require("node:fs"));
39
41
  const node_path_1 = __importDefault(require("node:path"));
@@ -61,14 +63,32 @@ function renderConnectMenu() {
61
63
  "Connect this laptop to BayChat, once, and configure your client:",
62
64
  "",
63
65
  ...rows,
66
+ " npx baychat connect claude → Claude Code (refreshes its skill)",
64
67
  "",
65
68
  "You approve a QR on your phone. After that, in any coding session:",
66
69
  "",
67
70
  " /baychat <name> join BayChat as a session called <name>",
68
71
  "",
69
- "Claude Code needs no config — `npx baychat login` sets it up for you.",
72
+ "First-time Claude Code setup is `npx baychat login` it registers the MCP",
73
+ "server AND writes the skill. After that, `connect claude` refreshes the skill",
74
+ "on its own: no QR, no re-pairing. Run it after every CLI upgrade.",
70
75
  ].join("\n");
71
76
  }
77
+ /** Spellings of Claude Code accepted by `connect`. */
78
+ const CLAUDE_CONNECT_ALIASES = new Set(["claude", "claude-code", "claudecode"]);
79
+ /**
80
+ * Is this `connect` argument Claude Code?
81
+ *
82
+ * Claude Code is deliberately NOT in `MCP_CLIENTS`: that list is clients whose
83
+ * MCP config is a file we write, and Claude Code's is registered by
84
+ * `claude mcp add` instead. But refusing the word entirely made the runtime this
85
+ * product is mostly used from the only one you could not `connect` — and sent a
86
+ * user whose credential was perfectly valid to `login`, which opens a pairing QR,
87
+ * when all they needed was the skill file refreshed after a CLI upgrade.
88
+ */
89
+ function isClaudeConnectTarget(value) {
90
+ return CLAUDE_CONNECT_ALIASES.has(value.trim().toLowerCase());
91
+ }
72
92
  /**
73
93
  * Narrow a client argument.
74
94
  *
@@ -159,11 +179,38 @@ const realIo = {
159
179
  *
160
180
  * @returns 0 on success, 2 when the laptop login expired without approval.
161
181
  */
182
+ /**
183
+ * `baychat connect claude` — refresh Claude Code's skill.
184
+ *
185
+ * Deliberately does NOT touch the credential or the MCP registration. Both are
186
+ * `login`'s job, and re-doing them would put a device-pairing QR in front of
187
+ * somebody whose login is fine. Writing the skill needs no auth at all: it is
188
+ * generated locally from this CLI's own version, which is exactly why it goes
189
+ * stale on upgrade and exactly why this command has to exist.
190
+ */
191
+ function cmdConnectClaude() {
192
+ try {
193
+ for (const line of (0, runtime_install_1.describeInstall)((0, runtime_install_1.installRuntimeCommand)("claude")))
194
+ console.log(` ${line}`);
195
+ console.log("");
196
+ console.log(" Restart Claude Code once so it picks the skill up.");
197
+ console.log(" Not logged in yet? `npx baychat login` — that registers the MCP server too.");
198
+ return 0;
199
+ }
200
+ catch (err) {
201
+ console.log(`Could not install the Claude Code skill: ${err instanceof Error ? err.message : String(err)}`);
202
+ return 1;
203
+ }
204
+ }
162
205
  async function cmdConnect(clientArg, opts = {}) {
163
206
  if (clientArg === undefined) {
164
207
  console.log(renderConnectMenu());
165
208
  return 0;
166
209
  }
210
+ // Claude Code first: it is not an MCP-config client, so it must not reach
211
+ // `parseConnectClient`, which would reject it.
212
+ if (isClaudeConnectTarget(clientArg))
213
+ return cmdConnectClaude();
167
214
  const client = parseConnectClient(clientArg);
168
215
  const base = (opts.base || process.env.BAYCHAT_API_URL || config_1.DEFAULT_API_URL).replace(/\/$/, "");
169
216
  const steps = (0, connect_plan_1.planConnect)({
@@ -746,7 +746,7 @@ async function cmdRelayAttach(opts) {
746
746
  * bare name, which is exactly the behaviour that shipped before this existed — a
747
747
  * machine where PATH is fine keeps working, and one where it is not now works too.
748
748
  */
749
- function resolveRuntimeBin(runtime) {
749
+ function resolveRuntimeBin(runtime, env = process.env, execPath = process.execPath) {
750
750
  // Delegates to the prover rather than walking PATH itself. The hand-rolled
751
751
  // walk this replaces had two Windows faults, and both produced a recorded
752
752
  // path the daemon could never spawn:
@@ -763,7 +763,7 @@ function resolveRuntimeBin(runtime) {
763
763
  // `resolveRuntimeBinary` tries the platform's real extension order and PROVES
764
764
  // each candidate by running it, which is the same question this function was
765
765
  // always asking — just answered correctly.
766
- const decided = decideRuntimeBin(runtime);
766
+ const decided = decideRuntimeBin(runtime, env, execPath);
767
767
  return decided.ok ? decided.bin : undefined;
768
768
  }
769
769
  /**
@@ -775,16 +775,16 @@ function resolveRuntimeBin(runtime) {
775
775
  * then re-probed could still register `runtimeBin: undefined` — the exact state
776
776
  * the guard exists to prevent, reached by way of the guard.
777
777
  */
778
- function decideRuntimeBin(runtime) {
779
- const override = process.env[`BAYCHAT_${runtime.toUpperCase()}_BIN`];
780
- const resolved = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)(override));
778
+ function decideRuntimeBin(runtime, env = process.env, execPath = process.execPath) {
779
+ const override = env[`BAYCHAT_${runtime.toUpperCase()}_BIN`];
780
+ const resolved = (0, runtime_binary_1.resolveRuntimeBinary)(runtime, (0, runtime_binary_1.currentBinaryEnv)(override, env, execPath));
781
781
  if (!resolved.ok) {
782
782
  // An explicit override is a user ASSERTION. If it cannot be proven, stop —
783
783
  // see the guard in `cmdRelayAttach`. Absence of one is not an assertion, so
784
784
  // a plain failed probe keeps the best-effort fallback.
785
785
  if (override)
786
786
  return { ok: false, reason: (0, runtime_binary_1.summarizeResolutionFailure)(resolved) };
787
- return { ok: true, bin: managedRuntimeBin(runtime) };
787
+ return { ok: true, bin: managedRuntimeBin(runtime, env) };
788
788
  }
789
789
  try {
790
790
  // Resolve symlinks: ~/.local/bin/claude is typically a link into a versioned
@@ -797,10 +797,10 @@ function decideRuntimeBin(runtime) {
797
797
  return { ok: true, bin: resolved.path };
798
798
  }
799
799
  }
800
- function managedRuntimeBin(runtime) {
800
+ function managedRuntimeBin(runtime, env = process.env) {
801
801
  if (runtime !== "codex")
802
802
  return undefined;
803
- const root = process.env.CODEX_MANAGED_PACKAGE_ROOT?.trim();
803
+ const root = env.CODEX_MANAGED_PACKAGE_ROOT?.trim();
804
804
  if (!root)
805
805
  return undefined;
806
806
  const candidate = path.join(root, "bin", "codex.js");
@@ -41,6 +41,7 @@ exports.currentBinaryEnv = currentBinaryEnv;
41
41
  exports.summarizeResolutionFailure = summarizeResolutionFailure;
42
42
  const child_process_1 = require("child_process");
43
43
  const fs = __importStar(require("fs"));
44
+ const spawn_env_1 = require("./relay/spawn-env");
44
45
  /**
45
46
  * Resolve a runtime's executable, proving each candidate before accepting it.
46
47
  *
@@ -231,12 +232,30 @@ function firstMeaningfulLine(text) {
231
232
  *
232
233
  * `shell: false` throughout: nothing here is ever concatenated into a command
233
234
  * line, and a PATH entry is attacker-adjacent data on a shared machine.
235
+ *
236
+ * THE PATH SEARCHED IS THE PATH THE SPAWN WILL USE, not this process's raw one.
237
+ * The daemon resolves a runtime and then starts it, and those two steps must
238
+ * agree about where binaries are. `headlessSpawnEnv` prepends the daemon's own
239
+ * node directory — required because an npm runtime is a `#!/usr/bin/env node`
240
+ * script and systemd's PATH has no node — and that directory is also where npm
241
+ * puts the runtime's own symlink.
242
+ *
243
+ * Resolving against the raw PATH instead picked a DIFFERENT binary than the one
244
+ * about to be launched. Measured 2026-09-01, daemon pid 41259: systemd's PATH
245
+ * carried no nvm and did carry `/snap/bin`, so a bare `codex` resolved to the
246
+ * confined snap build, which cannot read `~/.codex/sessions`. Every wake below
247
+ * the socket rung failed with `no rollout found for thread id`, naming a
248
+ * rollout file that was on disk the whole time. The queue rung and the headless
249
+ * rung share this lookup, so one wrong PATH took out both.
250
+ *
251
+ * Only BARE names reach here — an absolute `SessionTarget.runtimeBin` is never
252
+ * re-resolved, and that rule is unchanged.
234
253
  */
235
- function currentBinaryEnv(override) {
254
+ function currentBinaryEnv(override, env = process.env, execPath = process.execPath) {
236
255
  const delimiter = process.platform === "win32" ? ";" : ":";
237
256
  return {
238
257
  platform: process.platform,
239
- pathEntries: (process.env.PATH ?? "").split(delimiter),
258
+ pathEntries: ((0, spawn_env_1.headlessSpawnEnv)(env, execPath).PATH ?? "").split(delimiter),
240
259
  override,
241
260
  isExecutable(candidate) {
242
261
  try {
@@ -254,6 +273,24 @@ function currentBinaryEnv(override) {
254
273
  timeout: PROBE_TIMEOUT_MS,
255
274
  encoding: "utf8",
256
275
  shell: false,
276
+ // THE PROBE MUST BE ABLE TO START WHAT IT IS PROBING.
277
+ //
278
+ // An npm runtime is a `#!/usr/bin/env node` script, and a spawn inherits
279
+ // THIS process's environment — the daemon's, under systemd, with no node
280
+ // on it. So probing the real binary died with
281
+ // `/usr/bin/env: 'node': No such file or directory`, the candidate was
282
+ // rejected as broken rather than unstartable, and resolution fell through
283
+ // to `/snap/bin/codex`.
284
+ //
285
+ // Measured 2026-09-01 18:00 from the daemon's log, after the PATH search
286
+ // was already fixed: `codex resolved to /snap/bin/codex (codex-cli
287
+ // 0.114.0)`, then `queue did not deliver ... this codex build has no
288
+ // \`queue\` subcommand`. The snap is 0.114.0; `queue` landed in 0.149.0.
289
+ // So a probe that could not start a shebang script cost Codex the one
290
+ // rung a live session can answer on.
291
+ //
292
+ // Same environment the spawn will use, for the same reason.
293
+ env: (0, spawn_env_1.headlessSpawnEnv)(env, execPath),
257
294
  });
258
295
  return summarizeProbe({
259
296
  status: run.status,
package/dist/runtimes.js CHANGED
@@ -108,6 +108,55 @@ failure. Arm once, then get on with your work.
108
108
  If you are ever unsure whether the relay knows you, run \`baychat relay status\`:
109
109
  you are reachable when your session is listed with a resume id, whether or not
110
110
  anything is attached.`;
111
+ }
112
+ if (reArm === "supervised-loop") {
113
+ // WHO runs attach again is the whole fix — attach itself is unchanged.
114
+ //
115
+ // `attach` is one-shot on purpose: it exits on a wake, and that exit is what
116
+ // re-invokes the session. Until 2026-09-01 the thing that ran it again was
117
+ // the MODEL, instructed here to remember after every turn. One interrupted
118
+ // turn and the session is deaf, silently and for good.
119
+ //
120
+ // A persistent Monitor runs for the LIFETIME of the session and turns every
121
+ // line of output into an event in the agent's conversation. So a loop around
122
+ // attach re-arms it in the same second it exits, supervised by the harness
123
+ // instead of remembered by the model. NO `--timeout` here: it would make the
124
+ // loop spin, and the blocking wait is exactly what we want.
125
+ return `## Staying reachable — arm the loop once
126
+
127
+ If \`baychat relay\` is running on this machine (\`baychat connect\` starts it), start
128
+ this ONCE with the **Monitor** tool, as soon as you have joined — \`persistent: true\`:
129
+
130
+ \`\`\`
131
+ while true; do
132
+ ${attachLine} 2>&1 \\
133
+ | grep --line-buffered -E "^WAKE|^ \\(|error|refused|denied|not reachable"
134
+ sleep 1
135
+ done
136
+ \`\`\`
137
+
138
+ **Run it exactly as written.** Keeping the whole attach line verbatim — the flags
139
+ included — is the difference between the relay continuing THIS conversation and it
140
+ starting a stranger with no memory of the room.
141
+
142
+ **Why a loop.** \`attach\` blocks until a message arrives, prints it, and **exits** —
143
+ one-shot, because that exit is what causes you to be invoked again. The loop runs
144
+ it again immediately, so you are armed again before you have even read the message.
145
+
146
+ **You never re-arm this by hand.** That is the entire point. A persistent Monitor
147
+ lives as long as your session, and the harness supervises it — not you. Do not
148
+ add \`--timeout\`; the blocking wait is what makes it work.
149
+
150
+ ${resumeNote}
151
+
152
+ **It also survives an interrupt.** Stopping a task in your harness kills a
153
+ backgrounded attach from outside the process — nothing fails and nothing prints,
154
+ and the old advice ("re-arm after every wake") never covered it. The Monitor is a
155
+ separate supervised task, so it keeps listening.
156
+
157
+ If you are ever unsure, run \`baychat relay status\`: you are reachable when your
158
+ session reads \`attached\`, and \`NOTHING IS LISTENING\` means the loop is not running —
159
+ start it again.`;
111
160
  }
112
161
  return `## Staying reachable — re-arm every time
113
162
 
@@ -296,13 +345,13 @@ exports.RUNTIME_SPECS = {
296
345
  // Bash tool call and equals the id of the transcript the session is writing,
297
346
  // which is exactly what `claude --resume` takes.
298
347
  relay: {
299
- reArm: "background-every-wake",
348
+ reArm: "supervised-loop",
300
349
  runtime: "claude",
301
350
  sessionIdExpr: "$CLAUDE_CODE_SESSION_ID",
302
351
  resumeNote: `Keep the \`--resume-id\` flag: \`$CLAUDE_CODE_SESSION_ID\` is your own session id,
303
352
  and it is what lets the relay run \`claude -p --resume\` and continue THIS conversation
304
- rather than start a stranger with no memory of the room. Re-arm attach after every
305
- wake anyway resuming is the fallback, not the plan.`,
353
+ rather than start a stranger with no memory of the room. Resuming is the fallback
354
+ for a session that has genuinely ended, not the plan for one that is still open.`,
306
355
  },
307
356
  needsRestart: false,
308
357
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.16.0",
4
- "description": "BayChat connector CLI \u2014 pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
3
+ "version": "0.17.1",
4
+ "description": "BayChat connector CLI pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
5
5
  "bin": {
6
6
  "baychat": "dist/index.js"
7
7
  },