baychat 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +36 -3
  3. package/dist/approval-wait.js +46 -33
  4. package/dist/approve-hook.js +35 -1
  5. package/dist/auto-update/describe.js +66 -0
  6. package/dist/auto-update/notice.js +40 -0
  7. package/dist/auto-update/policy.js +110 -0
  8. package/dist/auto-update/relay-glue.js +143 -0
  9. package/dist/auto-update/store.js +140 -0
  10. package/dist/auto-update/updater.js +324 -0
  11. package/dist/channel/permission.js +6 -0
  12. package/dist/cli-help.js +6 -1
  13. package/dist/denied/after-hook.js +8 -2
  14. package/dist/denied/confirm.js +131 -0
  15. package/dist/denied/grants.js +60 -2
  16. package/dist/denied/hook-log.js +75 -0
  17. package/dist/denied/live-session.js +154 -0
  18. package/dist/denied/notice.js +29 -0
  19. package/dist/denied/permission-rule.js +9 -6
  20. package/dist/denied/settings-rules.js +61 -0
  21. package/dist/denied/transcript.js +39 -16
  22. package/dist/denied-hook.js +132 -30
  23. package/dist/doctor-command.js +7 -1
  24. package/dist/doctor.js +28 -0
  25. package/dist/main.js +11 -2
  26. package/dist/permissions-command.js +18 -1
  27. package/dist/refresh-clients.js +36 -0
  28. package/dist/relay/acp/runner.js +4 -0
  29. package/dist/relay/commands.js +19 -3
  30. package/dist/relay/daemon.js +37 -1
  31. package/dist/relay/launcher/loop.js +4 -1
  32. package/dist/relay/self-restart.js +149 -0
  33. package/dist/relay/terminal-pane.js +74 -2
  34. package/dist/self-upgrade.js +92 -27
  35. package/dist/start-command.js +15 -1
  36. package/dist/update-command.js +51 -31
  37. package/dist/waiting-cards.js +107 -0
  38. package/package.json +4 -1
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.realSupervisorProbe = exports.WINDOWS_SELF_UPDATE_ENABLED = void 0;
37
+ exports.selfUpdateAllowedOn = selfUpdateAllowedOn;
38
+ exports.detectSupervisor = detectSupervisor;
39
+ exports.processesEndedByRestart = processesEndedByRestart;
40
+ exports.arrangeRestart = arrangeRestart;
41
+ const child_process_1 = require("child_process");
42
+ const fs = __importStar(require("fs"));
43
+ const autostart_1 = require("./autostart");
44
+ /**
45
+ * ⚠️ WINDOWS SHIPS NOTIFY-ONLY. Enable after a verified restart on a real Windows machine.
46
+ *
47
+ * The Task Scheduler restart below (a detached helper running `schtasks /run` after the relay
48
+ * exits) has never been watched working: if Task Scheduler ends the helper together with the task,
49
+ * the relay stays down until the next logon — worse than running an old version. Until someone
50
+ * has seen it come back on Windows, a Windows relay only says a new version is out. The whole path
51
+ * stays in place; flipping this one constant turns it on.
52
+ */
53
+ exports.WINDOWS_SELF_UPDATE_ENABLED = false;
54
+ /** May a relay on `platform` install an update and restart itself? (Only the gate above says no.) */
55
+ function selfUpdateAllowedOn(platform) {
56
+ return platform !== "win32" || exports.WINDOWS_SELF_UPDATE_ENABLED;
57
+ }
58
+ /** Which init system is supervising THIS process, or undefined when none will restart it. */
59
+ function detectSupervisor(probe) {
60
+ if (probe.platform === "linux") {
61
+ // Our own cgroup names the unit we run in: exact, and nothing else can fake it for us.
62
+ const cgroup = probe.readFile("/proc/self/cgroup") ?? "";
63
+ return cgroup.split("\n").some((line) => line.trim().endsWith(`/${autostart_1.SYSTEMD_UNIT_NAME}`)) ? "systemd" : undefined;
64
+ }
65
+ if (probe.platform === "darwin")
66
+ return probe.env.XPC_SERVICE_NAME === autostart_1.SERVICE_LABEL ? "launchd" : undefined;
67
+ // Task Scheduler stamps nothing on the process it starts. But only one relay can hold the socket,
68
+ // and this one does: if the relay task is RUNNING, this is its relay (a second, task-started relay
69
+ // would have failed to bind and exited). `Get-ScheduledTask` names the state unlocalised.
70
+ if (probe.platform === "win32")
71
+ return probe.taskState() === "Running" ? "task-scheduler" : undefined;
72
+ return undefined;
73
+ }
74
+ exports.realSupervisorProbe = {
75
+ platform: process.platform,
76
+ env: process.env,
77
+ taskState() {
78
+ try {
79
+ // A constant command: the task's fixed name, no value from anywhere else.
80
+ return (0, child_process_1.execFileSync)("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-ScheduledTask -TaskName '${autostart_1.SERVICE_LABEL}').State`], { encoding: "utf8", timeout: 20_000, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }).trim();
81
+ }
82
+ catch {
83
+ // No task, no PowerShell: not supervised as far as we can prove.
84
+ return undefined;
85
+ }
86
+ },
87
+ readFile(file) {
88
+ try {
89
+ return fs.readFileSync(file, "utf8");
90
+ }
91
+ catch {
92
+ // Not Linux, or no /proc: no cgroup to read.
93
+ return undefined;
94
+ }
95
+ },
96
+ };
97
+ /**
98
+ * Processes a restart would end with this relay: under systemd, every other process in the unit's
99
+ * cgroup (the default `KillMode=control-group` stops them all when the main process exits) — a tmux
100
+ * server the launcher started, an agent still running. The relay's own per-Bay child relays are not
101
+ * counted: they restart with it. Empty elsewhere (launchd and Task Scheduler leave detached
102
+ * processes alone) and wherever the cgroup cannot be read.
103
+ */
104
+ function processesEndedByRestart(probe, selfPid = process.pid) {
105
+ const cgroup = probe.readFile("/proc/self/cgroup") ?? "";
106
+ // cgroup v2: "0::/user.slice/…/baychat-relay.service". v1 has no single path to read.
107
+ const unified = cgroup.split("\n").find((line) => line.startsWith("0::"));
108
+ if (!unified || !unified.trim().endsWith(`/${autostart_1.SYSTEMD_UNIT_NAME}`))
109
+ return [];
110
+ const procs = probe.readFile(`/sys/fs/cgroup${unified.slice(3).trim()}/cgroup.procs`) ?? "";
111
+ return procs
112
+ .split("\n")
113
+ .map((line) => Number(line.trim()))
114
+ .filter((pid) => Number.isInteger(pid) && pid > 1 && pid !== selfPid)
115
+ .filter((pid) => !isRelayProcess(probe.readFile(`/proc/${pid}/cmdline`)));
116
+ }
117
+ /** A `… relay start --foreground` process: one of this relay's per-Bay children. */
118
+ function isRelayProcess(cmdline) {
119
+ const argv = (cmdline ?? "").split("\0");
120
+ const at = argv.indexOf("relay");
121
+ return at >= 0 && argv[at + 1] === "start" && argv.includes("--foreground");
122
+ }
123
+ /**
124
+ * Arrange for the supervisor to start the relay again once this process exits. The caller exits
125
+ * right after (gracefully: the daemon's own stop). Nothing to arrange for systemd and launchd.
126
+ */
127
+ function arrangeRestart(supervisor) {
128
+ if (supervisor !== "task-scheduler")
129
+ return;
130
+ // A constant script and constant arguments (the task's fixed name, our pid): no shell, nothing
131
+ // from outside reaches it. Detached, so it outlives this process.
132
+ const helper = (0, child_process_1.spawn)(process.execPath, ["-e", WINDOWS_RESTART_HELPER, String(process.pid), autostart_1.SERVICE_LABEL], {
133
+ detached: true,
134
+ stdio: "ignore",
135
+ windowsHide: true,
136
+ });
137
+ helper.unref();
138
+ }
139
+ /** Wait (up to a minute) for the old relay to exit, then run the relay's scheduled task again. */
140
+ const WINDOWS_RESTART_HELPER = `
141
+ const pid = Number(process.argv[1]);
142
+ const task = process.argv[2];
143
+ const deadline = Date.now() + 60000;
144
+ const gone = () => { try { process.kill(pid, 0); return false; } catch { return true; } };
145
+ (function wait() {
146
+ if (!gone() && Date.now() < deadline) return setTimeout(wait, 500);
147
+ require("child_process").execFile("schtasks", ["/run", "/tn", task], { windowsHide: true }, () => undefined);
148
+ })();
149
+ `;
@@ -37,6 +37,68 @@ function detectPane(env = process.env) {
37
37
  function expectedCommand(runtime) {
38
38
  return (0, profiles_1.profileFor)(runtime)?.bin ?? runtime;
39
39
  }
40
+ /**
41
+ * A second file name a runtime's own binary runs under, and the path that proves it is that
42
+ * binary rather than something that happens to share the name.
43
+ *
44
+ * Claude Code's native installer (seen on macOS after its self-update, 2026-10-14) runs
45
+ * `…/claude-code/bin/claude.exe` behind a `claude` symlink, so tmux names the pane's program
46
+ * `claude.exe` and every session command was refused ("pane %8 is running claude.exe, not
47
+ * claude"). The NAME alone is never enough to type: the process must also be that build — its
48
+ * executable path, or its argv[0] being the `claude` symlink it was started through.
49
+ */
50
+ const NATIVE_BUILDS = {
51
+ claude: { name: "claude.exe", path: /[\\/]claude-code[\\/]bin[\\/]claude\.exe$/ },
52
+ };
53
+ /** The last path segment: macOS `ps -o comm=` prints a full path, Linux a bare name. */
54
+ function baseName(file) {
55
+ return file.split(/[\\/]/).pop() ?? file;
56
+ }
57
+ /** Is `name` (a pane's program, a process's comm) the runtime's native build's file name? */
58
+ function isNativeBuildName(runtime, name) {
59
+ const build = NATIVE_BUILDS[runtime];
60
+ return !!build && baseName(name) === build.name;
61
+ }
62
+ /**
63
+ * Is `pid` the runtime's native build, proven by more than its name?
64
+ *
65
+ * Accepted when its executable path (`comm` on macOS) or its argv[0] is the build's path, or its
66
+ * argv[0] is the runtime's own command (`claude`, the symlink the build is started through). A
67
+ * process merely CALLED `claude.exe` and started as itself proves nothing and is refused.
68
+ */
69
+ function isNativeBuildProcess(pid, runtime, run) {
70
+ const build = NATIVE_BUILDS[runtime];
71
+ if (!build || !Number.isInteger(pid) || pid <= 0)
72
+ return false;
73
+ try {
74
+ const comm = run("ps", ["-p", String(pid), "-o", "comm="]).trim();
75
+ if (baseName(comm) !== build.name)
76
+ return false;
77
+ const argv0 = run("ps", ["-p", String(pid), "-o", "args="]).trim().split(/\s+/)[0] ?? "";
78
+ return build.path.test(comm) || build.path.test(argv0) || baseName(argv0) === expectedCommand(runtime);
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ /**
85
+ * Is the pane's FOREGROUND process the runtime's native build? The foreground process group of
86
+ * the pane's terminal (`tpgid` of the pane's first process) is led by the program a keystroke
87
+ * reaches, and that leader is the process checked.
88
+ */
89
+ function foregroundIsNativeBuild(panePid, runtime, run) {
90
+ const pid = Number(panePid);
91
+ if (!Number.isInteger(pid) || pid <= 0)
92
+ return false;
93
+ let tpgid;
94
+ try {
95
+ tpgid = Number(run("ps", ["-o", "tpgid=", "-p", String(pid)]).trim());
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ return isNativeBuildProcess(tpgid, runtime, run);
101
+ }
40
102
  /**
41
103
  * Is this pane still the session we recorded?
42
104
  *
@@ -81,7 +143,7 @@ function verifyPane(handle, runtime, run = (file, args) => (0, child_process_1.e
81
143
  "-p",
82
144
  "-t",
83
145
  handle.pane,
84
- "#{pane_dead} #{pane_current_command} #{pane_tty}",
146
+ "#{pane_dead} #{pane_current_command} #{pane_tty} #{pane_pid}",
85
147
  ]).trim();
86
148
  }
87
149
  catch (err) {
@@ -90,10 +152,15 @@ function verifyPane(handle, runtime, run = (file, args) => (0, child_process_1.e
90
152
  reason: `tmux could not find pane ${handle.pane} any more (${errText(err)}).`,
91
153
  };
92
154
  }
93
- const [dead, current, paneTty] = out.split(/\s+/);
155
+ const [dead, current, paneTty, panePid] = out.split(/\s+/);
94
156
  if (dead !== "0")
95
157
  return { ok: false, reason: `pane ${handle.pane} has exited.` };
96
158
  const want = expectedCommand(runtime);
159
+ // The runtime under its native build's file name (Claude Code's `claude.exe`): allowed only
160
+ // when the pane's foreground process proves to be that build — never on the name alone.
161
+ if (current !== want && current && isNativeBuildName(runtime, current) && foregroundIsNativeBuild(panePid, runtime, run)) {
162
+ return { ok: true };
163
+ }
97
164
  // A launcher that stayed the pane's foreground program: `baychat claude` or `baychat start`
98
165
  // run INSIDE tmux keeps `node` as the parent of the agent (Node cannot exec), so tmux names
99
166
  // `node`. Typing is still safe when the session's OWN recorded process is alive, is the
@@ -152,6 +219,11 @@ function runtimeStillRunning(pid, runtime, run) {
152
219
  // `comm` is truncated to 15 characters by the kernel, so compare on that basis rather
153
220
  // than demanding equality — "claude" and "codex" are short, but a longer runtime name
154
221
  // would otherwise never match itself.
222
+ if (isNativeBuildName(runtime, comm)) {
223
+ return isNativeBuildProcess(pid, runtime, run)
224
+ ? { ok: true }
225
+ : { ok: false, reason: `that process is ${comm}, but not ${want}'s own build — refusing to type into it.` };
226
+ }
155
227
  if (comm !== want && !want.startsWith(comm)) {
156
228
  return { ok: false, reason: `that process is ${comm}, not ${want} — refusing to type into it.` };
157
229
  }
@@ -33,11 +33,14 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.RELEASE_VERSION = void 0;
36
37
  exports.resolveEntry = resolveEntry;
37
38
  exports.currentEntry = currentEntry;
38
39
  exports.classifyInstall = classifyInstall;
39
40
  exports.compareVersions = compareVersions;
40
41
  exports.upgradePackage = upgradePackage;
42
+ exports.lookupLatest = lookupLatest;
43
+ exports.installRelease = installRelease;
41
44
  exports.describeOutcome = describeOutcome;
42
45
  exports.realNpm = realNpm;
43
46
  exports.readPackageVersion = readPackageVersion;
@@ -100,8 +103,12 @@ function compareVersions(a, b) {
100
103
  }
101
104
  const VIEW_TIMEOUT_MS = 30_000;
102
105
  const INSTALL_TIMEOUT_MS = 180_000;
106
+ /** A release version we will act on: plain `x.y.z`. Prereleases and anything else are refused. */
107
+ exports.RELEASE_VERSION = /^\d+\.\d+\.\d+$/;
108
+ /** A reason is shown on a card and in a terminal: one short line. */
109
+ const ATTENTION_MAX_CHARS = 200;
103
110
  /** Upgrade the running package where it can be upgraded, and say exactly what happened. */
104
- function upgradePackage(deps) {
111
+ async function upgradePackage(deps) {
105
112
  const { install, current } = deps;
106
113
  if (install.kind === "npx")
107
114
  return { outcome: "npx", current };
@@ -112,38 +119,89 @@ function upgradePackage(deps) {
112
119
  // Ask first, install only when there is something newer. Besides saving a slow npm run a
113
120
  // day, this is what lets a machine whose global folder needs sudo finish `baychat update`
114
121
  // after the one `sudo npm i -g` — otherwise every run would retry the write and fail.
115
- const view = deps.npm(["view", "baychat", "version"], VIEW_TIMEOUT_MS);
116
- const latest = view.stdout.trim();
117
- if (view.error || view.status !== 0 || !/^\d+\.\d+\.\d+$/.test(latest)) {
118
- return { outcome: "unchecked", current, reason: npmFailure(view) };
119
- }
120
- const order = compareVersions(current, latest);
122
+ const latest = await lookupLatest(deps.npm);
123
+ if (!latest.ok)
124
+ return { outcome: "unchecked", current, reason: latest.reason };
125
+ const order = compareVersions(current, latest.release.version);
121
126
  if (order === 0 || order === 1)
122
127
  return { outcome: "current", current };
123
- // `--prefer-online`: `npm view` above always revalidates, but `install` trusts a cached
124
- // packument for its max-age (~5 minutes). Straight after a publish — exactly when people run
125
- // `update` — that cache can predate the version view just named, and install dies ETARGET.
126
- const result = deps.npm(["install", "--global", "--prefer-online", "--prefix", install.prefix, `baychat@${latest}`], INSTALL_TIMEOUT_MS);
128
+ const installed = await installRelease({ ...deps, install }, latest.release.version);
129
+ if (!installed.ok)
130
+ return { outcome: "failed", current, reason: installed.reason };
131
+ return { outcome: "upgraded", from: current, to: installed.to, packageRoot: installed.packageRoot };
132
+ }
133
+ /**
134
+ * Ask the registry for the newest `baychat` — the ONLY source an update ever comes from. Nothing
135
+ * the BayChat server says can name a version, a package or a URL. The answer is validated here,
136
+ * at the trust boundary: a version that is not plain `x.y.z` is "could not check", and the
137
+ * attention notes keep only exact versions and one sanitised line each.
138
+ */
139
+ async function lookupLatest(npm) {
140
+ const view = await npm(["view", "baychat@latest", "version", "baychat", "--json"], VIEW_TIMEOUT_MS);
141
+ if (view.error || view.status !== 0)
142
+ return { ok: false, reason: npmFailure(view) };
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(view.stdout);
146
+ }
147
+ catch {
148
+ return { ok: false, reason: "npm answered with something that is not JSON" };
149
+ }
150
+ // One field asked for and present → npm prints it bare; several → an object of them.
151
+ const fields = typeof parsed === "string" ? { version: parsed } : parsed;
152
+ const version = typeof fields?.version === "string" ? fields.version.trim() : "";
153
+ if (!exports.RELEASE_VERSION.test(version))
154
+ return { ok: false, reason: `npm named no release version (${JSON.stringify(version)})` };
155
+ return { ok: true, release: { version, attention: attentionNotes(fields?.baychat) } };
156
+ }
157
+ /**
158
+ * Install exactly `version` over the running global copy, in its own prefix, and believe only the
159
+ * disk. Touches nothing but `baychat` in that prefix. On failure nothing claims success; the
160
+ * caller decides what to tell people.
161
+ */
162
+ async function installRelease(deps, version) {
163
+ if (!exports.RELEASE_VERSION.test(version))
164
+ return { ok: false, reason: `refusing to install ${JSON.stringify(version)}: not a release version` };
165
+ const { install } = deps;
166
+ // `--prefer-online`: `npm view` always revalidates, but `install` trusts a cached packument
167
+ // for its max-age (~5 minutes). Straight after a publish — exactly when people run `update` —
168
+ // that cache can predate the version view just named, and install dies ETARGET.
169
+ const result = await deps.npm(["install", "--global", "--prefer-online", "--prefix", install.prefix, `baychat@${version}`], INSTALL_TIMEOUT_MS);
127
170
  if (result.error || result.status !== 0) {
128
171
  const denied = /\b(EACCES|EPERM)\b/.test(`${result.stderr}\n${result.error?.message ?? ""}`);
129
172
  const reason = denied
130
- ? `npm is not allowed to write to ${install.prefix}. Run \`${sudoHint(deps, install.prefix)}\` ` +
173
+ ? `npm is not allowed to write to ${install.prefix}. Run \`${await sudoHint(deps.npm, install.prefix)}\` ` +
131
174
  "(or give npm a folder you own: https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally), " +
132
175
  "then `baychat update` again."
133
176
  : npmFailure(result);
134
- return { outcome: "failed", current, reason };
177
+ return { ok: false, reason };
135
178
  }
136
179
  // npm said yes. Believe the file on disk, not the exit code: this is the very step that
137
180
  // used to report success over an unchanged install.
138
181
  const onDisk = deps.readVersion(install.packageRoot);
139
- if (onDisk !== latest) {
182
+ if (onDisk !== version) {
140
183
  return {
141
- outcome: "failed",
142
- current,
143
- reason: `npm reported success, but ${install.packageRoot} holds ${onDisk ?? "no readable package.json"}, not ${latest}.`,
184
+ ok: false,
185
+ reason: `npm reported success, but ${install.packageRoot} holds ${onDisk ?? "no readable package.json"}, not ${version}.`,
144
186
  };
145
187
  }
146
- return { outcome: "upgraded", from: current, to: onDisk, packageRoot: install.packageRoot };
188
+ return { ok: true, to: onDisk, packageRoot: install.packageRoot };
189
+ }
190
+ /** The attention notes a release carries, validated: exact versions only, one short plain line each. */
191
+ function attentionNotes(raw) {
192
+ const notes = {};
193
+ const attention = raw && typeof raw === "object" ? raw.attention : undefined;
194
+ if (!attention || typeof attention !== "object" || Array.isArray(attention))
195
+ return notes;
196
+ for (const [version, reason] of Object.entries(attention)) {
197
+ if (!exports.RELEASE_VERSION.test(version) || typeof reason !== "string")
198
+ continue;
199
+ // eslint-disable-next-line no-control-regex
200
+ const line = reason.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, ATTENTION_MAX_CHARS);
201
+ if (line)
202
+ notes[version] = line;
203
+ }
204
+ return notes;
147
205
  }
148
206
  /** What a person reads for each outcome. `ok: false` means: do not claim this machine is updated. */
149
207
  function describeOutcome(o) {
@@ -176,9 +234,9 @@ function describeOutcome(o) {
176
234
  };
177
235
  }
178
236
  }
179
- function sudoHint(deps, prefix) {
237
+ async function sudoHint(npm, prefix) {
180
238
  // Plain `sudo npm i -g` is only right when npm's own global prefix IS this install's.
181
- const own = deps.npm(["prefix", "--global"], VIEW_TIMEOUT_MS);
239
+ const own = await npm(["prefix", "--global"], VIEW_TIMEOUT_MS);
182
240
  const ownPrefix = !own.error && own.status === 0 ? own.stdout.trim() : "";
183
241
  return ownPrefix && resolveEntry(ownPrefix) === resolveEntry(prefix)
184
242
  ? "sudo npm i -g baychat@latest"
@@ -191,18 +249,25 @@ function npmFailure(r) {
191
249
  const lines = r.stderr.split("\n").map((l) => l.trim()).filter(Boolean);
192
250
  return lines.slice(-3).join(" ") || `npm exited with ${r.status}`;
193
251
  }
194
- /** Run npm for real. Windows' npm is a `.cmd` shim, which Node will only start through a shell. */
252
+ /** 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. */
195
255
  function realNpm(args, timeoutMs) {
196
256
  const windows = process.platform === "win32";
197
257
  if (windows && args.some((a) => commands_1.WINDOWS_UNSAFE.test(a))) {
198
- return { status: null, stdout: "", stderr: "", error: new Error("a path npm needs cannot be passed safely through cmd.exe") };
258
+ return Promise.resolve({ status: null, stdout: "", stderr: "", error: new Error("a path npm needs cannot be passed safely through cmd.exe") });
199
259
  }
200
- const r = (0, child_process_1.spawnSync)("npm", windows ? args.map(commands_1.quoteForCmd) : args, {
201
- encoding: "utf8",
202
- timeout: timeoutMs,
203
- shell: windows,
260
+ 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) => {
262
+ if (!err)
263
+ return resolve({ status: 0, stdout, stderr });
264
+ const code = err.code;
265
+ // A number is npm's own exit status; anything else (ENOENT, a timeout's kill) never ran it to the end.
266
+ if (typeof code === "number")
267
+ return resolve({ status: code, stdout, stderr });
268
+ resolve({ status: null, stdout: stdout ?? "", stderr: stderr ?? "", error: err });
269
+ });
204
270
  });
205
- return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", error: r.error };
206
271
  }
207
272
  function readPackageVersion(packageRoot) {
208
273
  try {
@@ -43,6 +43,9 @@ const multiplexer_launch_1 = require("./multiplexer-launch");
43
43
  const profiles_1 = require("./relay/profiles");
44
44
  const runtime_binary_1 = require("./runtime-binary");
45
45
  const update_command_1 = require("./update-command");
46
+ const policy_1 = require("./auto-update/policy");
47
+ const store_1 = require("./auto-update/store");
48
+ const commands_1 = require("./relay/commands");
46
49
  const launch_1 = require("./channel/launch");
47
50
  /**
48
51
  * `baychat start [name]` — the whole product in one command.
@@ -105,7 +108,11 @@ async function cmdStart(args) {
105
108
  // is swallowed: the agent starts anyway. An update is an improvement to a session, never
106
109
  // a precondition for one, and a person who cannot start their agent because a background
107
110
  // refresh failed would rightly never trust this command again.
108
- if (!args.includes("--no-update") && (0, update_command_1.autoUpdateDue)()) {
111
+ //
112
+ // Since 0.31.0 the relay updates this computer by itself (docs/features/CLI_AUTO_UPDATE.md), in
113
+ // a quiet moment and without restarting under a running turn. When it says it does, `start`
114
+ // leaves updating to it; when the person switched automatic updates off, `start` honours that.
115
+ if (!args.includes("--no-update") && (0, update_command_1.autoUpdateDue)() && (await startShouldUpdate())) {
109
116
  try {
110
117
  await (0, update_command_1.cmdUpdate)({ quiet: true });
111
118
  }
@@ -140,6 +147,13 @@ async function cmdStart(args) {
140
147
  // A NAMED session: running `baychat start` again returns you to the agent already running.
141
148
  return (0, multiplexer_launch_1.launchAgent)({ binary: binary.path, args: agentArgs, env: channel?.env ?? {} }, { kind: "reattach", name: name || "baychat" });
142
149
  }
150
+ /** Is the daily update `start`'s job here, rather than the relay's or nobody's? */
151
+ async function startShouldUpdate() {
152
+ if ((0, policy_1.autoUpdateOffReason)(process.env, (0, store_1.loadAutoSetting)()))
153
+ return false;
154
+ const relay = await (0, commands_1.tryRelayStatus)();
155
+ return relay?.autoUpdate?.mode !== "auto";
156
+ }
143
157
  /**
144
158
  * Which agent to start.
145
159
  *
@@ -39,10 +39,10 @@ const child_process_1 = require("child_process");
39
39
  const fs = __importStar(require("fs"));
40
40
  const path = __importStar(require("path"));
41
41
  const config_1 = require("./config");
42
- const credential_refresh_1 = require("./credential-refresh");
43
- const commands_1 = require("./commands");
44
- const client_paths_1 = require("./client-paths");
45
- const commands_2 = require("./relay/commands");
42
+ const refresh_clients_1 = require("./refresh-clients");
43
+ const commands_1 = require("./relay/commands");
44
+ const policy_1 = require("./auto-update/policy");
45
+ const store_1 = require("./auto-update/store");
46
46
  const self_upgrade_1 = require("./self-upgrade");
47
47
  /**
48
48
  * `baychat update` — bring this whole machine onto the current version, in one word.
@@ -76,6 +76,8 @@ const self_upgrade_1 = require("./self-upgrade");
76
76
  const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
77
77
  const STAMP_FILE = "last-update-check.json";
78
78
  async function cmdUpdate(opts = {}) {
79
+ if (opts.auto !== undefined)
80
+ return setAutoUpdate(opts.auto);
79
81
  const say = (line) => {
80
82
  if (!opts.quiet)
81
83
  console.log(line);
@@ -93,12 +95,24 @@ async function cmdUpdate(opts = {}) {
93
95
  done.push(`upgraded baychat ${upgradedFrom} → ${(0, self_upgrade_1.ownVersion)()}`);
94
96
  }
95
97
  else {
96
- const outcome = (0, self_upgrade_1.upgradePackage)({
97
- install: (0, self_upgrade_1.classifyInstall)((0, self_upgrade_1.currentEntry)()),
98
- current: (0, self_upgrade_1.ownVersion)(),
99
- npm: self_upgrade_1.realNpm,
100
- readVersion: self_upgrade_1.readPackageVersion,
101
- });
98
+ // One npm install over this baychat at a time: the relay may be installing one by itself.
99
+ const unlock = (0, store_1.acquireUpdateLock)();
100
+ if (!unlock) {
101
+ console.error("BayChat is updating itself right now (the relay is installing a new version). Try again in a minute.");
102
+ return 1;
103
+ }
104
+ let outcome;
105
+ try {
106
+ outcome = await (0, self_upgrade_1.upgradePackage)({
107
+ install: (0, self_upgrade_1.classifyInstall)((0, self_upgrade_1.currentEntry)()),
108
+ current: (0, self_upgrade_1.ownVersion)(),
109
+ npm: self_upgrade_1.realNpm,
110
+ readVersion: self_upgrade_1.readPackageVersion,
111
+ });
112
+ }
113
+ finally {
114
+ unlock();
115
+ }
102
116
  const told = (0, self_upgrade_1.describeOutcome)(outcome);
103
117
  if (!told.ok) {
104
118
  // Never "Updated this machine" over a package that did not move. Stop before the relay
@@ -122,33 +136,22 @@ async function cmdUpdate(opts = {}) {
122
136
  if (told.done)
123
137
  done.push(told.done);
124
138
  }
125
- // 2. EVERY CONNECTED CLIENT's copy of the credential and endpoint.
126
- try {
127
- const updated = (0, credential_refresh_1.refreshExistingClientConfigs)({ url: `${device.baseUrl}/api/mcp`, token: device.token }, commands_1.realConfigIo, (0, client_paths_1.currentPathEnv)());
128
- for (const file of updated)
129
- done.push(`refreshed ${file}`);
130
- }
131
- catch (err) {
132
- // Never fatal: the next steps are independent, and a machine that failed one of four
133
- // is better off doing the other three than stopping at the first.
134
- say(` Could not refresh client configs: ${errText(err)}`);
135
- }
136
- // 3. CLAUDE CODE, which is not an MCP-config client and registers through its own CLI.
137
- if (hasBinary("claude") && (0, commands_1.registerWithClaude)(device.baseUrl, device.token)) {
138
- done.push("re-registered BayChat with Claude Code");
139
- }
139
+ // 2 + 3. EVERY CONNECTED CLIENT, and Claude Code.
140
+ done.push(...(0, refresh_clients_1.refreshConnectedClients)(device, say));
140
141
  // 4. THE RELAY — the step people forget, and the one that silently keeps old code
141
142
  // running. Stopped and started rather than signalled: the daemon loads its code at
142
143
  // process start, so nothing short of a restart picks up a new version.
143
144
  try {
144
- await (0, commands_2.cmdRelayStop)();
145
+ await (0, commands_1.cmdRelayStop)();
145
146
  }
146
147
  catch {
147
148
  // Not running is the normal case on a fresh boot, and is not a failure to report.
148
149
  }
149
- const relay = await (0, commands_2.ensureRelayInstalled)();
150
+ const relay = await (0, commands_1.ensureRelayInstalled)();
150
151
  done.push(relay.startsWith("Relay:") ? relay.toLowerCase() : relay);
151
152
  writeStamp();
153
+ // Whatever the relay left for the terminal ("x is available", "updated itself") is answered now.
154
+ (0, store_1.takeUpdateNotice)();
152
155
  if (done.length === 0) {
153
156
  say("Everything was already up to date.");
154
157
  return 0;
@@ -164,6 +167,27 @@ async function cmdUpdate(opts = {}) {
164
167
  console.log(` ✓ ${line}`);
165
168
  return 0;
166
169
  }
170
+ /**
171
+ * `baychat update --auto on|off` — the relay's automatic updates for this computer (default on).
172
+ * Read by the relay on its next look, within a minute; nothing needs restarting.
173
+ */
174
+ function setAutoUpdate(value) {
175
+ if (value !== "on" && value !== "off") {
176
+ console.error("Usage: baychat update --auto on|off");
177
+ return 1;
178
+ }
179
+ (0, store_1.saveAutoSetting)(value);
180
+ const envOff = (0, policy_1.autoUpdateOffReason)(process.env, "on");
181
+ if (value === "off") {
182
+ console.log("Automatic updates are off on this computer. Update with `baychat update` when you choose; `baychat update --auto on` turns them back on.");
183
+ }
184
+ else {
185
+ console.log("Automatic updates are on: the relay installs new BayChat versions by itself in a quiet moment, and asks on your phone first when a version needs you.");
186
+ if (envOff)
187
+ console.log(`Note: ${envOff} is set here, which keeps them off for anything started from this shell.`);
188
+ }
189
+ return 0;
190
+ }
167
191
  /**
168
192
  * Has it been long enough for `start` to check again?
169
193
  *
@@ -217,10 +241,6 @@ function runOnNewCode(packageRoot, from, quiet) {
217
241
  }
218
242
  return child.status ?? 1;
219
243
  }
220
- function hasBinary(name) {
221
- const probe = (0, child_process_1.spawnSync)(name, ["--version"], { stdio: "ignore", timeout: 15_000 });
222
- return !probe.error && probe.status === 0;
223
- }
224
244
  function errText(err) {
225
245
  return err instanceof Error ? err.message : String(err);
226
246
  }