baychat 0.12.0 → 0.13.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/README.md +47 -1
- package/dist/api.js +58 -0
- package/dist/approve-hook.js +425 -0
- package/dist/attachments.js +273 -0
- package/dist/commands.js +41 -3
- package/dist/index.js +62 -4
- package/dist/mcp-files.js +442 -0
- package/dist/mcp.js +187 -21
- package/dist/protocol-content.js +1 -1
- package/dist/relay/adapters.js +48 -5
- package/dist/relay/autostart.js +324 -0
- package/dist/relay/commands.js +80 -49
- package/dist/relay/daemon.js +1 -0
- package/dist/relay/socket.js +55 -6
- package/dist/runtimes.js +75 -10
- package/dist/tool-defs.js +205 -60
- package/package.json +1 -1
|
@@ -0,0 +1,324 @@
|
|
|
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.AUTOSTART_IMPLEMENTATIONS = exports.LingerUnavailable = exports.SYSTEMD_UNIT_NAME = exports.SERVICE_LABEL = void 0;
|
|
37
|
+
exports.autostartForPlatform = autostartForPlatform;
|
|
38
|
+
exports.relayCommand = relayCommand;
|
|
39
|
+
// Making the relay come back by itself, on all three platforms.
|
|
40
|
+
//
|
|
41
|
+
// THE BUG THIS EXISTS TO FIX. `baychat relay start` used to write a systemd user
|
|
42
|
+
// unit and, off Linux, print "Automatic startup needs systemd" and hand the user
|
|
43
|
+
// `--foreground` to run themselves. A foreground process dies with the terminal
|
|
44
|
+
// that started it, so on macOS and Windows the "wake my session instantly" half of
|
|
45
|
+
// BayChat was unattended-broken: the relay ran exactly as long as somebody kept a
|
|
46
|
+
// window open, and every reboot silently turned wake-ups off. Recorded as the
|
|
47
|
+
// motivating finding of `docs/prd/desktop_app_spec.md` §2, whose Stage 1 this is.
|
|
48
|
+
//
|
|
49
|
+
// THE SHAPE. One `Autostart` per init system, chosen by `process.platform`. Each
|
|
50
|
+
// splits into two halves on purpose:
|
|
51
|
+
//
|
|
52
|
+
// - `unit()` — a PURE function from a command line to the text the init system
|
|
53
|
+
// wants (a systemd unit, a launchd plist, a Task Scheduler XML). No I/O, no
|
|
54
|
+
// spawning, so the part that is easy to get wrong is the part a test can read.
|
|
55
|
+
// - `install()` — writes that text and runs the one command that arms it. This
|
|
56
|
+
// half cannot be tested off its own platform, which is exactly why it is kept
|
|
57
|
+
// as thin as it is.
|
|
58
|
+
//
|
|
59
|
+
// WHY NOT A DESKTOP APP INSTEAD. Eventually, yes — the same spec's Stage 3 moves
|
|
60
|
+
// the relay inside an Electron main process. This lands first because it is a week
|
|
61
|
+
// rather than a month, it fixes the breakage for people who will never install a
|
|
62
|
+
// desktop app, and the spec says so: "Pays off even if the app is never built."
|
|
63
|
+
const child_process_1 = require("child_process");
|
|
64
|
+
const fs = __importStar(require("fs"));
|
|
65
|
+
const os = __importStar(require("os"));
|
|
66
|
+
const path = __importStar(require("path"));
|
|
67
|
+
const util_1 = require("util");
|
|
68
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
69
|
+
/** The reverse-DNS name macOS and Windows both want, and the systemd unit's stem.
|
|
70
|
+
* One constant so a rename cannot half-happen: a plist labelled one thing and
|
|
71
|
+
* bootstrapped as another leaves an agent nobody can stop. */
|
|
72
|
+
exports.SERVICE_LABEL = "io.baychat.relay";
|
|
73
|
+
exports.SYSTEMD_UNIT_NAME = "baychat-relay.service";
|
|
74
|
+
/**
|
|
75
|
+
* Linux — a systemd **user** unit.
|
|
76
|
+
*
|
|
77
|
+
* `enable-linger` is what makes "after reboot" true rather than "after the next
|
|
78
|
+
* login": without it a user manager only runs while somebody is logged in, so a
|
|
79
|
+
* headless box that rebooted overnight comes back deaf.
|
|
80
|
+
*/
|
|
81
|
+
const systemd = {
|
|
82
|
+
name: "systemd",
|
|
83
|
+
unit: ({ node, entry }) => `[Unit]
|
|
84
|
+
Description=BayChat relay — wakes local agent sessions on new messages
|
|
85
|
+
After=network-online.target
|
|
86
|
+
Wants=network-online.target
|
|
87
|
+
|
|
88
|
+
[Service]
|
|
89
|
+
Type=simple
|
|
90
|
+
ExecStart=${node} ${entry} relay start --foreground
|
|
91
|
+
Restart=always
|
|
92
|
+
RestartSec=5
|
|
93
|
+
Environment=NODE_ENV=production
|
|
94
|
+
|
|
95
|
+
[Install]
|
|
96
|
+
WantedBy=default.target
|
|
97
|
+
`,
|
|
98
|
+
unitPath: () => path.join(os.homedir(), ".config", "systemd", "user", exports.SYSTEMD_UNIT_NAME),
|
|
99
|
+
async install(cmd) {
|
|
100
|
+
const target = systemd.unitPath();
|
|
101
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
102
|
+
fs.writeFileSync(target, systemd.unit(cmd), { mode: 0o644 });
|
|
103
|
+
await execFileAsync("systemctl", ["--user", "daemon-reload"]);
|
|
104
|
+
await execFileAsync("systemctl", ["--user", "enable", "--now", exports.SYSTEMD_UNIT_NAME]);
|
|
105
|
+
try {
|
|
106
|
+
await execFileAsync("loginctl", ["enable-linger", os.userInfo().username]);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Not fatal and not silent: the caller prints the degraded promise rather
|
|
110
|
+
// than claiming boot-time startup it did not get.
|
|
111
|
+
throw new LingerUnavailable();
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
async uninstall() {
|
|
115
|
+
await execFileAsync("systemctl", ["--user", "disable", "--now", exports.SYSTEMD_UNIT_NAME]).catch(() => undefined);
|
|
116
|
+
fs.rmSync(systemd.unitPath(), { force: true });
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
/** Thrown when the unit is installed but only starts at login, not at boot. The
|
|
120
|
+
* relay works; the promise made to the user is the smaller one, and saying so is
|
|
121
|
+
* the whole point of having a distinct type rather than a swallowed catch. */
|
|
122
|
+
class LingerUnavailable extends Error {
|
|
123
|
+
constructor() {
|
|
124
|
+
super("relay will start at login rather than at boot (could not enable linger)");
|
|
125
|
+
this.name = "LingerUnavailable";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
exports.LingerUnavailable = LingerUnavailable;
|
|
129
|
+
/**
|
|
130
|
+
* macOS — a launchd **LaunchAgent** in the user's own domain.
|
|
131
|
+
*
|
|
132
|
+
* `RunAtLoad` + `KeepAlive` is launchd's equivalent of `Restart=always`, and the
|
|
133
|
+
* `gui/<uid>` domain is what makes it a per-user agent rather than a root daemon:
|
|
134
|
+
* the relay reads `~/.baychat/credentials.json` and must never run as anyone else.
|
|
135
|
+
*
|
|
136
|
+
* `bootstrap` (not the deprecated `load`) is used because `load` reports success
|
|
137
|
+
* for a plist it silently refused, which is the failure mode this whole file
|
|
138
|
+
* exists to avoid.
|
|
139
|
+
*/
|
|
140
|
+
const launchd = {
|
|
141
|
+
name: "launchd",
|
|
142
|
+
unit: ({ node, entry }) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
143
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
144
|
+
<plist version="1.0">
|
|
145
|
+
<dict>
|
|
146
|
+
<key>Label</key>
|
|
147
|
+
<string>${exports.SERVICE_LABEL}</string>
|
|
148
|
+
<key>ProgramArguments</key>
|
|
149
|
+
<array>
|
|
150
|
+
<string>${escapeXml(node)}</string>
|
|
151
|
+
<string>${escapeXml(entry)}</string>
|
|
152
|
+
<string>relay</string>
|
|
153
|
+
<string>start</string>
|
|
154
|
+
<string>--foreground</string>
|
|
155
|
+
</array>
|
|
156
|
+
<key>RunAtLoad</key>
|
|
157
|
+
<true/>
|
|
158
|
+
<key>KeepAlive</key>
|
|
159
|
+
<true/>
|
|
160
|
+
<key>EnvironmentVariables</key>
|
|
161
|
+
<dict>
|
|
162
|
+
<key>NODE_ENV</key>
|
|
163
|
+
<string>production</string>
|
|
164
|
+
</dict>
|
|
165
|
+
<key>ProcessType</key>
|
|
166
|
+
<string>Background</string>
|
|
167
|
+
</dict>
|
|
168
|
+
</plist>
|
|
169
|
+
`,
|
|
170
|
+
unitPath: () => path.join(os.homedir(), "Library", "LaunchAgents", `${exports.SERVICE_LABEL}.plist`),
|
|
171
|
+
async install(cmd) {
|
|
172
|
+
const target = launchd.unitPath();
|
|
173
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
174
|
+
fs.writeFileSync(target, launchd.unit(cmd), { mode: 0o644 });
|
|
175
|
+
const domain = `gui/${os.userInfo().uid}`;
|
|
176
|
+
// Replacing an agent means removing the old one first; bootout on a label
|
|
177
|
+
// that was never loaded exits non-zero, which is not a failure here.
|
|
178
|
+
await execFileAsync("launchctl", ["bootout", `${domain}/${exports.SERVICE_LABEL}`]).catch(() => undefined);
|
|
179
|
+
await execFileAsync("launchctl", ["bootstrap", domain, target]);
|
|
180
|
+
},
|
|
181
|
+
async uninstall() {
|
|
182
|
+
const domain = `gui/${os.userInfo().uid}`;
|
|
183
|
+
await execFileAsync("launchctl", ["bootout", `${domain}/${exports.SERVICE_LABEL}`]).catch(() => undefined);
|
|
184
|
+
fs.rmSync(launchd.unitPath(), { force: true });
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Windows — a Task Scheduler task, triggered at logon.
|
|
189
|
+
*
|
|
190
|
+
* Three candidates were considered and two rejected, because the difference
|
|
191
|
+
* matters to whether wake-ups survive a crash:
|
|
192
|
+
*
|
|
193
|
+
* - A **Startup-folder shortcut** or a **Run registry key** launches the process
|
|
194
|
+
* once and never looks at it again. A relay that dies at 3am stays dead until
|
|
195
|
+
* the next login — the same "works while somebody is watching" failure this
|
|
196
|
+
* file replaces.
|
|
197
|
+
* - A **Windows Service** would restart it, but installing one needs elevation
|
|
198
|
+
* and a service wrapper; asking for an admin prompt to receive chat messages
|
|
199
|
+
* is the wrong trade.
|
|
200
|
+
* - A **scheduled task** needs no elevation in the user's own context, and
|
|
201
|
+
* `RestartOnFailure` gives the `Restart=always` behaviour the other two
|
|
202
|
+
* platforms have.
|
|
203
|
+
*
|
|
204
|
+
* `schtasks /create /xml` is used rather than the flag soup of `/sc onlogon`,
|
|
205
|
+
* because the flags cannot express the restart policy and cannot turn off the
|
|
206
|
+
* "stop after 3 days" default that would quietly end the relay every third day.
|
|
207
|
+
*/
|
|
208
|
+
const scheduledTask = {
|
|
209
|
+
name: "Task Scheduler",
|
|
210
|
+
unit: (cmd) => `<?xml version="1.0" encoding="UTF-16"?>
|
|
211
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
212
|
+
<RegistrationInfo>
|
|
213
|
+
<Description>BayChat relay — wakes local agent sessions on new messages</Description>
|
|
214
|
+
</RegistrationInfo>
|
|
215
|
+
<Triggers>
|
|
216
|
+
<LogonTrigger>
|
|
217
|
+
<Enabled>true</Enabled>
|
|
218
|
+
<UserId>${escapeXml(windowsUserId())}</UserId>
|
|
219
|
+
</LogonTrigger>
|
|
220
|
+
</Triggers>
|
|
221
|
+
<Principals>
|
|
222
|
+
<Principal id="Author">
|
|
223
|
+
<UserId>${escapeXml(windowsUserId())}</UserId>
|
|
224
|
+
<LogonType>InteractiveToken</LogonType>
|
|
225
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
226
|
+
</Principal>
|
|
227
|
+
</Principals>
|
|
228
|
+
<Settings>
|
|
229
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
230
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
231
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
232
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
233
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
234
|
+
<IdleSettings>
|
|
235
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
236
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
237
|
+
</IdleSettings>
|
|
238
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
239
|
+
<Enabled>true</Enabled>
|
|
240
|
+
<Hidden>true</Hidden>
|
|
241
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
242
|
+
<RestartOnFailure>
|
|
243
|
+
<Interval>PT1M</Interval>
|
|
244
|
+
<Count>999</Count>
|
|
245
|
+
</RestartOnFailure>
|
|
246
|
+
</Settings>
|
|
247
|
+
<Actions Context="Author">
|
|
248
|
+
<Exec>
|
|
249
|
+
<Command>${escapeXml(cmd.node)}</Command>
|
|
250
|
+
<Arguments>"${escapeXml(cmd.entry)}" relay start --foreground</Arguments>
|
|
251
|
+
</Exec>
|
|
252
|
+
</Actions>
|
|
253
|
+
</Task>
|
|
254
|
+
`,
|
|
255
|
+
// Written beside the credentials rather than in %TEMP%: schtasks reads it once,
|
|
256
|
+
// but keeping it means `relay status` can show what was actually registered.
|
|
257
|
+
unitPath: () => path.join(configHome(), "relay-task.xml"),
|
|
258
|
+
async install(cmd) {
|
|
259
|
+
const target = scheduledTask.unitPath();
|
|
260
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
261
|
+
// UTF-16LE with a BOM, because that is what the XML declaration above claims
|
|
262
|
+
// and what schtasks refuses the file without.
|
|
263
|
+
fs.writeFileSync(target, "\ufeff" + scheduledTask.unit(cmd), { encoding: "utf16le" });
|
|
264
|
+
await execFileAsync("schtasks", ["/create", "/tn", exports.SERVICE_LABEL, "/xml", target, "/f"]);
|
|
265
|
+
await execFileAsync("schtasks", ["/run", "/tn", exports.SERVICE_LABEL]);
|
|
266
|
+
},
|
|
267
|
+
async uninstall() {
|
|
268
|
+
await execFileAsync("schtasks", ["/delete", "/tn", exports.SERVICE_LABEL, "/f"]).catch(() => undefined);
|
|
269
|
+
fs.rmSync(scheduledTask.unitPath(), { force: true });
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
/** `DOMAIN\user`, which is what a task's `UserId` wants. `USERDOMAIN` is absent
|
|
273
|
+
* on a machine that was never joined to a domain; the bare username is correct
|
|
274
|
+
* there, and inventing a domain would register a task for a principal that does
|
|
275
|
+
* not exist. */
|
|
276
|
+
function windowsUserId() {
|
|
277
|
+
const user = process.env.USERNAME || os.userInfo().username;
|
|
278
|
+
const domain = process.env.USERDOMAIN;
|
|
279
|
+
return domain ? `${domain}\\${user}` : user;
|
|
280
|
+
}
|
|
281
|
+
/** Duplicated from `config.ts` rather than imported, and deliberately: importing
|
|
282
|
+
* it here would pull the credentials module into a file that must stay loadable
|
|
283
|
+
* on a machine with no credentials at all. */
|
|
284
|
+
function configHome() {
|
|
285
|
+
return process.env.BAYCHAT_CONFIG_DIR || path.join(os.homedir(), ".baychat");
|
|
286
|
+
}
|
|
287
|
+
/** The five characters that would otherwise end the element early. Paths can
|
|
288
|
+
* legally contain `&` on every platform this ships to, and a home directory with
|
|
289
|
+
* one in it would produce a plist launchd rejects with no useful message. */
|
|
290
|
+
function escapeXml(value) {
|
|
291
|
+
return value
|
|
292
|
+
.replace(/&/g, "&")
|
|
293
|
+
.replace(/</g, "<")
|
|
294
|
+
.replace(/>/g, ">")
|
|
295
|
+
.replace(/"/g, """)
|
|
296
|
+
.replace(/'/g, "'");
|
|
297
|
+
}
|
|
298
|
+
const BY_PLATFORM = {
|
|
299
|
+
linux: systemd,
|
|
300
|
+
darwin: launchd,
|
|
301
|
+
win32: scheduledTask,
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* The init system for this machine, or `undefined` where BayChat has none.
|
|
305
|
+
*
|
|
306
|
+
* `undefined` rather than a throwing default, because every caller has something
|
|
307
|
+
* better to do than fail: `connect` prints the manual command and carries on, and
|
|
308
|
+
* `relay start` explains itself. The platforms that reach `undefined` are the
|
|
309
|
+
* BSDs and anything exotic — never the three this product ships to.
|
|
310
|
+
*/
|
|
311
|
+
function autostartForPlatform(platform = process.platform) {
|
|
312
|
+
return BY_PLATFORM[platform];
|
|
313
|
+
}
|
|
314
|
+
/** The command line the init system should run, resolved from this process.
|
|
315
|
+
*
|
|
316
|
+
* `process.argv[1]` is the script node was given; resolving it is what keeps an
|
|
317
|
+
* npx-run or `npm link`ed CLI producing a unit that still works after the shell
|
|
318
|
+
* that installed it is gone. */
|
|
319
|
+
function relayCommand() {
|
|
320
|
+
return { node: process.execPath, entry: path.resolve(process.argv[1] ?? "") };
|
|
321
|
+
}
|
|
322
|
+
/** Exported for tests, which need to reach a platform other than the one they
|
|
323
|
+
* run on. Production code goes through {@link autostartForPlatform}. */
|
|
324
|
+
exports.AUTOSTART_IMPLEMENTATIONS = { systemd, launchd, scheduledTask };
|
package/dist/relay/commands.js
CHANGED
|
@@ -38,18 +38,18 @@ exports.ensureRelayInstalled = ensureRelayInstalled;
|
|
|
38
38
|
exports.cmdRelayStatus = cmdRelayStatus;
|
|
39
39
|
exports.cmdRelayStop = cmdRelayStop;
|
|
40
40
|
exports.cmdRelayAttach = cmdRelayAttach;
|
|
41
|
+
exports.resolveRuntimeBin = resolveRuntimeBin;
|
|
41
42
|
const child_process_1 = require("child_process");
|
|
42
43
|
const fs = __importStar(require("fs"));
|
|
43
44
|
const net = __importStar(require("net"));
|
|
44
|
-
const os = __importStar(require("os"));
|
|
45
45
|
const path = __importStar(require("path"));
|
|
46
46
|
const util_1 = require("util");
|
|
47
47
|
const adapters_1 = require("./adapters");
|
|
48
|
+
const autostart_1 = require("./autostart");
|
|
48
49
|
const daemon_1 = require("./daemon");
|
|
49
50
|
const resume_1 = require("./resume");
|
|
50
51
|
const socket_1 = require("./socket");
|
|
51
52
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
52
|
-
const UNIT_NAME = "baychat-relay.service";
|
|
53
53
|
/** Connect to a running daemon, or explain that there isn't one. */
|
|
54
54
|
async function connectOrFail() {
|
|
55
55
|
const sockPath = (0, socket_1.socketPath)();
|
|
@@ -106,53 +106,28 @@ async function cmdRelayStart(opts = {}) {
|
|
|
106
106
|
console.log("Relay is already running. `baychat relay status` for details.");
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
const autostart = (0, autostart_1.autostartForPlatform)();
|
|
110
|
+
if (!autostart) {
|
|
111
|
+
console.log(`No automatic startup is available on ${process.platform}. Run the daemon`);
|
|
112
|
+
console.log("yourself with:");
|
|
111
113
|
console.log(" baychat relay start --foreground");
|
|
112
114
|
return;
|
|
113
115
|
}
|
|
114
|
-
await installUserUnit();
|
|
115
|
-
console.log("Relay started and enabled at boot.");
|
|
116
|
-
console.log(" baychat relay status — see sessions, cursor, pending deliveries");
|
|
117
|
-
console.log(" baychat relay stop — stop it");
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Write and enable the user unit.
|
|
121
|
-
*
|
|
122
|
-
* `enable-linger` is what makes "after reboot" true rather than "after the
|
|
123
|
-
* next login": without it a user manager only runs while someone is logged in,
|
|
124
|
-
* so a headless box that rebooted overnight would come back deaf.
|
|
125
|
-
*/
|
|
126
|
-
async function installUserUnit() {
|
|
127
|
-
const unitDir = path.join(os.homedir(), ".config", "systemd", "user");
|
|
128
|
-
fs.mkdirSync(unitDir, { recursive: true });
|
|
129
|
-
// Resolve the installed CLI entry point rather than assuming a global name —
|
|
130
|
-
// an npx-run or locally-linked copy must produce a unit that keeps working.
|
|
131
|
-
const entry = path.resolve(process.argv[1]);
|
|
132
|
-
const unit = `[Unit]
|
|
133
|
-
Description=BayChat relay — wakes local agent sessions on new messages
|
|
134
|
-
After=network-online.target
|
|
135
|
-
Wants=network-online.target
|
|
136
|
-
|
|
137
|
-
[Service]
|
|
138
|
-
Type=simple
|
|
139
|
-
ExecStart=${process.execPath} ${entry} relay start --foreground
|
|
140
|
-
Restart=always
|
|
141
|
-
RestartSec=5
|
|
142
|
-
Environment=NODE_ENV=production
|
|
143
|
-
|
|
144
|
-
[Install]
|
|
145
|
-
WantedBy=default.target
|
|
146
|
-
`;
|
|
147
|
-
fs.writeFileSync(path.join(unitDir, UNIT_NAME), unit, { mode: 0o644 });
|
|
148
|
-
await execFileAsync("systemctl", ["--user", "daemon-reload"]);
|
|
149
|
-
await execFileAsync("systemctl", ["--user", "enable", "--now", UNIT_NAME]);
|
|
150
116
|
try {
|
|
151
|
-
await
|
|
117
|
+
await autostart.install((0, autostart_1.relayCommand)());
|
|
118
|
+
console.log(`Relay started and enabled at boot (${autostart.name}).`);
|
|
152
119
|
}
|
|
153
|
-
catch {
|
|
154
|
-
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (!(err instanceof autostart_1.LingerUnavailable))
|
|
122
|
+
throw err;
|
|
123
|
+
// The relay IS installed and running; only the boot-time half is missing.
|
|
124
|
+
// Reported rather than swallowed, because "enabled at boot" would be a lie
|
|
125
|
+
// and the user would not find out until the machine next rebooted unattended.
|
|
126
|
+
console.log(`Relay started and enabled at login (${autostart.name}).`);
|
|
127
|
+
console.log(`Note: ${err.message}.`);
|
|
155
128
|
}
|
|
129
|
+
console.log(" baychat relay status — see sessions, cursor, pending deliveries");
|
|
130
|
+
console.log(" baychat relay stop — stop it");
|
|
156
131
|
}
|
|
157
132
|
/**
|
|
158
133
|
* Best-effort relay setup, run as the last step of `baychat connect`.
|
|
@@ -172,10 +147,18 @@ async function ensureRelayInstalled() {
|
|
|
172
147
|
}
|
|
173
148
|
if (await (0, socket_1.probeSocket)((0, socket_1.socketPath)()))
|
|
174
149
|
return "Relay: already running — sessions wake instantly.";
|
|
175
|
-
|
|
176
|
-
|
|
150
|
+
const autostart = (0, autostart_1.autostartForPlatform)();
|
|
151
|
+
if (!autostart) {
|
|
152
|
+
return `Relay: no automatic startup on ${process.platform}. Run \`baychat relay start --foreground\` to wake sessions instantly.`;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await autostart.install((0, autostart_1.relayCommand)());
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
if (!(err instanceof autostart_1.LingerUnavailable))
|
|
159
|
+
throw err;
|
|
160
|
+
return `Relay: installed and started, but only at login — ${err.message}.`;
|
|
177
161
|
}
|
|
178
|
-
await installUserUnit();
|
|
179
162
|
return "Relay: installed and started — sessions now wake the moment a message arrives.";
|
|
180
163
|
}
|
|
181
164
|
catch (err) {
|
|
@@ -249,10 +232,15 @@ function transportLabel(status) {
|
|
|
249
232
|
return status.transport === "websocket" ? `websocket${detail}` : `long-poll${detail}`;
|
|
250
233
|
}
|
|
251
234
|
async function cmdRelayStop() {
|
|
252
|
-
|
|
235
|
+
// Disarm the init system FIRST. Stopping the process without it means the
|
|
236
|
+
// relay is back within the minute — systemd's Restart=always, launchd's
|
|
237
|
+
// KeepAlive and the scheduled task's RestartOnFailure all exist to make that
|
|
238
|
+
// happen, and all three would make `relay stop` look like it silently failed.
|
|
239
|
+
const autostart = (0, autostart_1.autostartForPlatform)();
|
|
240
|
+
if (autostart && fs.existsSync(autostart.unitPath())) {
|
|
253
241
|
try {
|
|
254
|
-
await
|
|
255
|
-
console.log(
|
|
242
|
+
await autostart.uninstall();
|
|
243
|
+
console.log(`Relay stopped and disabled at boot (${autostart.name}).`);
|
|
256
244
|
return 0;
|
|
257
245
|
}
|
|
258
246
|
catch {
|
|
@@ -345,9 +333,52 @@ async function cmdRelayAttach(opts) {
|
|
|
345
333
|
resumeEvidence: resume.ok ? resume.evidence : undefined,
|
|
346
334
|
resumeCwd: resume.ok ? resume.cwd : undefined,
|
|
347
335
|
cwd: process.cwd(),
|
|
336
|
+
// Resolved HERE, inside the session, because this is the only process that
|
|
337
|
+
// can answer it correctly — see SessionTarget.runtimeBin.
|
|
338
|
+
runtimeBin: resolveRuntimeBin(runtime),
|
|
348
339
|
});
|
|
349
340
|
});
|
|
350
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* The absolute path to the runtime binary this session is running under, or
|
|
344
|
+
* `undefined` if it cannot be established.
|
|
345
|
+
*
|
|
346
|
+
* WHY THE ATTACHING PROCESS AND NOT THE DAEMON. The daemon inherits whatever
|
|
347
|
+
* environment started it — typically a systemd unit or a login shell from days ago
|
|
348
|
+
* — and spawning a bare `claude` asks THAT environment to find the binary. On the
|
|
349
|
+
* machine where this bug was found it could not: `claude` lived on an interactive
|
|
350
|
+
* shell's PATH and not the daemon's, so every detached wake died with ENOENT and no
|
|
351
|
+
* message ever said so. `attach` runs inside the session itself, so its PATH is the
|
|
352
|
+
* one the human actually uses.
|
|
353
|
+
*
|
|
354
|
+
* `process.execPath` is deliberately NOT used: that is the Node binary running this
|
|
355
|
+
* CLI, which for a globally-installed `baychat` is not the runtime at all.
|
|
356
|
+
*
|
|
357
|
+
* Returns undefined rather than throwing or guessing. The adapters fall back to the
|
|
358
|
+
* bare name, which is exactly the behaviour that shipped before this existed — a
|
|
359
|
+
* machine where PATH is fine keeps working, and one where it is not now works too.
|
|
360
|
+
*/
|
|
361
|
+
function resolveRuntimeBin(runtime) {
|
|
362
|
+
const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
363
|
+
for (const dir of dirs) {
|
|
364
|
+
const candidate = path.join(dir, runtime);
|
|
365
|
+
try {
|
|
366
|
+
// X_OK, not existsSync: a same-named directory or an unreadable file on PATH
|
|
367
|
+
// must not be recorded as the binary, or the recorded path is worse than the
|
|
368
|
+
// bare name it replaced.
|
|
369
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
370
|
+
// Resolve symlinks: ~/.local/bin/claude is typically a link into a versioned
|
|
371
|
+
// directory, and recording the link means a later version bump silently
|
|
372
|
+
// repoints every wake. The real path is what this session is actually running.
|
|
373
|
+
return fs.realpathSync(candidate);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// Not here; keep looking. No logging — a PATH entry that does not hold the
|
|
377
|
+
// binary is the normal case, not a problem.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
351
382
|
/**
|
|
352
383
|
* Decide what resume id this attach registers, and say so out loud.
|
|
353
384
|
*
|
package/dist/relay/daemon.js
CHANGED
|
@@ -303,6 +303,7 @@ class RelayDaemon {
|
|
|
303
303
|
resumeEvidence: frame.resumeEvidence,
|
|
304
304
|
resumeCwd: frame.resumeCwd,
|
|
305
305
|
cwd: frame.cwd,
|
|
306
|
+
runtimeBin: frame.runtimeBin,
|
|
306
307
|
});
|
|
307
308
|
// A session that names itself supersedes anything discovery guessed
|
|
308
309
|
// for it, so drop the throttle and let the next wake use the new id.
|
package/dist/relay/socket.js
CHANGED
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.socketPath = socketPath;
|
|
37
|
+
exports.isNamedPipe = isNamedPipe;
|
|
37
38
|
exports.pidFilePath = pidFilePath;
|
|
38
39
|
exports.createFrameReader = createFrameReader;
|
|
39
40
|
exports.writeFrame = writeFrame;
|
|
@@ -41,24 +42,60 @@ exports.probeSocket = probeSocket;
|
|
|
41
42
|
exports.unlinkStaleSocket = unlinkStaleSocket;
|
|
42
43
|
const fs = __importStar(require("fs"));
|
|
43
44
|
const net = __importStar(require("net"));
|
|
45
|
+
const os = __importStar(require("os"));
|
|
44
46
|
const path = __importStar(require("path"));
|
|
45
47
|
const config_1 = require("../config");
|
|
46
48
|
/**
|
|
47
|
-
* Where the attach
|
|
49
|
+
* Where the attach channel lives.
|
|
48
50
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
51
|
+
* On Unix this is a filesystem path to a socket. `XDG_RUNTIME_DIR` is the right
|
|
52
|
+
* home: it is user-private (0700), on tmpfs, and cleared on logout, so a stale
|
|
53
|
+
* socket never outlives the session that made it. The config-dir fallback covers
|
|
54
|
+
* boxes without it — some containers, and macOS, which has no XDG_RUNTIME_DIR at
|
|
55
|
+
* all.
|
|
56
|
+
*
|
|
57
|
+
* ON WINDOWS IT IS NOT A PATH. Windows has no filesystem-visible Unix sockets:
|
|
58
|
+
* `net.createServer().listen("C:\\Users\\…\\relay.sock")` does not create a
|
|
59
|
+
* listener a peer can reach, and `net.createConnection` on the same string does
|
|
60
|
+
* not connect. Node's `net` speaks NAMED PIPES there instead, addressed as
|
|
61
|
+
* `\\.\pipe\<name>` — the same API, a different namespace, so every call site
|
|
62
|
+
* that already went through this function keeps working untouched.
|
|
63
|
+
*
|
|
64
|
+
* The name carries the username because the pipe namespace is machine-wide while
|
|
65
|
+
* the relay is per-user: two people signed in to one Windows box must not collide
|
|
66
|
+
* on one pipe, and must not be able to reach each other's daemon by opening it.
|
|
67
|
+
* (Reaching it is separately refused by the pipe's default ACL, which admits only
|
|
68
|
+
* the creating user — the name keeps the two daemons from fighting over it.)
|
|
52
69
|
*/
|
|
53
70
|
function socketPath() {
|
|
54
71
|
const override = process.env.BAYCHAT_RELAY_SOCKET;
|
|
55
72
|
if (override)
|
|
56
73
|
return override;
|
|
74
|
+
if (process.platform === "win32") {
|
|
75
|
+
return `\\\\.\\pipe\\baychat-relay-${sanitizePipeName(os.userInfo().username)}`;
|
|
76
|
+
}
|
|
57
77
|
const runtimeDir = process.env.XDG_RUNTIME_DIR;
|
|
58
78
|
if (runtimeDir)
|
|
59
79
|
return path.join(runtimeDir, "baychat-relay.sock");
|
|
60
80
|
return path.join((0, config_1.configDir)(), "relay.sock");
|
|
61
81
|
}
|
|
82
|
+
/** A pipe name may not contain a backslash — it would read as another level of
|
|
83
|
+
* the pipe namespace — and Windows usernames legally can (`DOMAIN\user` reaches
|
|
84
|
+
* `os.userInfo()` that way on some setups). Everything outside the safe set is
|
|
85
|
+
* collapsed rather than dropped, so two different users cannot sanitize to one
|
|
86
|
+
* name. */
|
|
87
|
+
function sanitizePipeName(value) {
|
|
88
|
+
return value.replace(/[^A-Za-z0-9_-]/g, "_") || "default";
|
|
89
|
+
}
|
|
90
|
+
/** Is this endpoint a file on disk, or a Windows named pipe?
|
|
91
|
+
*
|
|
92
|
+
* A pipe is not a file: it has no directory entry, `fs.existsSync` is always
|
|
93
|
+
* false for it, and unlinking it is meaningless — the OS reclaims it when the
|
|
94
|
+
* last handle closes, which is exactly why a Windows relay never leaves a stale
|
|
95
|
+
* endpoint the way a hard-killed Unix one does. */
|
|
96
|
+
function isNamedPipe(endpoint) {
|
|
97
|
+
return endpoint.startsWith("\\\\.\\pipe\\") || endpoint.startsWith("\\\\?\\pipe\\");
|
|
98
|
+
}
|
|
62
99
|
function pidFilePath() {
|
|
63
100
|
return path.join((0, config_1.configDir)(), "relay.pid");
|
|
64
101
|
}
|
|
@@ -101,7 +138,11 @@ function writeFrame(sock, frame) {
|
|
|
101
138
|
*/
|
|
102
139
|
function probeSocket(sockPath, timeoutMs = 1_000) {
|
|
103
140
|
return new Promise((resolve) => {
|
|
104
|
-
|
|
141
|
+
// The existence check is a cheap way to skip a connect that cannot succeed —
|
|
142
|
+
// but a named pipe has no directory entry, so on Windows it answers "false"
|
|
143
|
+
// for a perfectly healthy daemon. Skipped there; the connect below is the
|
|
144
|
+
// honest test on every platform anyway.
|
|
145
|
+
if (!isNamedPipe(sockPath) && !fs.existsSync(sockPath))
|
|
105
146
|
return resolve(false);
|
|
106
147
|
const sock = net.createConnection(sockPath);
|
|
107
148
|
const done = (alive) => {
|
|
@@ -113,8 +154,16 @@ function probeSocket(sockPath, timeoutMs = 1_000) {
|
|
|
113
154
|
sock.on("error", () => done(false));
|
|
114
155
|
});
|
|
115
156
|
}
|
|
116
|
-
/** Remove a socket file we have already proven dead.
|
|
157
|
+
/** Remove a socket file we have already proven dead.
|
|
158
|
+
*
|
|
159
|
+
* A no-op for a named pipe, which has nothing on disk to remove: Windows tears
|
|
160
|
+
* the pipe down when the last handle closes, so there is no stale-endpoint case
|
|
161
|
+
* to recover from there. Calling `fs.unlinkSync` on the pipe name would fail
|
|
162
|
+
* harmlessly, but silently — and a reader would be left believing this function
|
|
163
|
+
* had done something. */
|
|
117
164
|
function unlinkStaleSocket(sockPath) {
|
|
165
|
+
if (isNamedPipe(sockPath))
|
|
166
|
+
return;
|
|
118
167
|
try {
|
|
119
168
|
fs.unlinkSync(sockPath);
|
|
120
169
|
}
|