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.
- 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/permission.js +6 -0
- 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/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
|
@@ -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
|
*
|
package/dist/update-command.js
CHANGED
|
@@ -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
|
|
43
|
-
const commands_1 = require("./commands");
|
|
44
|
-
const
|
|
45
|
-
const
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
126
|
-
|
|
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,
|
|
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,
|
|
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
|
}
|