baychat 0.29.9 → 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 (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +43 -10
  3. package/dist/approval-wait.js +143 -0
  4. package/dist/approve-hook.js +68 -133
  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 +30 -3
  13. package/dist/denied/after-hook.js +46 -0
  14. package/dist/denied/confirm.js +131 -0
  15. package/dist/denied/grants.js +292 -0
  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/opt-in.js +68 -0
  20. package/dist/denied/permission-rule.js +186 -0
  21. package/dist/denied/settings-rules.js +221 -0
  22. package/dist/denied/transcript.js +173 -0
  23. package/dist/denied-hook.js +431 -0
  24. package/dist/doctor-command.js +7 -1
  25. package/dist/doctor.js +28 -0
  26. package/dist/help-topics.js +12 -4
  27. package/dist/hook-stdin.js +35 -0
  28. package/dist/index.js +13 -1
  29. package/dist/main.js +20 -2
  30. package/dist/permissions-command.js +104 -0
  31. package/dist/protocol-content.js +1 -1
  32. package/dist/refresh-clients.js +36 -0
  33. package/dist/relay/acp/runner.js +4 -0
  34. package/dist/relay/commands.js +19 -3
  35. package/dist/relay/daemon.js +37 -1
  36. package/dist/relay/launcher/approval-settings.js +56 -2
  37. package/dist/relay/launcher/loop.js +4 -1
  38. package/dist/relay/self-restart.js +149 -0
  39. package/dist/relay/terminal-pane.js +74 -2
  40. package/dist/runtimes.js +2 -2
  41. package/dist/self-upgrade.js +92 -27
  42. package/dist/start-command.js +15 -1
  43. package/dist/update-command.js +51 -31
  44. package/dist/waiting-cards.js +107 -0
  45. package/package.json +4 -1
@@ -193,10 +193,16 @@ class RelayDaemon {
193
193
  startLauncherFn;
194
194
  /** Aborted on stop(): ends the launcher's long-poll. */
195
195
  launcherAbort = new AbortController();
196
+ autoUpdateStatus;
197
+ /** When a message last reached this relay or was delivered — the automatic update's quiet window. */
198
+ lastActivityAt;
199
+ /** A launch from BayChat is being carried out (the launcher's `onLaunch`). */
200
+ launching = false;
196
201
  constructor(opts = {}) {
197
202
  this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
198
203
  this.onListening = opts.onListening;
199
204
  this.startLauncherFn = opts.startLauncher ?? loop_1.startLauncher;
205
+ this.autoUpdateStatus = opts.autoUpdateStatus;
200
206
  // EVERY candidate root, because the daemon cannot know which one the agent's
201
207
  // sandbox turned out to permit — it may have fallen back past the one this
202
208
  // process would have chosen.
@@ -341,7 +347,14 @@ class RelayDaemon {
341
347
  this.codexContext.start();
342
348
  if (!(0, bay_relays_1.isBayRelayChild)()) {
343
349
  // Never awaited and never fatal: the launcher retries its own network failures until stop().
344
- void this.startLauncherFn({ auth, log: (line) => this.log(line), signal: this.launcherAbort.signal }).catch((err) => this.log(`launcher stopped: ${err instanceof Error ? err.message : String(err)}`));
350
+ void this.startLauncherFn({
351
+ auth,
352
+ log: (line) => this.log(line),
353
+ signal: this.launcherAbort.signal,
354
+ onLaunch: (busy) => {
355
+ this.launching = busy;
356
+ },
357
+ }).catch((err) => this.log(`launcher stopped: ${err instanceof Error ? err.message : String(err)}`));
345
358
  }
346
359
  this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
347
360
  this.log(`${this.registry.all().length} known session target(s)`);
@@ -406,6 +419,7 @@ class RelayDaemon {
406
419
  // behind must not have its recovery window truncated by a session that was
407
420
  // ahead. An event with no session still registers the room — see Watermarks.
408
421
  this.watermarks.record(sessionName, conversationId, message.createdAt);
422
+ this.lastActivityAt = Date.now();
409
423
  if (!sessionName) {
410
424
  this.log(`event ${message.id} carries no session (renamed or ended mid-poll) — ignoring`);
411
425
  return;
@@ -1330,6 +1344,7 @@ class RelayDaemon {
1330
1344
  return updated;
1331
1345
  }
1332
1346
  record(outcome, session, batch) {
1347
+ this.lastActivityAt = Date.now();
1333
1348
  if (outcome.kind === "woken") {
1334
1349
  this.delivered += batch.length;
1335
1350
  const room = batch.at(-1)?.conversationId;
@@ -1635,8 +1650,29 @@ class RelayDaemon {
1635
1650
  pending: this.pending,
1636
1651
  held: this.held.summary(),
1637
1652
  lastError: this.lastError,
1653
+ autoUpdate: this.autoUpdateStatus?.(),
1638
1654
  };
1639
1655
  }
1656
+ /** What this relay is doing right now, for deciding whether it may restart (auto-update/policy.ts). */
1657
+ activity() {
1658
+ return {
1659
+ deliveries: this.queue.activeSessions(),
1660
+ headless: [...this.headlessInFlight],
1661
+ acp: this.acp.activeSessions(),
1662
+ heldAt: this.held.summary().map((h) => h.heldAt),
1663
+ launching: this.launching,
1664
+ lastActivityAt: this.lastActivityAt,
1665
+ };
1666
+ }
1667
+ /**
1668
+ * Sessions running on THIS computer that the server lists as live — the ones a card can be asked
1669
+ * through. Attached ones first: their owner is most likely looking at them.
1670
+ */
1671
+ hostedLiveSessions() {
1672
+ const hosted = this.registry.all().filter((t) => this.liveSessions.includes(t.name));
1673
+ const reachable = (name) => this.attached.has(name) || this.mailboxes.has(name) || this.channelLinked(name);
1674
+ return hosted.sort((a, b) => Number(reachable(b.name)) - Number(reachable(a.name))).map((t) => t.name);
1675
+ }
1640
1676
  async stop() {
1641
1677
  clearInterval(this.acpKeepAlive);
1642
1678
  this.launcherAbort.abort();
@@ -37,6 +37,8 @@ exports.TERMINAL_SETTINGS_FILE = exports.UNATTENDED_SETTINGS = exports.JOIN_ALLO
37
37
  exports.approvalPathFor = approvalPathFor;
38
38
  exports.launchDir = launchDir;
39
39
  exports.approvalSettingsPath = approvalSettingsPath;
40
+ exports.deniedCardHooks = deniedCardHooks;
41
+ exports.withDeniedCard = withDeniedCard;
40
42
  exports.approvalSettings = approvalSettings;
41
43
  exports.terminalApprovalSettings = terminalApprovalSettings;
42
44
  exports.approvalsDir = approvalsDir;
@@ -48,6 +50,7 @@ const fs = __importStar(require("fs"));
48
50
  const path = __importStar(require("path"));
49
51
  const approve_hook_1 = require("../../approve-hook");
50
52
  const config_1 = require("../../config");
53
+ const opt_in_1 = require("../../denied/opt-in");
51
54
  /** Always `"hook"` for now: Claude Code's channel permission relay is behind a flag that is off. */
52
55
  function approvalPathFor(_sessionName) {
53
56
  return "hook";
@@ -94,6 +97,57 @@ function permissionHook(cli, flags) {
94
97
  ],
95
98
  };
96
99
  }
100
+ /** The tools the after-hook watches: the ones a once-rule can be written for (permission-rule.ts). */
101
+ const DENIED_AFTER_MATCHER = "Bash|WebFetch|mcp__.*";
102
+ /** The after-hook is async and returns in milliseconds; this bounds only a wedged one. */
103
+ const DENIED_AFTER_TIMEOUT_SECONDS = 30;
104
+ /**
105
+ * The denied-command card's hooks (docs/features/DENIED_COMMAND_CARD.md). `flags` are the same
106
+ * session flags approve-hook gets in that file.
107
+ */
108
+ function deniedCardHooks(cli, flags) {
109
+ const after = {
110
+ matcher: DENIED_AFTER_MATCHER,
111
+ hooks: [
112
+ {
113
+ type: "command",
114
+ command: cli.node,
115
+ args: [cli.entry, "denied-after"],
116
+ // Never in a tool call's way: Claude Code starts it and carries on.
117
+ async: true,
118
+ timeout: DENIED_AFTER_TIMEOUT_SECONDS,
119
+ },
120
+ ],
121
+ };
122
+ return {
123
+ PermissionDenied: [
124
+ {
125
+ matcher: "*",
126
+ hooks: [
127
+ {
128
+ type: "command",
129
+ command: cli.node,
130
+ args: [cli.entry, "denied-hook", ...flags, "--timeout", String(approve_hook_1.DEFAULT_WAIT_SECONDS)],
131
+ // Our own deadline (which withdraws the card) lands first; being killed would only
132
+ // leave the denial standing, but also the card looking answerable.
133
+ timeout: approve_hook_1.RECOMMENDED_HANDLER_TIMEOUT_SECONDS,
134
+ statusMessage: "Blocked by auto mode — asking in BayChat…",
135
+ },
136
+ ],
137
+ },
138
+ ],
139
+ PostToolUse: [after],
140
+ PostToolUseFailure: [after],
141
+ };
142
+ }
143
+ /** `settings` with the denied-command card's hooks added beside its own. Pure. */
144
+ function withDeniedCard(settings, cli, flags) {
145
+ return { ...settings, hooks: { ...(settings.hooks ?? {}), ...deniedCardHooks(cli, flags) } };
146
+ }
147
+ /** The file's settings, with the card's hooks when the owner turned it on. */
148
+ function withOptIn(settings, cli, flags) {
149
+ return (0, opt_in_1.deniedCardEnabled)() ? withDeniedCard(settings, cli, flags) : settings;
150
+ }
97
151
  /**
98
152
  * Claude Code nudges that stop a session with a menu nobody can answer, switched off for
99
153
  * launched sessions only. "Teach auto mode about your environment?" (1 Yes / 2 Not now / 3 Don't
@@ -130,7 +184,7 @@ exports.TERMINAL_SETTINGS_FILE = "claude-terminal.settings.json";
130
184
  */
131
185
  function writeTerminalApprovalSettings(cli, dir = approvalsDir()) {
132
186
  const file = path.join(dir, exports.TERMINAL_SETTINGS_FILE);
133
- writePrivateJson(dir, file, terminalApprovalSettings(cli));
187
+ writePrivateJson(dir, file, withOptIn(terminalApprovalSettings(cli), cli, ["--session-from-claude"]));
134
188
  return file;
135
189
  }
136
190
  function writePrivateJson(dir, file, value) {
@@ -145,7 +199,7 @@ function currentCliLaunch() {
145
199
  /** Write the session's settings file (0600) and return its path. `sessionName` must already be validated. */
146
200
  function writeApprovalSettings(sessionName, cli, dir = launchDir(), bayEntry) {
147
201
  const file = approvalSettingsPath(sessionName, dir);
148
- writePrivateJson(dir, file, approvalSettings(sessionName, cli, bayEntry));
202
+ writePrivateJson(dir, file, withOptIn(approvalSettings(sessionName, cli, bayEntry), cli, ["--session", sessionName]));
149
203
  return file;
150
204
  }
151
205
  /** Delete the session's settings file. Idempotent: an absent file is already deleted. */
@@ -66,7 +66,7 @@ const BACKOFF_MAX_MS = 60_000;
66
66
  const CAPABILITIES_REFRESH_MS = 10 * 60_000;
67
67
  /** A long-poll holds for WAIT_SECONDS; give the request this much beyond it before calling it dead. */
68
68
  const REQUEST_GRACE_MS = 15_000;
69
- async function startLauncher({ auth, log, signal, deps: overrides }) {
69
+ async function startLauncher({ auth, log, signal, onLaunch, deps: overrides }) {
70
70
  const deps = { ...realLoopDeps(log, signal), ...overrides };
71
71
  let advertised;
72
72
  let unreported;
@@ -76,6 +76,7 @@ async function startLauncher({ auth, log, signal, deps: overrides }) {
76
76
  if (unreported) {
77
77
  await deps.request(auth, "POST", `/api/device-api/launches/${encodeURIComponent(unreported.id)}/report`, unreported.report, signal);
78
78
  unreported = undefined;
79
+ onLaunch?.(false);
79
80
  }
80
81
  const stamp = deps.policyStamp();
81
82
  if (!advertised || advertised.stamp !== stamp || deps.now() - advertised.at >= CAPABILITIES_REFRESH_MS) {
@@ -93,9 +94,11 @@ async function startLauncher({ auth, log, signal, deps: overrides }) {
93
94
  else {
94
95
  // Kept until delivered: a report lost to the network is sent before the next poll.
95
96
  const io = launchIo(deps, auth, id, signal);
97
+ onLaunch?.(true);
96
98
  unreported = { id, report: launch ? await safeHandle(deps, launch, io, log) : { status: "REFUSED", reason: "BAD_REQUEST" } };
97
99
  await deps.request(auth, "POST", `/api/device-api/launches/${encodeURIComponent(id)}/report`, unreported.report, signal);
98
100
  unreported = undefined;
101
+ onLaunch?.(false);
99
102
  }
100
103
  }
101
104
  backoff = BACKOFF_MIN_MS;
@@ -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
  }
package/dist/runtimes.js CHANGED
@@ -181,13 +181,13 @@ ${ctx.invocation}
181
181
  \`\`\`
182
182
 
183
183
  - The supplied name becomes both your BayChat agent name and session name automatically.
184
- - With a name only: the shared Sessions group in your Bay, plus your private owner chat.
184
+ - With a name only: the shared Sessions group in your Bay. No private chat is created.
185
185
  - With \`--sessions\` and no name: automatically name this verified session and join Sessions.
186
186
  - With \`<name> --private\`: join only your private owner chat.
187
187
  - With a name and a group title: join that exact group, or create it if missing.
188
188
  - With a name and a group id (\`--group "<id>"\`, as a session started from the
189
189
  BayChat app gets): join that group; never create one.
190
- Joining an existing group requires membership and admin rights. Your private chat stays available under the same name.
190
+ Joining an existing group requires membership and admin rights. A group join creates no private chat; one you already have stays available under the same name.
191
191
  \`list_groups\` prints the exact titles.
192
192
  - With \`--group "<title>"\` and no name: automatic naming is explicitly requested.
193
193
  The join command chooses a name from this verified native session. It stays
@@ -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 {