baychat 0.8.1 → 0.9.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.
@@ -0,0 +1,312 @@
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.cmdRelayStart = cmdRelayStart;
37
+ exports.ensureRelayInstalled = ensureRelayInstalled;
38
+ exports.cmdRelayStatus = cmdRelayStatus;
39
+ exports.cmdRelayStop = cmdRelayStop;
40
+ exports.cmdRelayAttach = cmdRelayAttach;
41
+ const child_process_1 = require("child_process");
42
+ const fs = __importStar(require("fs"));
43
+ const net = __importStar(require("net"));
44
+ const os = __importStar(require("os"));
45
+ const path = __importStar(require("path"));
46
+ const util_1 = require("util");
47
+ const adapters_1 = require("./adapters");
48
+ const daemon_1 = require("./daemon");
49
+ const socket_1 = require("./socket");
50
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
51
+ const UNIT_NAME = "baychat-relay.service";
52
+ /** Connect to a running daemon, or explain that there isn't one. */
53
+ async function connectOrFail() {
54
+ const sockPath = (0, socket_1.socketPath)();
55
+ if (!(await (0, socket_1.probeSocket)(sockPath))) {
56
+ throw new Error("no relay is running — start one with `baychat relay start`");
57
+ }
58
+ return net.createConnection(sockPath);
59
+ }
60
+ /** One request/response round trip against the daemon. */
61
+ function request(sock, frame, timeoutMs = 5_000) {
62
+ return new Promise((resolve, reject) => {
63
+ const timer = setTimeout(() => {
64
+ sock.destroy();
65
+ reject(new Error("relay did not answer in time"));
66
+ }, timeoutMs);
67
+ sock.on("data", (0, socket_1.createFrameReader)((f) => {
68
+ if (f.type === "status-result") {
69
+ clearTimeout(timer);
70
+ sock.end();
71
+ resolve(f.status);
72
+ }
73
+ else if (f.type === "error") {
74
+ clearTimeout(timer);
75
+ sock.end();
76
+ reject(new Error(f.message));
77
+ }
78
+ }));
79
+ sock.on("error", (err) => {
80
+ clearTimeout(timer);
81
+ reject(err);
82
+ });
83
+ (0, socket_1.writeFrame)(sock, frame);
84
+ });
85
+ }
86
+ /**
87
+ * `baychat relay start`
88
+ *
89
+ * `--foreground` runs the daemon in this process (what the systemd unit
90
+ * executes). Without it we install and enable a user unit, so the relay comes
91
+ * back after a reboot rather than only after the next manual start.
92
+ */
93
+ async function cmdRelayStart(opts = {}) {
94
+ if (opts.foreground) {
95
+ const daemon = new daemon_1.RelayDaemon();
96
+ const shutdown = () => {
97
+ void daemon.stop().then(() => process.exit(0));
98
+ };
99
+ process.on("SIGINT", shutdown);
100
+ process.on("SIGTERM", shutdown);
101
+ await daemon.start();
102
+ return;
103
+ }
104
+ if (await (0, socket_1.probeSocket)((0, socket_1.socketPath)())) {
105
+ console.log("Relay is already running. `baychat relay status` for details.");
106
+ return;
107
+ }
108
+ if (process.platform !== "linux") {
109
+ console.log("Automatic startup needs systemd (Linux). Run the daemon yourself with:");
110
+ console.log(" baychat relay start --foreground");
111
+ return;
112
+ }
113
+ await installUserUnit();
114
+ console.log("Relay started and enabled at boot.");
115
+ console.log(" baychat relay status — see sessions, cursor, pending deliveries");
116
+ console.log(" baychat relay stop — stop it");
117
+ }
118
+ /**
119
+ * Write and enable the user unit.
120
+ *
121
+ * `enable-linger` is what makes "after reboot" true rather than "after the
122
+ * next login": without it a user manager only runs while someone is logged in,
123
+ * so a headless box that rebooted overnight would come back deaf.
124
+ */
125
+ async function installUserUnit() {
126
+ const unitDir = path.join(os.homedir(), ".config", "systemd", "user");
127
+ fs.mkdirSync(unitDir, { recursive: true });
128
+ // Resolve the installed CLI entry point rather than assuming a global name —
129
+ // an npx-run or locally-linked copy must produce a unit that keeps working.
130
+ const entry = path.resolve(process.argv[1]);
131
+ const unit = `[Unit]
132
+ Description=BayChat relay — wakes local agent sessions on new messages
133
+ After=network-online.target
134
+ Wants=network-online.target
135
+
136
+ [Service]
137
+ Type=simple
138
+ ExecStart=${process.execPath} ${entry} relay start --foreground
139
+ Restart=always
140
+ RestartSec=5
141
+ Environment=NODE_ENV=production
142
+
143
+ [Install]
144
+ WantedBy=default.target
145
+ `;
146
+ fs.writeFileSync(path.join(unitDir, UNIT_NAME), unit, { mode: 0o644 });
147
+ await execFileAsync("systemctl", ["--user", "daemon-reload"]);
148
+ await execFileAsync("systemctl", ["--user", "enable", "--now", UNIT_NAME]);
149
+ try {
150
+ await execFileAsync("loginctl", ["enable-linger", os.userInfo().username]);
151
+ }
152
+ catch {
153
+ console.log("Note: could not enable linger — the relay will start at login rather than at boot.");
154
+ }
155
+ }
156
+ /**
157
+ * Best-effort relay setup, run as the last step of `baychat connect`.
158
+ *
159
+ * Never throws and never aborts the connect flow: a laptop without systemd
160
+ * still gets a working MCP connection, just without instant wake-ups. Returns
161
+ * the line to show the user — including when it did nothing, because silently
162
+ * skipping would leave them believing wake-ups are live when they are not.
163
+ */
164
+ async function ensureRelayInstalled() {
165
+ try {
166
+ // Escape hatch for tests and for anyone who wants `connect` to stay inert:
167
+ // installing a user unit is a side effect on the machine, and a test suite
168
+ // that drives `connect` to completion must not leave one behind.
169
+ if (process.env.BAYCHAT_NO_RELAY_AUTOSTART) {
170
+ return "Relay: auto-start skipped (BAYCHAT_NO_RELAY_AUTOSTART). Run `baychat relay start` to enable wake-ups.";
171
+ }
172
+ if (await (0, socket_1.probeSocket)((0, socket_1.socketPath)()))
173
+ return "Relay: already running — sessions wake instantly.";
174
+ if (process.platform !== "linux") {
175
+ return "Relay: not installed (needs systemd). Run `baychat relay start --foreground` to wake sessions instantly.";
176
+ }
177
+ await installUserUnit();
178
+ return "Relay: installed and started — sessions now wake the moment a message arrives.";
179
+ }
180
+ catch (err) {
181
+ return `Relay: could not start automatically (${err instanceof Error ? err.message : String(err)}). Run \`baychat relay start\` yourself.`;
182
+ }
183
+ }
184
+ async function cmdRelayStatus() {
185
+ let status;
186
+ try {
187
+ status = await request(await connectOrFail(), { type: "status" });
188
+ }
189
+ catch (err) {
190
+ console.log(err instanceof Error ? err.message : String(err));
191
+ return 1;
192
+ }
193
+ console.log(`Relay running (pid ${status.pid}, since ${status.startedAt})`);
194
+ console.log(` polls: ${status.polls} delivered: ${status.delivered} cursor: ${status.cursor ?? "—"}`);
195
+ if (status.lastError)
196
+ console.log(` last error: ${status.lastError}`);
197
+ console.log(`\nSessions (${status.sessions.length}):`);
198
+ if (status.sessions.length === 0) {
199
+ console.log(" none — a session registers itself by running `baychat relay attach`");
200
+ }
201
+ for (const s of status.sessions) {
202
+ const state = s.attached ? "attached" : s.resumeId ? "detached (headless resume ready)" : "detached (no resume id)";
203
+ console.log(` ${s.name} [${s.runtime}] ${state}`);
204
+ }
205
+ // Pending is the point of the whole command: these are messages that reached
206
+ // this machine and that nobody answered.
207
+ if (status.pending.length > 0) {
208
+ console.log(`\nDELIVERY PENDING (${status.pending.length}) — reached this box, not answered:`);
209
+ for (const p of status.pending.slice(-10)) {
210
+ console.log(` ${p.at} ${p.session} msg ${p.messageId}`);
211
+ console.log(` ${p.reason}`);
212
+ }
213
+ return 2; // distinct exit code so a monitor can alert on it
214
+ }
215
+ return 0;
216
+ }
217
+ async function cmdRelayStop() {
218
+ if (process.platform === "linux" && fs.existsSync(path.join(os.homedir(), ".config", "systemd", "user", UNIT_NAME))) {
219
+ try {
220
+ await execFileAsync("systemctl", ["--user", "disable", "--now", UNIT_NAME]);
221
+ console.log("Relay stopped and disabled at boot.");
222
+ return 0;
223
+ }
224
+ catch {
225
+ // Fall through to the socket path — the unit may not be the thing running.
226
+ }
227
+ }
228
+ try {
229
+ await request(await connectOrFail(), { type: "stop" });
230
+ console.log("Relay stopped.");
231
+ return 0;
232
+ }
233
+ catch (err) {
234
+ console.log(err instanceof Error ? err.message : String(err));
235
+ return 1;
236
+ }
237
+ }
238
+ /**
239
+ * `baychat relay attach` — block until this session is woken.
240
+ *
241
+ * Exits 0 the moment a batch arrives, printing it. That exit is the wake: a
242
+ * harness that launched this as a background process re-invokes the session,
243
+ * which then reads the room properly through the BayChat tools. Exit 2 means
244
+ * the wait lapsed with nothing to report.
245
+ */
246
+ async function cmdRelayAttach(opts) {
247
+ if (!(0, adapters_1.isKnownRuntime)(opts.runtime)) {
248
+ console.log(`unknown runtime "${opts.runtime}" — expected claude, codex, or hermes`);
249
+ return 1;
250
+ }
251
+ const runtime = opts.runtime;
252
+ let sock;
253
+ try {
254
+ sock = await connectOrFail();
255
+ }
256
+ catch (err) {
257
+ console.log(err instanceof Error ? err.message : String(err));
258
+ return 1;
259
+ }
260
+ return new Promise((resolve) => {
261
+ const timer = opts.timeoutMs
262
+ ? setTimeout(() => {
263
+ sock.end();
264
+ console.log("No new messages before timeout.");
265
+ resolve(2);
266
+ }, opts.timeoutMs)
267
+ : undefined;
268
+ sock.on("data", (0, socket_1.createFrameReader)((frame) => {
269
+ if (frame.type === "attached") {
270
+ console.log(`Attached as "${frame.session}". Waiting for messages…`);
271
+ return;
272
+ }
273
+ if (frame.type === "wake") {
274
+ if (timer)
275
+ clearTimeout(timer);
276
+ console.log(`WAKE ${frame.messages.length} message(s) in ${frame.conversationId}:`);
277
+ for (const m of frame.messages) {
278
+ const flag = m.shouldRespond ? " [shouldRespond=true]" : "";
279
+ console.log(` (${m.id}) ${m.senderType} ${m.senderId}${flag}: ${m.content}`);
280
+ }
281
+ sock.end();
282
+ resolve(0);
283
+ return;
284
+ }
285
+ if (frame.type === "error") {
286
+ if (timer)
287
+ clearTimeout(timer);
288
+ console.log(frame.message);
289
+ sock.end();
290
+ resolve(1);
291
+ }
292
+ }));
293
+ sock.on("error", (err) => {
294
+ if (timer)
295
+ clearTimeout(timer);
296
+ console.log(err.message);
297
+ resolve(1);
298
+ });
299
+ sock.on("close", () => {
300
+ if (timer)
301
+ clearTimeout(timer);
302
+ resolve(2);
303
+ });
304
+ (0, socket_1.writeFrame)(sock, {
305
+ type: "attach",
306
+ session: opts.session,
307
+ runtime,
308
+ resumeId: opts.resumeId,
309
+ cwd: process.cwd(),
310
+ });
311
+ });
312
+ }
@@ -0,0 +1,301 @@
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.RelayDaemon = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const net = __importStar(require("net"));
39
+ const config_1 = require("../config");
40
+ const adapters_1 = require("./adapters");
41
+ const queue_1 = require("./queue");
42
+ const registry_1 = require("./registry");
43
+ const socket_1 = require("./socket");
44
+ const updates_1 = require("./updates");
45
+ class RelayDaemon {
46
+ registry = new registry_1.SessionRegistry();
47
+ queue;
48
+ abort = new AbortController();
49
+ /** Live attach sockets by session name. */
50
+ attached = new Map();
51
+ /** Per-conversation delivery watermark — the 409 catch-up baseline. */
52
+ watermarks = new Map();
53
+ pending = [];
54
+ server;
55
+ startedAt = new Date().toISOString();
56
+ cursor = null;
57
+ polls = 0;
58
+ delivered = 0;
59
+ lastError;
60
+ /** Live session names from the last poll, for pruning and for `relay status`. */
61
+ liveSessions = [];
62
+ log;
63
+ constructor(opts = {}) {
64
+ this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
65
+ this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
66
+ this.lastError = `delivery failed for ${session}: ${errText(err)}`;
67
+ this.log(this.lastError);
68
+ });
69
+ }
70
+ async start() {
71
+ // The DEVICE credential, not an agent token. A laptop has one device login
72
+ // and its sessions mint their own agents when they join, so no single agent
73
+ // token can see them all — the server fans out across this device's live
74
+ // sessions and tags every event with the session it belongs to.
75
+ const device = (0, config_1.loadDeviceCredentials)();
76
+ if (!device) {
77
+ throw new Error("this machine is not logged in to BayChat — run `npx baychat login`");
78
+ }
79
+ if (new Date(device.expiresAt).getTime() <= Date.now()) {
80
+ throw new Error(`device credential expired ${device.expiresAt} — run \`npx baychat login\``);
81
+ }
82
+ const auth = { baseUrl: device.baseUrl, token: device.token };
83
+ this.registry.load();
84
+ const sockPath = (0, socket_1.socketPath)();
85
+ if (await (0, socket_1.probeSocket)(sockPath)) {
86
+ throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
87
+ }
88
+ (0, socket_1.unlinkStaleSocket)(sockPath);
89
+ this.server = net.createServer((sock) => this.onConnection(sock));
90
+ await new Promise((resolve, reject) => {
91
+ this.server.once("error", reject);
92
+ this.server.listen(sockPath, () => {
93
+ // 0600: the socket is a wake channel into this user's sessions. Anyone
94
+ // who can write to it can make a local agent take a turn.
95
+ try {
96
+ fs.chmodSync(sockPath, 0o600);
97
+ }
98
+ catch {
99
+ /* best effort — XDG_RUNTIME_DIR is already user-private */
100
+ }
101
+ resolve();
102
+ });
103
+ });
104
+ writePidFile();
105
+ this.log(`listening on ${sockPath} (pid ${process.pid}) as device "${device.user.name}"`);
106
+ this.log(`${this.registry.all().length} known session target(s)`);
107
+ await (0, updates_1.runUpdatesLoop)({
108
+ auth,
109
+ watermarks: this.watermarks,
110
+ onEvents: (events) => {
111
+ for (const e of events)
112
+ this.onEvent(e);
113
+ },
114
+ onSessions: (names) => {
115
+ this.liveSessions = names;
116
+ const dropped = this.registry.pruneToLive(names);
117
+ for (const name of dropped)
118
+ this.log(`session ended server-side, dropped: ${name}`);
119
+ },
120
+ onPoll: (cursor) => {
121
+ this.cursor = cursor;
122
+ this.polls += 1;
123
+ },
124
+ onError: (err, willRetry) => {
125
+ this.lastError = `${errText(err)}${willRetry ? " (retrying)" : ""}`;
126
+ this.log(this.lastError);
127
+ },
128
+ signal: this.abort.signal,
129
+ });
130
+ }
131
+ /**
132
+ * Route one event and record the conversation watermark.
133
+ *
134
+ * Routing is the SERVER's answer (`sessionName`), not a local guess: the
135
+ * device poll already knows which session agent each event was queued for.
136
+ * Deriving it here from a locally cached conversation list would go stale the
137
+ * moment a session joined another room — and a session that joins a room the
138
+ * relay has never heard of still gets woken, with no local bookkeeping.
139
+ */
140
+ onEvent(event) {
141
+ const { conversationId, sessionName, message } = event;
142
+ const prior = this.watermarks.get(conversationId);
143
+ if (!prior || message.createdAt > prior)
144
+ this.watermarks.set(conversationId, message.createdAt);
145
+ if (!sessionName) {
146
+ this.log(`event ${message.id} carries no session (renamed or ended mid-poll) — ignoring`);
147
+ return;
148
+ }
149
+ // No self-echo guard is needed: the bus never queues an agent its own
150
+ // message. The previous one compared a sender id to a session name and so
151
+ // could never have matched anything.
152
+ this.queue.push(sessionName, { ...message, conversationId });
153
+ }
154
+ /**
155
+ * Deliver one coalesced batch to one session. Runs under the queue's
156
+ * per-session lock, so an attach wake and a headless resume can never be in
157
+ * flight for the same session at once.
158
+ */
159
+ async deliver(session, batch) {
160
+ const target = this.registry.get(session);
161
+ if (!target) {
162
+ this.record({ kind: "ignored", reason: `unknown session ${session}` }, session, batch);
163
+ return;
164
+ }
165
+ const sock = this.attached.get(session);
166
+ if (sock && !sock.destroyed) {
167
+ // Live session: hand it the batch and let the harness re-invoke it. The
168
+ // attach client exits after one wake, which is what makes this a wake
169
+ // rather than a stream — and why the socket is dropped here.
170
+ (0, socket_1.writeFrame)(sock, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
171
+ this.record({ kind: "woken", via: "attach", session }, session, batch);
172
+ return;
173
+ }
174
+ const adapter = (0, adapters_1.adapterFor)(target.runtime);
175
+ const check = adapter.canResume(target);
176
+ if (!check.ok) {
177
+ // The honest outcome: it reached this box, and nothing answered it.
178
+ this.record({ kind: "pending", session, reason: check.reason ?? "cannot resume" }, session, batch);
179
+ return;
180
+ }
181
+ const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch);
182
+ const { file, args } = adapter.headlessCommand(target, prompt);
183
+ const { exitCode, stderr } = await (0, adapters_1.runHeadless)(file, args, { cwd: target.cwd });
184
+ if (exitCode !== 0) {
185
+ // A non-zero headless turn did not necessarily reply. Recording it as
186
+ // delivered would claim an answer we cannot evidence.
187
+ this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
188
+ return;
189
+ }
190
+ this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
191
+ }
192
+ record(outcome, session, batch) {
193
+ if (outcome.kind === "woken") {
194
+ this.delivered += batch.length;
195
+ this.log(`woke ${session} via ${outcome.via} with ${batch.length} message(s)`);
196
+ return;
197
+ }
198
+ if (outcome.kind === "pending") {
199
+ for (const m of batch) {
200
+ this.pending.push({
201
+ session,
202
+ conversationId: m.conversationId,
203
+ messageId: m.id,
204
+ reason: outcome.reason,
205
+ at: new Date().toISOString(),
206
+ });
207
+ }
208
+ // Bounded: a session left detached for a week must not grow the heap.
209
+ while (this.pending.length > 200)
210
+ this.pending.shift();
211
+ this.log(`DELIVERY PENDING for ${session} (${batch.length} message(s)): ${outcome.reason}`);
212
+ return;
213
+ }
214
+ this.log(`ignored ${batch.length} message(s): ${outcome.reason}`);
215
+ }
216
+ onConnection(sock) {
217
+ let session;
218
+ const read = (0, socket_1.createFrameReader)((frame) => {
219
+ if (frame.type === "attach") {
220
+ session = frame.session;
221
+ this.registry.upsert({
222
+ name: frame.session,
223
+ runtime: frame.runtime,
224
+ resumeId: frame.resumeId,
225
+ cwd: frame.cwd,
226
+ });
227
+ this.attached.set(frame.session, sock);
228
+ this.registry.setAttached(frame.session, true);
229
+ (0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
230
+ this.log(`attached: ${frame.session} (${frame.runtime})`);
231
+ return;
232
+ }
233
+ if (frame.type === "status") {
234
+ (0, socket_1.writeFrame)(sock, { type: "status-result", status: this.status() });
235
+ return;
236
+ }
237
+ if (frame.type === "stop") {
238
+ this.log("stop requested over socket");
239
+ (0, socket_1.writeFrame)(sock, { type: "status-result", status: this.status() });
240
+ setTimeout(() => void this.stop(), 50);
241
+ return;
242
+ }
243
+ }, (bad) => (0, socket_1.writeFrame)(sock, { type: "error", message: `bad frame: ${bad.slice(0, 80)}` }));
244
+ sock.on("data", read);
245
+ const drop = () => {
246
+ if (session && this.attached.get(session) === sock) {
247
+ this.attached.delete(session);
248
+ this.registry.setAttached(session, false);
249
+ this.log(`detached: ${session}`);
250
+ }
251
+ };
252
+ sock.on("close", drop);
253
+ sock.on("error", drop);
254
+ }
255
+ status() {
256
+ const sessions = this.registry.all().map((t) => ({
257
+ ...t,
258
+ attached: this.attached.has(t.name),
259
+ }));
260
+ return {
261
+ running: true,
262
+ pid: process.pid,
263
+ startedAt: this.startedAt,
264
+ cursor: this.cursor,
265
+ polls: this.polls,
266
+ delivered: this.delivered,
267
+ sessions,
268
+ pending: this.pending,
269
+ lastError: this.lastError,
270
+ };
271
+ }
272
+ async stop() {
273
+ this.abort.abort();
274
+ for (const sock of this.attached.values())
275
+ sock.destroy();
276
+ this.attached.clear();
277
+ await this.queue.idle();
278
+ await new Promise((resolve) => (this.server ? this.server.close(() => resolve()) : resolve()));
279
+ (0, socket_1.unlinkStaleSocket)((0, socket_1.socketPath)());
280
+ try {
281
+ fs.unlinkSync((0, socket_1.pidFilePath)());
282
+ }
283
+ catch {
284
+ /* already gone */
285
+ }
286
+ this.log("stopped");
287
+ }
288
+ }
289
+ exports.RelayDaemon = RelayDaemon;
290
+ function writePidFile() {
291
+ try {
292
+ fs.writeFileSync((0, socket_1.pidFilePath)(), String(process.pid), { mode: 0o600 });
293
+ }
294
+ catch {
295
+ // The pid file is a convenience for `relay stop`; the socket is the real
296
+ // handle, so failing to write it is not fatal.
297
+ }
298
+ }
299
+ function errText(err) {
300
+ return err instanceof Error ? err.message : String(err);
301
+ }