baychat 0.33.0 → 0.33.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.33.0",
3
+ "version": "0.33.2",
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": {
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.onReleaseAnnounced = onReleaseAnnounced;
4
+ exports.announceRelease = announceRelease;
5
+ let listener;
6
+ let log = (line) => console.error(line);
7
+ function onReleaseAnnounced(fn, logLine) {
8
+ listener = fn;
9
+ if (logLine)
10
+ log = logLine;
11
+ }
12
+ function announceRelease(version) {
13
+ try {
14
+ listener?.(version);
15
+ }
16
+ catch (err) {
17
+ // A hint that cannot be taken is a hint missed; the daily check still comes.
18
+ log(`auto-update: could not take the server's release hint (${err instanceof Error ? err.message : String(err)})`);
19
+ }
20
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HELD_FRESH_MS = exports.QUIET_MS = exports.CARD_POLL_MS = exports.ASK_AGAIN_MS = exports.CHECK_INTERVAL_MS = exports.AUTO_UPDATE_ENV = void 0;
3
+ exports.HELD_FRESH_MS = exports.QUIET_MS = exports.CARD_POLL_MS = exports.ASK_AGAIN_MS = exports.HINT_COOLDOWN_MS = exports.HINT_SPREAD_MS = exports.HINT_MIN_MS = exports.CHECK_INTERVAL_MS = exports.AUTO_UPDATE_ENV = void 0;
4
4
  exports.planUpdate = planUpdate;
5
5
  exports.autoUpdateOffReason = autoUpdateOffReason;
6
6
  exports.nextCheckAt = nextCheckAt;
@@ -50,6 +50,13 @@ const CHECK_SPREAD_MS = HOUR;
50
50
  /** The first check after a start: soon, but not in the busy first minutes. */
51
51
  const FIRST_CHECK_MIN_MS = 5 * MINUTE;
52
52
  const FIRST_CHECK_SPREAD_MS = 10 * MINUTE;
53
+ /** The server named a newer release in the feed's `ready` frame (hint.ts): npm is asked within
54
+ * 1–5 minutes — spread so a fleet that hears the same frame never asks npm together. */
55
+ exports.HINT_MIN_MS = MINUTE;
56
+ exports.HINT_SPREAD_MS = 4 * MINUTE;
57
+ /** A hint right after a check is not taken: a server that names a release on every reconnect
58
+ * must not become an npm query on every reconnect. */
59
+ exports.HINT_COOLDOWN_MS = 15 * MINUTE;
53
60
  /** A check that failed (offline, npm down) is tried again sooner than a day, then backs off. */
54
61
  const CHECK_RETRY_MS = HOUR;
55
62
  /** A failed install of one version waits a day, then two, … up to a week. A newer version resets it. */
@@ -10,6 +10,7 @@ const refresh_clients_1 = require("../refresh-clients");
10
10
  const waiting_cards_1 = require("../waiting-cards");
11
11
  const policy_1 = require("./policy");
12
12
  const store_1 = require("./store");
13
+ const hint_1 = require("./hint");
13
14
  const updater_1 = require("./updater");
14
15
  /**
15
16
  * The automatic update wired to a real relay: npm, the disk, the device API for the card, and the
@@ -50,6 +51,8 @@ function startRelayUpdater(opts) {
50
51
  };
51
52
  const updater = new updater_1.AutoUpdater(deps);
52
53
  updater.start();
54
+ // The server's feed names the newest release in each `ready` frame: check npm soon, not tomorrow.
55
+ (0, hint_1.onReleaseAnnounced)((version) => updater.hint(version), opts.log);
53
56
  return updater;
54
57
  }
55
58
  /**
@@ -17,6 +17,10 @@ class AutoUpdater {
17
17
  running = false;
18
18
  stopped = false;
19
19
  lastCardPoll = 0;
20
+ /** A release the server named (hint.ts), and when npm is asked about it. In memory only: a
21
+ * restarted relay hears the next `ready` frame anyway. */
22
+ hinted;
23
+ hintCheckAt;
20
24
  constructor(deps) {
21
25
  this.deps = deps;
22
26
  this.startedAt = deps.now();
@@ -46,6 +50,35 @@ class AutoUpdater {
46
50
  failure: state.failure?.reason,
47
51
  };
48
52
  }
53
+ /**
54
+ * The server's feed named `version` as the newest BayChat (hint.ts). Newer than this copy, and
55
+ * newer than what npm last said: ask npm in 1–5 minutes instead of at the daily check. Only the
56
+ * moment moves — npm still names the version, and planUpdate still decides what happens.
57
+ *
58
+ * The moment never moves earlier than the backoff allows: not within HINT_COOLDOWN_MS of the
59
+ * last check, and not while a failed check waits out its retry (npm down stays npm down,
60
+ * however often the server reconnects).
61
+ */
62
+ hint(version) {
63
+ const { mode } = this.mode();
64
+ if (mode === "off" || mode === "none")
65
+ return;
66
+ const ahead = (0, self_upgrade_1.compareVersions)(version, this.deps.current);
67
+ if (ahead === undefined || ahead <= 0 || this.hinted === version)
68
+ return;
69
+ const state = this.deps.loadState();
70
+ const known = state.latest ? (0, self_upgrade_1.compareVersions)(state.latest, version) : undefined;
71
+ if (known !== undefined && known >= 0)
72
+ return; // npm already named it (or newer)
73
+ const now = this.deps.now();
74
+ if (state.lastCheckAt !== undefined && now - state.lastCheckAt < policy_1.HINT_COOLDOWN_MS)
75
+ return;
76
+ if ((state.checkFailures ?? 0) > 0 && now < (0, policy_1.nextCheckAt)(state, this.startedAt, this.spread))
77
+ return;
78
+ this.hinted = version;
79
+ this.hintCheckAt = now + policy_1.HINT_MIN_MS + this.deps.random() * policy_1.HINT_SPREAD_MS;
80
+ this.deps.log(`auto-update: the server names ${version}; asking npm within minutes`);
81
+ }
49
82
  /** One look at the state. Never throws; never runs twice at once. */
50
83
  async tick() {
51
84
  await this.runExclusive(async () => {
@@ -64,8 +97,11 @@ class AutoUpdater {
64
97
  if (mode === "off" || mode === "none")
65
98
  return;
66
99
  const now = this.deps.now();
67
- if (now >= (0, policy_1.nextCheckAt)(state, this.startedAt, this.spread))
100
+ const hinted = this.hintCheckAt !== undefined && now >= this.hintCheckAt;
101
+ if (hinted || now >= (0, policy_1.nextCheckAt)(state, this.startedAt, this.spread)) {
102
+ this.hintCheckAt = undefined;
68
103
  await this.check(state, mode);
104
+ }
69
105
  if (mode !== "auto")
70
106
  return;
71
107
  if (state.card?.requestId && now - this.lastCardPoll >= policy_1.CARD_POLL_MS)
package/dist/bay-mcp.js CHANGED
@@ -20,6 +20,7 @@ exports.realClaudeRun = realClaudeRun;
20
20
  exports.syncBayEntriesWithClaude = syncBayEntriesWithClaude;
21
21
  exports.applyBayEntries = applyBayEntries;
22
22
  const node_child_process_1 = require("node:child_process");
23
+ const windows_cmd_1 = require("./windows-cmd");
23
24
  const client_config_writer_1 = require("./client-config-writer");
24
25
  const client_paths_1 = require("./client-paths");
25
26
  const mcp_dialects_1 = require("./mcp-dialects");
@@ -96,21 +97,20 @@ function claudeBayAddArgs(name, baseUrl, token) {
96
97
  `Authorization: Bearer ${token}`,
97
98
  ];
98
99
  }
99
- /** Characters cmd.exe quoting cannot hold (commands.ts `WINDOWS_UNSAFE`). */
100
- const WINDOWS_UNSAFE = /["%\u0000-\u001f\u007f]/;
101
100
  /** Run `claude <args>` the way `registerWithClaude` does, or null when it cannot run safely. */
102
101
  function realClaudeRun(platform = process.platform) {
103
102
  return (args) => {
104
- const shell = platform === "win32";
105
- if (shell && args.some((a) => WINDOWS_UNSAFE.test(a))) {
103
+ const windows = platform === "win32";
104
+ if (windows && args.some((a) => windows_cmd_1.WINDOWS_UNSAFE.test(a))) {
106
105
  return { status: null, stderr: "", error: new Error("unsafe argument for cmd.exe") };
107
106
  }
108
- const res = (0, node_child_process_1.spawnSync)("claude", shell ? args.map((a) => `"${a}"`) : args, {
107
+ const run = windows ? (0, windows_cmd_1.cmdExeSpawn)("claude", args.map(windows_cmd_1.quoteForCmd)) : { command: "claude", args };
108
+ const res = (0, node_child_process_1.spawnSync)(run.command, run.args, {
109
109
  encoding: "utf8",
110
110
  stdio: ["ignore", "ignore", "pipe"],
111
111
  timeout: 15_000,
112
112
  killSignal: "SIGKILL",
113
- shell,
113
+ windowsVerbatimArguments: windows,
114
114
  });
115
115
  return { status: res.status, stderr: String(res.stderr ?? ""), ...(res.error ? { error: res.error } : {}) };
116
116
  };
@@ -113,7 +113,11 @@ function channelLaunchFor(claudePath, deps = {}) {
113
113
  return { on: true, args: [detect_1.DEV_CHANNELS_FLAG, ...entries], env: { ...exports.CHANNEL_ENV } };
114
114
  }
115
115
  function probeVersion(claudePath) {
116
- const probe = (0, node_child_process_1.spawnSync)(claudePath, ["--version"], { encoding: "utf8", timeout: 10_000 });
116
+ // claude.cmd on Windows cannot be spawned as is (EINVAL), and then the channel stayed off.
117
+ const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(claudePath, process.platform), ["--version"]);
118
+ if (!run.ok)
119
+ return undefined;
120
+ const probe = (0, node_child_process_1.spawnSync)(run.file, run.args, { encoding: "utf8", timeout: 10_000, windowsVerbatimArguments: run.verbatim });
117
121
  return probe.error || probe.status !== 0 ? undefined : String(probe.stdout ?? "");
118
122
  }
119
123
  /**
package/dist/commands.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.realConfigIo = exports.WINDOWS_UNSAFE = exports.CATCHUP_UNTRUSTED_REMINDER = void 0;
6
+ exports.realConfigIo = exports.WINDOWS_UNSAFE = exports.quoteForCmd = exports.cmdExeSpawn = exports.CATCHUP_UNTRUSTED_REMINDER = void 0;
7
7
  exports.requireCredentials = requireCredentials;
8
8
  exports.cmdPair = cmdPair;
9
9
  exports.cmdWhoami = cmdWhoami;
@@ -18,7 +18,7 @@ exports.resetSessionState = resetSessionState;
18
18
  exports.cmdCheck = cmdCheck;
19
19
  exports.cmdWatch = cmdWatch;
20
20
  exports.cmdLink = cmdLink;
21
- exports.quoteForCmd = quoteForCmd;
21
+ exports.claudeSpawn = claudeSpawn;
22
22
  exports.claudeMcpAddSpawn = claudeMcpAddSpawn;
23
23
  exports.refreshConnectedClients = refreshConnectedClients;
24
24
  exports.registerWithClaude = registerWithClaude;
@@ -37,6 +37,7 @@ const protocol_1 = require("./protocol");
37
37
  const connection_qr_1 = require("./connection-qr");
38
38
  const config_1 = require("./config");
39
39
  const bay_mcp_1 = require("./bay-mcp");
40
+ const windows_cmd_1 = require("./windows-cmd");
40
41
  const context_1 = require("./context");
41
42
  const runtime_install_1 = require("./runtime-install");
42
43
  const credential_refresh_1 = require("./credential-refresh");
@@ -570,44 +571,37 @@ function printManualMcpAdd(baseUrl) {
570
571
  console.log(` claude mcp add --transport http --scope user baychat ${baseUrl}/api/mcp \\`);
571
572
  console.log(` --header "Authorization: Bearer <your token — in ~/.baychat/credentials.json under device.token>"`);
572
573
  }
574
+ var windows_cmd_2 = require("./windows-cmd");
575
+ Object.defineProperty(exports, "cmdExeSpawn", { enumerable: true, get: function () { return windows_cmd_2.cmdExeSpawn; } });
576
+ Object.defineProperty(exports, "quoteForCmd", { enumerable: true, get: function () { return windows_cmd_2.quoteForCmd; } });
577
+ Object.defineProperty(exports, "WINDOWS_UNSAFE", { enumerable: true, get: function () { return windows_cmd_2.WINDOWS_UNSAFE; } });
573
578
  /**
574
- * cmd.exe quoting: wrap the whole argument so spaces, `&`, `|` and `>` inside it
575
- * stay literal. Only usable on values `WINDOWS_UNSAFE` has already cleared.
576
- */
577
- function quoteForCmd(arg) {
578
- return `"${arg}"`;
579
- }
580
- /**
581
- * Characters that survive — or break out of — cmd.exe double quotes.
582
- *
583
- * A `"` ends the wrapper, so everything after it is parsed as shell syntax. A
584
- * `%NAME%` is expanded *inside* quotes. A newline ends the command line. Nothing
585
- * else in cmd's metacharacter set (`&`, `|`, `<`, `>`, `^`) is interpreted while
586
- * quoted, and `!` only expands under delayed expansion, which `cmd /d /s /c` —
587
- * what Node's `shell: true` invokes — does not enable.
588
- */
589
- exports.WINDOWS_UNSAFE = /["%\u0000-\u001f\u007f]/;
590
- /**
591
- * The spawn recipe for `claude mcp add`, or null when it cannot be run safely.
579
+ * The spawn recipe for `claude <args>`, or null when it cannot be run safely.
592
580
  *
593
581
  * On Windows `claude` is a `.cmd` shim, and Node has refused to spawn `.bat` /
594
- * `.cmd` without `shell: true` since the CVE-2024-27980 fix (18.20.2 /
595
- * 20.12.2+) — it throws EINVAL. Every runtime this package supports (node >=20)
596
- * is past that fix, so naming `claude.cmd` explicitly cannot work either: a
597
- * shell is the only route.
582
+ * `.cmd` directly since the CVE-2024-27980 fix (18.20.2 / 20.12.2+) — it throws
583
+ * EINVAL. Every runtime this package supports (node >=20) is past that fix, so
584
+ * naming `claude.cmd` explicitly cannot work either: cmd.exe is the only route
585
+ * (`cmdExeSpawn`, the same line `shell: true` used to build).
598
586
  *
599
- * The cost of a shell is that arguments become shell syntax. Node does NOT
600
- * escape them — with `shell: true` on Windows it joins argv with spaces and
601
- * hands the string to `cmd.exe /d /s /c` verbatim — so `Authorization: Bearer
602
- * <token>` would arrive as three separate arguments, and a `&` in an
603
- * interpolated value would arrive as a command separator. Hence: quote every
604
- * argument here, and refuse outright when an interpolated value contains
605
- * something quoting cannot contain. Refusing costs the user one manual paste;
606
- * guessing would run their token through a command interpreter.
587
+ * The cost of cmd.exe is that arguments become shell syntax. It gets one string
588
+ * of argv joined with spaces, so `Authorization: Bearer <token>` would arrive as
589
+ * three separate arguments, and a `&` in an interpolated value would arrive as a
590
+ * command separator. Hence: quote every argument here, and refuse outright when
591
+ * a value contains something quoting cannot contain. Refusing costs the user one
592
+ * manual paste; guessing would run their token through a command interpreter.
607
593
  *
608
594
  * POSIX keeps `execve` semantics — argv is passed verbatim, there is no shell to
609
595
  * interpret it, and so nothing to quote or refuse.
610
596
  */
597
+ function claudeSpawn(platform, args) {
598
+ if (platform !== "win32")
599
+ return { command: "claude", args, viaCmd: false };
600
+ if (args.some((a) => windows_cmd_1.WINDOWS_UNSAFE.test(a)))
601
+ return null;
602
+ return { ...(0, windows_cmd_1.cmdExeSpawn)("claude", args.map(windows_cmd_1.quoteForCmd)), viaCmd: true };
603
+ }
604
+ /** `claude mcp add` for the remote BayChat server — see `claudeSpawn`. */
611
605
  function claudeMcpAddSpawn(platform, baseUrl, token) {
612
606
  const args = [
613
607
  "mcp",
@@ -621,11 +615,7 @@ function claudeMcpAddSpawn(platform, baseUrl, token) {
621
615
  "--header",
622
616
  `Authorization: Bearer ${token}`,
623
617
  ];
624
- if (platform !== "win32")
625
- return { command: "claude", args, shell: false };
626
- if (args.some((a) => exports.WINDOWS_UNSAFE.test(a)))
627
- return null;
628
- return { command: "claude", args: args.map(quoteForCmd), shell: true };
618
+ return claudeSpawn(platform, args);
629
619
  }
630
620
  /**
631
621
  * Register the remote BayChat MCP server with Claude Code, carrying the device
@@ -705,9 +695,9 @@ function registerWithClaude(baseUrl, token) {
705
695
  stdio: ["ignore", "ignore", "pipe"],
706
696
  timeout: 15_000,
707
697
  killSignal: "SIGKILL",
708
- shell: recipe.shell,
698
+ windowsVerbatimArguments: recipe.viaCmd,
709
699
  });
710
- // 9009 is cmd.exe's "'claude' is not recognized": with a shell there is no
700
+ // 9009 is cmd.exe's "'claude' is not recognized": through cmd.exe there is no
711
701
  // ENOENT to catch, and reporting a missing binary as a generic failure would
712
702
  // point the user at the renewal advice below instead of at installing it.
713
703
  // "already exists" is what a RENEWAL looks like, and it was being reported as
@@ -716,18 +706,20 @@ function registerWithClaude(baseUrl, token) {
716
706
  // scheduled state of a credential with an expiry, so it is handled, not
717
707
  // reported.
718
708
  if (res.status !== 0 && /already exists/i.test(String(res.stderr ?? ""))) {
719
- const removed = (0, node_child_process_1.spawnSync)(recipe.command, ["mcp", "remove", "baychat"], {
709
+ // Fixed words only, so this recipe is never null where `recipe` was not.
710
+ const remove = claudeSpawn(process.platform, ["mcp", "remove", "baychat"]);
711
+ const removed = (0, node_child_process_1.spawnSync)(remove.command, remove.args, {
720
712
  encoding: "utf8",
721
713
  stdio: ["ignore", "ignore", "pipe"],
722
714
  timeout: 15_000,
723
- shell: recipe.shell,
715
+ windowsVerbatimArguments: remove.viaCmd,
724
716
  });
725
717
  if (removed.status === 0) {
726
718
  const readded = (0, node_child_process_1.spawnSync)(recipe.command, recipe.args, {
727
719
  encoding: "utf8",
728
720
  stdio: ["ignore", "ignore", "pipe"],
729
721
  timeout: 15_000,
730
- shell: recipe.shell,
722
+ windowsVerbatimArguments: recipe.viaCmd,
731
723
  });
732
724
  if (readded.status === 0) {
733
725
  console.log("✓ BayChat re-registered with Claude Code (credential renewed)");
@@ -736,7 +728,7 @@ function registerWithClaude(baseUrl, token) {
736
728
  }
737
729
  }
738
730
  }
739
- if (recipe.shell && res.status === 9009) {
731
+ if (recipe.viaCmd && res.status === 9009) {
740
732
  console.log("\nClaude Code CLI not found — add BayChat manually:");
741
733
  printManualMcpAdd(baseUrl);
742
734
  return false;
@@ -25,9 +25,11 @@ function cursorLoginState(statusOutput) {
25
25
  }
26
26
  function runAgentStatus(binaryPath) {
27
27
  // No shell: the path came from PATH resolution. A Windows `.cmd` shim needs its interpreter named.
28
- const plan = (0, runtime_binary_1.spawnPlanFor)(binaryPath, process.platform);
28
+ const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(binaryPath, process.platform), ["status"]);
29
+ if (!run.ok)
30
+ return Promise.resolve(run.reason);
29
31
  return new Promise((resolve) => {
30
- (0, child_process_1.execFile)(plan.file, [...plan.prefixArgs, "status"], { timeout: STATUS_TIMEOUT_MS, windowsHide: true, shell: false }, (err, stdout, stderr) => {
32
+ (0, child_process_1.execFile)(run.file, run.args, { timeout: STATUS_TIMEOUT_MS, windowsHide: true, shell: false, windowsVerbatimArguments: run.verbatim }, (err, stdout, stderr) => {
31
33
  const said = `${stdout ?? ""}${stderr ?? ""}`;
32
34
  // A failed run is still an answer: "Not logged in" may come with a non-zero exit.
33
35
  resolve(said || (err ? err.message : ""));
@@ -147,9 +147,14 @@ async function currentDoctorEnv() {
147
147
  // Same .cmd rule as the probe: an npm-installed CLI on Windows is a shim
148
148
  // that cannot be spawned without a shell, and this call would otherwise
149
149
  // throw EINVAL and be reported as "could not run `codex mcp list`".
150
- const plan = (0, runtime_binary_1.spawnPlanFor)(resolved.path, process.platform);
150
+ const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(resolved.path, process.platform), ["mcp", "list"]);
151
+ if (!run.ok)
152
+ return null;
153
+ // execFileSync passes this on to spawnSync, which honours it; its option type leaves it out.
154
+ const verbatim = { windowsVerbatimArguments: run.verbatim };
151
155
  try {
152
- return (0, child_process_1.execFileSync)(plan.file, [...plan.prefixArgs, "mcp", "list"], {
156
+ return (0, child_process_1.execFileSync)(run.file, run.args, {
157
+ ...verbatim,
153
158
  encoding: "utf8",
154
159
  timeout: MCP_LIST_TIMEOUT_MS,
155
160
  stdio: ["ignore", "pipe", "pipe"],
@@ -74,6 +74,7 @@ const path = __importStar(require("path"));
74
74
  const readline = __importStar(require("readline"));
75
75
  const config_1 = require("./config");
76
76
  const terminal_pane_1 = require("./relay/terminal-pane");
77
+ const runtime_binary_1 = require("./runtime-binary");
77
78
  /**
78
79
  * Start `program` where session commands can reach it — inside tmux or screen when possible —
79
80
  * and return its exit status. Never refuses to start the agent: every failure on the way falls
@@ -93,9 +94,16 @@ async function launchAgent(program, naming, deps = realLaunchDeps()) {
93
94
  return runHere(program, deps);
94
95
  return result.status ?? 0;
95
96
  }
96
- /** Run the agent in this terminal, as it would run without BayChat. */
97
+ /** Run the agent in this terminal, as it would run without BayChat. On Windows an npm-installed
98
+ * agent is a `.cmd` shim, which Node will not spawn directly (EINVAL): `spawnPlanFor` starts
99
+ * what the shim runs. */
97
100
  function runHere(program, deps = realLaunchDeps()) {
98
- const result = deps.run(program.binary, program.args, { ...deps.env, ...program.env });
101
+ const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(program.binary, deps.platform), program.args);
102
+ if (!run.ok) {
103
+ deps.say(`Could not start ${path.basename(program.binary)}: ${run.reason}.`);
104
+ return 1;
105
+ }
106
+ const result = deps.run(run.file, run.args, { ...deps.env, ...program.env }, run.verbatim);
99
107
  if (result.error) {
100
108
  deps.say(`Could not start ${path.basename(program.binary)}: ${result.error.message}`);
101
109
  return 1;
@@ -222,8 +230,8 @@ function realLaunchDeps() {
222
230
  env: process.env,
223
231
  atTerminal: !!process.stdin.isTTY && !!process.stdout.isTTY,
224
232
  pid: process.pid,
225
- run: (file, args, env) => {
226
- const r = (0, child_process_1.spawnSync)(file, args, { stdio: "inherit", env });
233
+ run: (file, args, env, verbatim) => {
234
+ const r = (0, child_process_1.spawnSync)(file, args, { stdio: "inherit", env, windowsVerbatimArguments: verbatim });
227
235
  return { status: r.status, ...(r.error ? { error: r.error } : {}) };
228
236
  },
229
237
  works: (program) => {
@@ -71,11 +71,14 @@ async function runAcpTurn(req, deps) {
71
71
  let child;
72
72
  try {
73
73
  // A Windows `.cmd` shim needs its interpreter named — same fix as `codex-app-server.ts`.
74
- const plan = (0, runtime_binary_1.spawnPlanFor)(req.binaryPath, deps.platform ?? process.platform);
75
- child = deps.spawn(plan.file, [...plan.prefixArgs, ...req.args], {
74
+ const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(req.binaryPath, deps.platform ?? process.platform), req.args);
75
+ if (!run.ok)
76
+ return failed("spawn", `the agent could not be started: ${run.reason}`);
77
+ child = deps.spawn(run.file, run.args, {
76
78
  cwd: req.cwd,
77
79
  env: req.env,
78
80
  shell: false,
81
+ windowsVerbatimArguments: run.verbatim,
79
82
  windowsHide: true,
80
83
  stdio: ["pipe", "pipe", "pipe"],
81
84
  });
@@ -397,6 +397,7 @@ function runHeadless(file, args, opts = {}) {
397
397
  return new Promise((resolve) => {
398
398
  const child = (0, child_process_1.spawn)(file, args, {
399
399
  windowsHide: true,
400
+ windowsVerbatimArguments: opts.windowsVerbatimArguments,
400
401
  cwd: opts.cwd,
401
402
  // An npm-installed runtime is a script with a `#!/usr/bin/env node`
402
403
  // shebang, and the daemon's systemd PATH has no node. See ./spawn-env.ts.
@@ -53,9 +53,13 @@ function runCodexTurn(req, deps) {
53
53
  // `delivery failed for CodexTest: spawn EINVAL` on every wake — the
54
54
  // transport failing before the handshake, on a machine where Codex was
55
55
  // installed and working.
56
- const plan = (0, runtime_binary_1.spawnPlanFor)(req.binaryPath, process.platform);
57
- const child = spawn(plan.file, [...plan.prefixArgs, "app-server"], {
56
+ // `app-server` is a fixed word, so a plan always yields argv — `!ok` is unreachable here.
57
+ const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(req.binaryPath, process.platform), ["app-server"]);
58
+ if (!run.ok)
59
+ return resolve({ kind: "failed", transportUnusable: true, reason: run.reason });
60
+ const child = spawn(run.file, run.args, {
58
61
  windowsHide: true,
62
+ windowsVerbatimArguments: run.verbatim,
59
63
  cwd: req.cwd,
60
64
  // Same reason as `runHeadless`: an npm-installed codex is a script with a
61
65
  // `#!/usr/bin/env node` shebang and the daemon's PATH has no node. Both
@@ -39,18 +39,14 @@ function queueToThread(input) {
39
39
  // interpreter is named explicitly. NEVER `shell: true`: the message is text
40
40
  // written by other people in the room, and a shell string would make `$(…)`
41
41
  // in a chat message run on this box.
42
- const plan = (0, runtime_binary_1.spawnPlanFor)(input.binaryPath, input.platform ?? process.platform);
42
+ const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(input.binaryPath, input.platform ?? process.platform), ["queue", "--thread", input.threadId, "--message", input.message]);
43
+ if (!run.ok)
44
+ return Promise.resolve({ kind: "failed", reason: run.reason });
43
45
  return new Promise((resolve) => {
44
- runner(plan.file, [
45
- ...plan.prefixArgs,
46
- "queue",
47
- "--thread",
48
- input.threadId,
49
- "--message",
50
- input.message,
51
- ], {
46
+ runner(run.file, run.args, {
52
47
  cwd: input.cwd,
53
48
  windowsHide: true,
49
+ windowsVerbatimArguments: run.verbatim,
54
50
  env: (0, spawn_env_1.headlessSpawnEnv)(),
55
51
  timeout: input.timeoutMs ?? QUEUE_TIMEOUT_MS,
56
52
  maxBuffer: 1024 * 1024,
@@ -80,11 +76,14 @@ function queueToThread(input) {
80
76
  }
81
77
  /** Verify native queue support without submitting a message or changing a task. */
82
78
  function verifyCodexQueue(binaryPath) {
83
- const plan = (0, runtime_binary_1.spawnPlanFor)(binaryPath, process.platform);
79
+ const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(binaryPath, process.platform), ["queue", "--help"]);
80
+ if (!run.ok)
81
+ return Promise.reject(new Error(run.reason));
84
82
  return new Promise((resolve, reject) => {
85
- (0, child_process_1.execFile)(plan.file, [...plan.prefixArgs, "queue", "--help"], {
83
+ (0, child_process_1.execFile)(run.file, run.args, {
86
84
  env: (0, spawn_env_1.headlessSpawnEnv)(),
87
85
  windowsHide: true,
86
+ windowsVerbatimArguments: run.verbatim,
88
87
  timeout: 5_000,
89
88
  maxBuffer: 64 * 1024,
90
89
  }, (error, stdout) => {
@@ -1281,12 +1281,19 @@ class RelayDaemon {
1281
1281
  // the path — resolution or the session's own runtimeBin — so the decision
1282
1282
  // lives here, after both, rather than inside either. Never a shell: these
1283
1283
  // args carry the wake prompt, and therefore other people's message text.
1284
- const plan = (0, runtime_binary_1.spawnPlanFor)(executable, process.platform);
1284
+ const run = (0, runtime_binary_1.planArgv)((0, runtime_binary_1.spawnPlanFor)(executable, process.platform), args);
1285
+ if (!run.ok) {
1286
+ // A .cmd we cannot start directly, and a prompt cmd.exe would read as syntax: say so
1287
+ // rather than run it through cmd.exe anyway.
1288
+ this.record({ kind: "pending", session, reason: run.reason }, session, batch);
1289
+ return;
1290
+ }
1285
1291
  // The session's own directory when we know it: `cwd` is only where the
1286
1292
  // attach process ran, and `codex exec` refuses to start outside a trusted
1287
1293
  // directory at all.
1288
- const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
1294
+ const { exitCode, stderr } = await this.spawnHeadless(run.file, run.args, {
1289
1295
  cwd: resolved.resumeCwd ?? resolved.cwd,
1296
+ ...(run.verbatim ? { windowsVerbatimArguments: true } : {}),
1290
1297
  });
1291
1298
  if (exitCode !== 0) {
1292
1299
  // The binary ran and the turn still failed, so what we resolved may be
package/dist/relay/ws.js CHANGED
@@ -27,6 +27,7 @@ const context_notice_1 = require("./context-notice");
27
27
  const project_notes_1 = require("./project-notes");
28
28
  const updates_1 = require("./updates");
29
29
  const server_registration_1 = require("./server-registration");
30
+ const hint_1 = require("../auto-update/hint");
30
31
  /** The published endpoint. Changing it breaks every deployed server — a wire constant. */
31
32
  exports.AGENT_WS_PATH = "/api/agent-api/ws";
32
33
  /** How long we wait for the server's `ready` after sending `hello`. */
@@ -132,6 +133,8 @@ function runWebSocketFeed(opts) {
132
133
  onSessions?.(frame.sessions, frame.registrations);
133
134
  if (frame.cursor)
134
135
  onCursor?.(frame.cursor);
136
+ if (frame.latestCli)
137
+ (0, hint_1.announceRelease)(frame.latestCli);
135
138
  onReady?.(frame.cursor ?? null);
136
139
  return;
137
140
  }
@@ -288,6 +291,8 @@ function parseFrame(raw) {
288
291
  cursor: str(frame.cursor),
289
292
  sessions: names(frame.sessions),
290
293
  registrations: (0, server_registration_1.parseRegistrations)(frame.registrations),
294
+ // Plain x.y.z only; anything else is dropped (a hint, never what installs: auto-update/hint.ts).
295
+ latestCli: typeof frame.latestCli === "string" && /^\d+\.\d+\.\d+$/.test(frame.latestCli) ? frame.latestCli : undefined,
291
296
  };
292
297
  case "cursor":
293
298
  return {
@@ -36,10 +36,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.resolveRuntimeBinary = resolveRuntimeBinary;
37
37
  exports.describeResolution = describeResolution;
38
38
  exports.spawnPlanFor = spawnPlanFor;
39
+ exports.planArgv = planArgv;
39
40
  exports.probeBinaryVersion = probeBinaryVersion;
40
41
  exports.summarizeProbe = summarizeProbe;
41
42
  exports.currentBinaryEnv = currentBinaryEnv;
42
43
  exports.summarizeResolutionFailure = summarizeResolutionFailure;
44
+ const windows_cmd_1 = require("./windows-cmd");
43
45
  const child_process_1 = require("child_process");
44
46
  const fs = __importStar(require("fs"));
45
47
  const spawn_env_1 = require("./relay/spawn-env");
@@ -190,9 +192,9 @@ function joinPath(dir, file, platform) {
190
192
  }
191
193
  /**
192
194
  * @param candidate an executable path already resolved by `resolveRuntimeBinary`
193
- * @returns file + prefix args to spawn it with, always `shell: false`
195
+ * @returns what to spawn; turn it into argv with `planArgv`, always `shell: false`
194
196
  */
195
- function spawnPlanFor(candidate, platform) {
197
+ function spawnPlanFor(candidate, platform, io = windows_cmd_1.realShimIo) {
196
198
  // A SCRIPT NEEDS ITS INTERPRETER, on every platform.
197
199
  //
198
200
  // The managed Codex fallback records `<CODEX_MANAGED_PACKAGE_ROOT>/bin/codex.js`
@@ -207,10 +209,31 @@ function spawnPlanFor(candidate, platform) {
207
209
  // `runtimeBin` is recorded by the session in the first place.
208
210
  if (/\.(mjs|cjs|js)$/i.test(candidate))
209
211
  return { file: process.execPath, prefixArgs: [candidate] };
210
- const needsInterpreter = platform === "win32" && /\.(cmd|bat)$/i.test(candidate);
211
- if (!needsInterpreter)
212
+ if (platform !== "win32" || !/\.(cmd|bat)$/i.test(candidate))
212
213
  return { file: candidate, prefixArgs: [] };
213
- return { file: "cmd.exe", prefixArgs: ["/d", "/s", "/c", candidate] };
214
+ const target = (0, windows_cmd_1.npmShimTarget)(candidate, io);
215
+ if (target?.kind === "exe")
216
+ return { file: target.path, prefixArgs: [] };
217
+ if (target?.kind === "node")
218
+ return { file: target.node ?? process.execPath, prefixArgs: [target.script] };
219
+ return { file: candidate, prefixArgs: [], viaCmd: true };
220
+ }
221
+ /**
222
+ * The argv for a plan. For a `.cmd` run through cmd.exe, one quoted line passed verbatim — and a
223
+ * refusal, never a guess, when an argument holds `"`, `%` or a line break, which cmd.exe would read
224
+ * as syntax inside quotes too.
225
+ */
226
+ function planArgv(plan, args) {
227
+ if (!plan.viaCmd)
228
+ return { ok: true, file: plan.file, args: [...plan.prefixArgs, ...args], verbatim: false };
229
+ if ([plan.file, ...args].some((a) => windows_cmd_1.WINDOWS_UNSAFE.test(a))) {
230
+ return {
231
+ ok: false,
232
+ reason: `${plan.file} is a .cmd BayChat cannot start directly, and an argument holds " or % or a line break, which cannot be passed safely through cmd.exe`,
233
+ };
234
+ }
235
+ const line = (0, windows_cmd_1.cmdExeSpawn)((0, windows_cmd_1.quoteForCmd)(plan.file), args.map(windows_cmd_1.quoteForCmd));
236
+ return { ok: true, file: line.command, args: line.args, verbatim: true };
214
237
  }
215
238
  /** How long a `--version` probe may run before it is treated as broken. */
216
239
  const PROBE_TIMEOUT_MS = 5_000;
@@ -220,9 +243,11 @@ const PROBE_TIMEOUT_MS = 5_000;
220
243
  * probe: no shell, the environment the agent will get, `summarizeProbe` decides.
221
244
  */
222
245
  function probeBinaryVersion(binaryPath, env) {
223
- const plan = spawnPlanFor(binaryPath, process.platform);
246
+ const run = planArgv(spawnPlanFor(binaryPath, process.platform), ["--version"]);
247
+ if (!run.ok)
248
+ return Promise.resolve(summarizeProbe({ status: null, stdout: "", stderr: "", error: new Error(run.reason) }));
224
249
  return new Promise((resolve) => {
225
- (0, child_process_1.execFile)(plan.file, [...plan.prefixArgs, "--version"], { timeout: PROBE_TIMEOUT_MS, windowsHide: true, shell: false, env, encoding: "utf8" }, (err, stdout, stderr) => {
250
+ (0, child_process_1.execFile)(run.file, run.args, { timeout: PROBE_TIMEOUT_MS, windowsHide: true, shell: false, windowsVerbatimArguments: run.verbatim, env, encoding: "utf8" }, (err, stdout, stderr) => {
226
251
  const status = err ? (typeof err.code === "number" ? err.code : null) : 0;
227
252
  // A non-zero exit is still a run: only a failure to run at all is an `error`.
228
253
  const failedToRun = err && typeof err.code !== "number" ? err : undefined;
@@ -304,12 +329,15 @@ function currentBinaryEnv(override, env = process.env, execPath = process.execPa
304
329
  }
305
330
  },
306
331
  probe(candidate) {
307
- const plan = spawnPlanFor(candidate, process.platform);
308
- const run = (0, child_process_1.spawnSync)(plan.file, [...plan.prefixArgs, "--version"], {
332
+ const planned = planArgv(spawnPlanFor(candidate, process.platform), ["--version"]);
333
+ if (!planned.ok)
334
+ return summarizeProbe({ status: null, stdout: "", stderr: "", error: new Error(planned.reason) });
335
+ const run = (0, child_process_1.spawnSync)(planned.file, planned.args, {
309
336
  windowsHide: true,
310
337
  timeout: PROBE_TIMEOUT_MS,
311
338
  encoding: "utf8",
312
339
  shell: false,
340
+ windowsVerbatimArguments: planned.verbatim,
313
341
  // THE PROBE MUST BE ABLE TO START WHAT IT IS PROBING.
314
342
  //
315
343
  // An npm runtime is a `#!/usr/bin/env node` script, and a spawn inherits
@@ -42,6 +42,7 @@ exports.upgradePackage = upgradePackage;
42
42
  exports.lookupLatest = lookupLatest;
43
43
  exports.installRelease = installRelease;
44
44
  exports.describeOutcome = describeOutcome;
45
+ exports.npmCommand = npmCommand;
45
46
  exports.realNpm = realNpm;
46
47
  exports.readPackageVersion = readPackageVersion;
47
48
  exports.packageEntry = packageEntry;
@@ -49,7 +50,7 @@ exports.ownVersion = ownVersion;
49
50
  const child_process_1 = require("child_process");
50
51
  const fs = __importStar(require("fs"));
51
52
  const path = __importStar(require("path"));
52
- const commands_1 = require("./commands");
53
+ const windows_cmd_1 = require("./windows-cmd");
53
54
  /** A path as the filesystem sees it: every symlink on the way resolved. */
54
55
  function resolveEntry(file) {
55
56
  try {
@@ -249,16 +250,54 @@ function npmFailure(r) {
249
250
  const lines = r.stderr.split("\n").map((l) => l.trim()).filter(Boolean);
250
251
  return lines.slice(-3).join(" ") || `npm exited with ${r.status}`;
251
252
  }
253
+ /**
254
+ * Which npm to run, and the PATH to run it with: the npm installed beside the Node running this
255
+ * code, when there is one — else plain `npm` from PATH.
256
+ *
257
+ * A relay under systemd or launchd does not read a shell profile, so with Node from nvm (or any
258
+ * version manager) its PATH holds no npm at all: every automatic update on such a computer failed
259
+ * with "npm was not found on PATH" (Karmen's PC, 2026-10-21: 11 failed checks, never one update).
260
+ * The Node that is running is the Node that installed this copy, and its npm sits next to it. That
261
+ * directory also goes first on the child's PATH, because npm itself starts with
262
+ * `#!/usr/bin/env node`, which must find a node too.
263
+ *
264
+ * Which global folder that npm calls its own (Volta, asdf, a custom `prefix`) does not matter:
265
+ * installRelease always passes `--prefix <the running copy's prefix>`, which npm puts above its
266
+ * own config (checked 2026-10-21 with npm_config_prefix pointing elsewhere: the install landed in
267
+ * `--prefix`), and then believes only the version on disk there.
268
+ */
269
+ function npmCommand(execPath, platform, pathEnv, exists) {
270
+ const dir = path.dirname(execPath);
271
+ const beside = path.join(dir, platform === "win32" ? "npm.cmd" : "npm");
272
+ if (!exists(beside))
273
+ return { command: "npm", path: pathEnv };
274
+ const sep = platform === "win32" ? ";" : ":";
275
+ return { command: beside, path: pathEnv ? `${dir}${sep}${pathEnv}` : dir };
276
+ }
252
277
  /** Run npm for real, without blocking the event loop (the relay keeps serving while npm runs).
253
- * Windows' npm is a `.cmd` shim, which Node will only start through a shell, so every argument
254
- * is first checked against what cmd.exe could misread and then quoted. */
278
+ * Windows' npm is a `.cmd` shim, which Node will only start through cmd.exe (`cmdExeSpawn`), so
279
+ * every argument is first checked against what cmd.exe could misread and then quoted. */
255
280
  function realNpm(args, timeoutMs) {
256
281
  const windows = process.platform === "win32";
257
- if (windows && args.some((a) => commands_1.WINDOWS_UNSAFE.test(a))) {
282
+ if (windows && args.some((a) => windows_cmd_1.WINDOWS_UNSAFE.test(a))) {
258
283
  return Promise.resolve({ status: null, stdout: "", stderr: "", error: new Error("a path npm needs cannot be passed safely through cmd.exe") });
259
284
  }
285
+ const found = npmCommand(process.execPath, process.platform, process.env.PATH, fs.existsSync);
286
+ // A Node directory cmd.exe could misread is not run through it: PATH's npm then, as before.
287
+ const npm = windows && windows_cmd_1.WINDOWS_UNSAFE.test(found.command) ? { command: "npm", path: process.env.PATH } : found;
288
+ // Through cmd.exe the command is a string too: a path with spaces must arrive as one word.
289
+ const run = windows
290
+ ? (0, windows_cmd_1.cmdExeSpawn)(npm.command !== "npm" ? (0, windows_cmd_1.quoteForCmd)(npm.command) : npm.command, args.map(windows_cmd_1.quoteForCmd))
291
+ : { command: npm.command, args };
260
292
  return new Promise((resolve) => {
261
- (0, child_process_1.execFile)("npm", windows ? args.map(commands_1.quoteForCmd) : args, { encoding: "utf8", timeout: timeoutMs, shell: windows, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
293
+ (0, child_process_1.execFile)(run.command, run.args, {
294
+ encoding: "utf8",
295
+ timeout: timeoutMs,
296
+ windowsVerbatimArguments: windows,
297
+ windowsHide: true,
298
+ maxBuffer: 16 * 1024 * 1024,
299
+ env: { ...process.env, ...(npm.path !== undefined ? { PATH: npm.path } : {}) },
300
+ }, (err, stdout, stderr) => {
262
301
  if (!err)
263
302
  return resolve({ status: 0, stdout, stderr });
264
303
  const code = err.code;
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ /**
3
+ * Starting a Windows `.cmd` shim (npm, claude) safely. Node will not spawn a `.cmd` directly
4
+ * (CVE-2024-27980), and `shell: true` with an argument list is deprecated (DEP0190), so the shim
5
+ * runs through cmd.exe, named explicitly, with every argument checked and quoted here first.
6
+ * Its own module because commands.ts, bay-mcp.ts and self-upgrade.ts all need it, and
7
+ * commands.ts already imports bay-mcp.ts.
8
+ */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.realShimIo = exports.WINDOWS_UNSAFE = void 0;
44
+ exports.quoteForCmd = quoteForCmd;
45
+ exports.cmdExeSpawn = cmdExeSpawn;
46
+ exports.cmdExePath = cmdExePath;
47
+ exports.npmShimTarget = npmShimTarget;
48
+ const fs = __importStar(require("fs"));
49
+ const path = __importStar(require("path"));
50
+ /**
51
+ * cmd.exe quoting: wrap the whole argument so spaces, `&`, `|` and `>` inside it
52
+ * stay literal. Only usable on values `WINDOWS_UNSAFE` has already cleared.
53
+ *
54
+ * Backslashes at the very end are doubled: the program behind the shim splits its
55
+ * command line by the C runtime's rules, where `\"` is an escaped quote, so
56
+ * `"C:\proj\"` would swallow the closing quote and merge with the next argument.
57
+ * Doubled, they read back as the backslashes they were.
58
+ */
59
+ function quoteForCmd(arg) {
60
+ return `"${arg.replace(/(\\+)$/, "$1$1")}"`;
61
+ }
62
+ /**
63
+ * Characters that survive — or break out of — cmd.exe double quotes.
64
+ *
65
+ * A `"` ends the wrapper, so everything after it is parsed as shell syntax. A
66
+ * `%NAME%` is expanded *inside* quotes. A newline ends the command line. Nothing
67
+ * else in cmd's metacharacter set (`&`, `|`, `<`, `>`, `^`) is interpreted while
68
+ * quoted, and `!` only expands under delayed expansion, which `cmdExeSpawn`
69
+ * switches off with `/v:off` (the registry can switch it on for every cmd.exe).
70
+ */
71
+ exports.WINDOWS_UNSAFE = /["%\u0000-\u001f\u007f]/;
72
+ /**
73
+ * Run a `.cmd` shim through cmd.exe, invoked EXPLICITLY, so the spawn itself needs no `shell: true`.
74
+ *
75
+ * `shell: true` with an argument list is deprecated (DEP0190) because Node only joins the list with
76
+ * spaces — newer Node may refuse it outright. This builds the very line Node built for it,
77
+ * `cmd.exe /d /s /c "<command> <args>"`, so cmd.exe sees the same line as before, plus `/v:off`:
78
+ * `/d` skips AutoRun, `/v:off` keeps `!VAR!` literal, `/s` strips exactly the outer quotes, `/c`
79
+ * runs and exits. Spawn it with `shell: false`
80
+ * and `windowsVerbatimArguments: true`, or Node would quote the line a second time.
81
+ *
82
+ * The caller still owns the quoting: `command` and every arg must already be safe for cmd.exe
83
+ * (`WINDOWS_UNSAFE` cleared, then `quoteForCmd`) — this only removes the deprecated route.
84
+ */
85
+ function cmdExeSpawn(command, quotedArgs, env = process.env) {
86
+ return { command: cmdExePath(env), args: ["/d", "/v:off", "/s", "/c", `"${[command, ...quotedArgs].join(" ")}"`] };
87
+ }
88
+ /**
89
+ * cmd.exe by its full path, as Node's own `shell: true` took it from ComSpec. A bare `cmd.exe` is
90
+ * looked up in the current folder FIRST, so `baychat claude` run inside a cloned repo that ships a
91
+ * `cmd.exe` would start that file.
92
+ */
93
+ function cmdExePath(env = process.env) {
94
+ return env.ComSpec ?? env.COMSPEC ?? path.win32.join(env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows", "System32", "cmd.exe");
95
+ }
96
+ exports.realShimIo = {
97
+ read: (file) => {
98
+ try {
99
+ return fs.readFileSync(file, "utf8");
100
+ }
101
+ catch {
102
+ // Unreadable is "not a shim we understand": the caller falls back to cmd.exe.
103
+ return undefined;
104
+ }
105
+ },
106
+ exists: (file) => fs.existsSync(file),
107
+ };
108
+ /** A shim is a few hundred bytes; anything this big is not one npm wrote. */
109
+ const SHIM_MAX_BYTES = 16 * 1024;
110
+ /** `%dp0%\` (cmd-shim today) or `%~dp0\` (older npm): the shim's own folder. */
111
+ const DP0 = /^(?:%dp0%|%~dp0)\\/i;
112
+ /**
113
+ * The program an npm `.cmd` shim runs, so it can be started directly — no cmd.exe at all, and so
114
+ * nothing in the arguments (a wake prompt with `"`, `%`, `&`) is ever read as cmd syntax.
115
+ *
116
+ * npm's shims end in one line that runs one file from the shim's folder with `%*`. Exactly one
117
+ * such file, present on disk, of a kind we know (`.exe`, or a script run by node) — anything else
118
+ * is undefined, and the caller uses cmd.exe with its stricter rules.
119
+ */
120
+ function npmShimTarget(shim, io = exports.realShimIo) {
121
+ const text = io.read(shim);
122
+ if (!text || text.length > SHIM_MAX_BYTES)
123
+ return undefined;
124
+ const line = text.split(/\r?\n/).reverse().find((l) => l.includes("%*"));
125
+ if (!line)
126
+ return undefined;
127
+ const dir = path.win32.dirname(shim);
128
+ const local = [...line.matchAll(/"([^"]*)"/g)]
129
+ .map((m) => m[1])
130
+ .filter((t) => DP0.test(t))
131
+ .map((t) => path.win32.join(dir, t.replace(DP0, "")));
132
+ const scripts = local.filter((f) => /\.[cm]?js$/i.test(f));
133
+ if (scripts.length === 1) {
134
+ if (!io.exists(scripts[0]))
135
+ return undefined;
136
+ const node = path.win32.join(dir, "node.exe");
137
+ return { kind: "node", script: scripts[0], ...(io.exists(node) ? { node } : {}) };
138
+ }
139
+ if (local.length === 1 && /\.exe$/i.test(local[0]) && io.exists(local[0]))
140
+ return { kind: "exe", path: local[0] };
141
+ return undefined;
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.33.0",
3
+ "version": "0.33.2",
4
4
  "description": "BayChat connector CLI — pair an agent session (Claude Code, Codex, Cursor) with BayChat, or have the relay run DeepSeek or the Cursor CLI for you",
5
5
  "bin": {
6
6
  "baychat": "dist/index.js"