baychat 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +36 -3
- package/dist/approval-wait.js +46 -33
- package/dist/approve-hook.js +35 -1
- package/dist/auto-update/describe.js +66 -0
- package/dist/auto-update/notice.js +40 -0
- package/dist/auto-update/policy.js +110 -0
- package/dist/auto-update/relay-glue.js +143 -0
- package/dist/auto-update/store.js +140 -0
- package/dist/auto-update/updater.js +324 -0
- package/dist/channel/feed.js +50 -1
- package/dist/channel/permission.js +6 -0
- package/dist/channel/server.js +1 -0
- package/dist/claude-onboarding.js +3 -1
- package/dist/cli-help.js +6 -1
- package/dist/denied/after-hook.js +8 -2
- package/dist/denied/confirm.js +131 -0
- package/dist/denied/grants.js +60 -2
- package/dist/denied/hook-log.js +75 -0
- package/dist/denied/live-session.js +154 -0
- package/dist/denied/notice.js +29 -0
- package/dist/denied/permission-rule.js +9 -6
- package/dist/denied/settings-rules.js +61 -0
- package/dist/denied/transcript.js +39 -16
- package/dist/denied-hook.js +132 -30
- package/dist/doctor-command.js +7 -1
- package/dist/doctor.js +28 -0
- package/dist/main.js +11 -2
- package/dist/permissions-command.js +18 -1
- package/dist/protocol-content.js +1 -1
- package/dist/refresh-clients.js +36 -0
- package/dist/relay/acp/runner.js +4 -0
- package/dist/relay/commands.js +19 -3
- package/dist/relay/daemon.js +37 -1
- package/dist/relay/launcher/loop.js +4 -1
- package/dist/relay/self-restart.js +149 -0
- package/dist/relay/terminal-pane.js +74 -2
- package/dist/self-upgrade.js +92 -27
- package/dist/start-command.js +15 -1
- package/dist/update-command.js +51 -31
- package/dist/waiting-cards.js +107 -0
- package/package.json +4 -1
package/dist/relay/daemon.js
CHANGED
|
@@ -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({
|
|
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();
|
|
@@ -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/self-upgrade.js
CHANGED
|
@@ -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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 {
|
|
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 !==
|
|
182
|
+
if (onDisk !== version) {
|
|
140
183
|
return {
|
|
141
|
-
|
|
142
|
-
|
|
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 {
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
201
|
-
encoding: "utf8",
|
|
202
|
-
|
|
203
|
-
|
|
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 {
|
package/dist/start-command.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|