baychat 0.8.2 → 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,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
+ }
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SessionQueue = void 0;
4
+ /**
5
+ * Per-session serialisation.
6
+ *
7
+ * The hard invariant: for any one session there is at most ONE delivery in
8
+ * flight at a time. Two concurrent wakes for the same session means two turns
9
+ * answering the same room from the same identity — an interactive turn and a
10
+ * headless resume racing, each unaware of the other, both replying. That is the
11
+ * failure this class exists to prevent, and it is why every wake path goes
12
+ * through `push` rather than calling the adapter directly.
13
+ *
14
+ * Messages that arrive while a turn is running are not dropped and do not
15
+ * spawn a second turn: they accumulate and are handed to the next delivery as
16
+ * one batch. Coalescing matters because a headless resume is expensive — three
17
+ * messages arriving during one run should produce one follow-up turn, not
18
+ * three.
19
+ */
20
+ class SessionQueue {
21
+ deliver;
22
+ onError;
23
+ /** Buffered messages per session, awaiting the next free slot. */
24
+ buffers = new Map();
25
+ /** Sessions with a delivery currently in flight. */
26
+ running = new Set();
27
+ /** Resolvers for `idle()`, so tests and shutdown can await quiescence. */
28
+ idleWaiters = [];
29
+ constructor(
30
+ /**
31
+ * Delivers one batch. Must not throw for ordinary failures — a failed
32
+ * delivery is reported through DeliveryOutcome, not by rejecting. A
33
+ * rejection is treated as a crashed turn: it is logged by the caller's
34
+ * handler and the slot is freed so the session isn't wedged forever.
35
+ */
36
+ deliver, onError = () => { }) {
37
+ this.deliver = deliver;
38
+ this.onError = onError;
39
+ }
40
+ /** Queue a message for a session, starting a delivery if none is running. */
41
+ push(session, message) {
42
+ const buf = this.buffers.get(session) ?? [];
43
+ // De-dupe inside the buffer too: the same event can legitimately arrive
44
+ // twice across a cursor re-baseline (409 recovery re-reads from a
45
+ // watermark), and a batch must not show the room the same line twice.
46
+ if (!buf.some((m) => m.id === message.id)) {
47
+ buf.push(message);
48
+ this.buffers.set(session, buf);
49
+ }
50
+ void this.drain(session);
51
+ }
52
+ async drain(session) {
53
+ if (this.running.has(session))
54
+ return; // a turn owns this session — it will pick the buffer up
55
+ const buf = this.buffers.get(session);
56
+ if (!buf || buf.length === 0)
57
+ return;
58
+ this.running.add(session);
59
+ try {
60
+ // Loop rather than recurse: messages that land *during* the delivery are
61
+ // picked up here without releasing the slot in between, so there is no
62
+ // window where a second drain could start a concurrent turn.
63
+ while (true) {
64
+ const batch = this.buffers.get(session) ?? [];
65
+ if (batch.length === 0)
66
+ break;
67
+ this.buffers.delete(session);
68
+ try {
69
+ await this.deliver(session, batch);
70
+ }
71
+ catch (err) {
72
+ // A thrown delivery must not strand the session. Report and move on:
73
+ // the batch is already consumed, and retrying it blindly risks
74
+ // double-answering if the turn actually ran.
75
+ this.onError(session, err);
76
+ }
77
+ }
78
+ }
79
+ finally {
80
+ this.running.delete(session);
81
+ this.buffers.delete(session);
82
+ this.notifyIfIdle();
83
+ }
84
+ }
85
+ /** True while any session has a delivery in flight. */
86
+ get busy() {
87
+ return this.running.size > 0;
88
+ }
89
+ /** Sessions with a delivery in flight — surfaced by `relay status`. */
90
+ activeSessions() {
91
+ return [...this.running];
92
+ }
93
+ /** Resolves once every in-flight delivery has finished. */
94
+ idle() {
95
+ if (!this.busy)
96
+ return Promise.resolve();
97
+ return new Promise((resolve) => this.idleWaiters.push(resolve));
98
+ }
99
+ notifyIfIdle() {
100
+ if (this.busy)
101
+ return;
102
+ const waiters = this.idleWaiters;
103
+ this.idleWaiters = [];
104
+ for (const w of waiters)
105
+ w();
106
+ }
107
+ }
108
+ exports.SessionQueue = SessionQueue;
@@ -0,0 +1,151 @@
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.SessionRegistry = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const config_1 = require("../config");
40
+ const FILE = "relay-sessions.json";
41
+ function registryPath() {
42
+ return path.join((0, config_1.configDir)(), FILE);
43
+ }
44
+ /**
45
+ * The set of local sessions the relay can wake, keyed by BayChat session name.
46
+ *
47
+ * Persisted because `resumeId` is the difference between a headless wake that
48
+ * continues the right conversation and one that is refused as `pending`. A
49
+ * daemon restart (or a reboot) must not silently downgrade every session to
50
+ * "cannot resume safely" — so what `attach` learned is written to disk.
51
+ *
52
+ * `attached` is deliberately NOT persisted: it describes a live socket, and a
53
+ * process that just started holds none. Loading it as `true` would make the
54
+ * daemon believe someone is listening and route wakes into nothing.
55
+ */
56
+ class SessionRegistry {
57
+ filePath;
58
+ sessions = new Map();
59
+ constructor(filePath = registryPath()) {
60
+ this.filePath = filePath;
61
+ }
62
+ load() {
63
+ try {
64
+ const raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
65
+ if (!raw || typeof raw !== "object")
66
+ return;
67
+ for (const [name, value] of Object.entries(raw)) {
68
+ if (!value || typeof value !== "object")
69
+ continue;
70
+ this.sessions.set(name, { ...value, name, attached: false });
71
+ }
72
+ }
73
+ catch {
74
+ // Missing or corrupt — an empty registry is the correct reading. Every
75
+ // wake then reports `pending` with a reason, which is visible in
76
+ // `relay status`, rather than failing silently.
77
+ }
78
+ }
79
+ save() {
80
+ const out = {};
81
+ for (const [name, target] of this.sessions) {
82
+ const { attached: _attached, ...persisted } = target;
83
+ out[name] = persisted;
84
+ }
85
+ try {
86
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
87
+ fs.writeFileSync(this.filePath, JSON.stringify(out, null, 2), { mode: 0o600 });
88
+ }
89
+ catch {
90
+ // A registry we cannot persist still works for this process's lifetime;
91
+ // losing it costs resume ids after a restart, not correctness now.
92
+ }
93
+ }
94
+ /**
95
+ * Record (or update) a target. Merges rather than replaces: an `attach` that
96
+ * omits `resumeId` must not erase one an earlier attach established, or the
97
+ * next detached wake would report `pending` for a session we can in fact
98
+ * resume.
99
+ */
100
+ upsert(target) {
101
+ const existing = this.sessions.get(target.name);
102
+ const merged = {
103
+ registeredAt: existing?.registeredAt ?? new Date().toISOString(),
104
+ ...existing,
105
+ ...stripUndefined(target),
106
+ name: target.name,
107
+ runtime: target.runtime,
108
+ };
109
+ this.sessions.set(target.name, merged);
110
+ this.save();
111
+ return merged;
112
+ }
113
+ get(name) {
114
+ return this.sessions.get(name);
115
+ }
116
+ all() {
117
+ return [...this.sessions.values()];
118
+ }
119
+ setAttached(name, attached) {
120
+ const t = this.sessions.get(name);
121
+ if (t)
122
+ t.attached = attached;
123
+ }
124
+ /**
125
+ * Drop targets whose session is no longer live server-side.
126
+ *
127
+ * A session ended from the app (Settings → Devices → Sessions → End) stops
128
+ * appearing in the device poll. Keeping a local target for it would leave a
129
+ * headless resume firing into a terminal the owner deliberately parked.
130
+ *
131
+ * Attached sessions are never pruned: a live socket outranks a poll, which
132
+ * may simply have raced a join that hasn't been stamped live yet.
133
+ */
134
+ pruneToLive(liveNames) {
135
+ const live = new Set(liveNames);
136
+ const dropped = [];
137
+ for (const [name, target] of this.sessions) {
138
+ if (!live.has(name) && !target.attached) {
139
+ this.sessions.delete(name);
140
+ dropped.push(name);
141
+ }
142
+ }
143
+ if (dropped.length > 0)
144
+ this.save();
145
+ return dropped;
146
+ }
147
+ }
148
+ exports.SessionRegistry = SessionRegistry;
149
+ function stripUndefined(obj) {
150
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
151
+ }
@@ -0,0 +1,125 @@
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.socketPath = socketPath;
37
+ exports.pidFilePath = pidFilePath;
38
+ exports.createFrameReader = createFrameReader;
39
+ exports.writeFrame = writeFrame;
40
+ exports.probeSocket = probeSocket;
41
+ exports.unlinkStaleSocket = unlinkStaleSocket;
42
+ const fs = __importStar(require("fs"));
43
+ const net = __importStar(require("net"));
44
+ const path = __importStar(require("path"));
45
+ const config_1 = require("../config");
46
+ /**
47
+ * Where the attach socket lives.
48
+ *
49
+ * `XDG_RUNTIME_DIR` is the right home: it is user-private (0700), on tmpfs, and
50
+ * cleared on logout, so a stale socket never outlives the session that made it.
51
+ * The config-dir fallback covers boxes without it (some containers, macOS).
52
+ */
53
+ function socketPath() {
54
+ const override = process.env.BAYCHAT_RELAY_SOCKET;
55
+ if (override)
56
+ return override;
57
+ const runtimeDir = process.env.XDG_RUNTIME_DIR;
58
+ if (runtimeDir)
59
+ return path.join(runtimeDir, "baychat-relay.sock");
60
+ return path.join((0, config_1.configDir)(), "relay.sock");
61
+ }
62
+ function pidFilePath() {
63
+ return path.join((0, config_1.configDir)(), "relay.pid");
64
+ }
65
+ /** Frames are newline-delimited JSON; this splits a stream into whole ones. */
66
+ function createFrameReader(onFrame, onBad) {
67
+ let buffer = "";
68
+ return (chunk) => {
69
+ buffer += chunk.toString();
70
+ // A peer that never sends a newline must not grow this unboundedly.
71
+ if (buffer.length > 1_000_000) {
72
+ buffer = "";
73
+ onBad?.("frame too large");
74
+ return;
75
+ }
76
+ let idx;
77
+ while ((idx = buffer.indexOf("\n")) >= 0) {
78
+ const line = buffer.slice(0, idx).trim();
79
+ buffer = buffer.slice(idx + 1);
80
+ if (!line)
81
+ continue;
82
+ try {
83
+ onFrame(JSON.parse(line));
84
+ }
85
+ catch {
86
+ onBad?.(line);
87
+ }
88
+ }
89
+ };
90
+ }
91
+ function writeFrame(sock, frame) {
92
+ sock.write(JSON.stringify(frame) + "\n");
93
+ }
94
+ /**
95
+ * Is a daemon already listening?
96
+ *
97
+ * A socket file on disk proves nothing — a killed daemon leaves one behind. The
98
+ * only honest test is to connect: ECONNREFUSED means the file is stale and safe
99
+ * to unlink, which is what lets `relay start` recover from a hard kill without
100
+ * a human deleting files.
101
+ */
102
+ function probeSocket(sockPath, timeoutMs = 1_000) {
103
+ return new Promise((resolve) => {
104
+ if (!fs.existsSync(sockPath))
105
+ return resolve(false);
106
+ const sock = net.createConnection(sockPath);
107
+ const done = (alive) => {
108
+ sock.destroy();
109
+ resolve(alive);
110
+ };
111
+ sock.setTimeout(timeoutMs, () => done(false));
112
+ sock.on("connect", () => done(true));
113
+ sock.on("error", () => done(false));
114
+ });
115
+ }
116
+ /** Remove a socket file we have already proven dead. */
117
+ function unlinkStaleSocket(sockPath) {
118
+ try {
119
+ fs.unlinkSync(sockPath);
120
+ }
121
+ catch {
122
+ // Already gone, or not ours to remove — the listen() that follows will
123
+ // surface anything that actually matters.
124
+ }
125
+ }