baychat 0.13.1 → 0.15.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,184 @@
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.HeldStore = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const config_1 = require("../config");
40
+ const FILE = "relay-held.json";
41
+ function heldPath() {
42
+ return path.join((0, config_1.configDir)(), FILE);
43
+ }
44
+ /**
45
+ * Messages held for a session that is alive but mid-turn, PERSISTED.
46
+ *
47
+ * A socket attach exits on every wake and the turn it triggers runs for minutes
48
+ * before the session re-arms. A message arriving in that window is early, not
49
+ * undeliverable, so the daemon holds it. That much already worked — in memory.
50
+ *
51
+ * In memory was not enough, and the gap was specific: a daemon restart lost
52
+ * every held message, and because the poll cursor had already advanced past
53
+ * them the server never sent them again. They were not pending, not delivered,
54
+ * and not anywhere. `relay status` showed nothing at all, so the only symptom
55
+ * was a person on a phone whose message was never answered — the exact reading
56
+ * of "the relay stopped" that holding was introduced to prevent.
57
+ *
58
+ * So the store is a file, and it is in `relay status`. Held is a THIRD state,
59
+ * neither delivered nor failed, and it has to be visible as itself: printing it
60
+ * as pending would make a working agent look broken, and printing nothing is
61
+ * what we just came from.
62
+ *
63
+ * ⚠️ THIS FILE CONTAINS MESSAGE TEXT IN PLAINTEXT. Everything else the relay
64
+ * persists is metadata — session names, resume ids, positions. This is chat
65
+ * content, sitting on disk on the user's own machine for as long as their agent
66
+ * takes to finish a turn. Mode 0600, in the same private config dir as the
67
+ * device credential, and deliberately emptied the moment a batch is handed
68
+ * over. That is the price of not losing messages, and it is worth naming rather
69
+ * than discovering.
70
+ */
71
+ class HeldStore {
72
+ filePath;
73
+ /** session → conversationId → room. */
74
+ rooms = new Map();
75
+ constructor(filePath = heldPath()) {
76
+ this.filePath = filePath;
77
+ }
78
+ load() {
79
+ let raw;
80
+ try {
81
+ raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
82
+ }
83
+ catch {
84
+ // Missing or corrupt. An empty store is the correct reading: it means the
85
+ // daemon holds nothing, which is true, rather than crashing a relay on
86
+ // startup over messages it cannot recover anyway.
87
+ return;
88
+ }
89
+ if (!raw || typeof raw !== "object")
90
+ return;
91
+ for (const [session, bySession] of Object.entries(raw)) {
92
+ if (!bySession || typeof bySession !== "object")
93
+ continue;
94
+ const rooms = new Map();
95
+ for (const [conversationId, value] of Object.entries(bySession)) {
96
+ const room = value;
97
+ if (!room || !Array.isArray(room.messages) || room.messages.length === 0)
98
+ continue;
99
+ rooms.set(conversationId, {
100
+ conversationId,
101
+ heldAt: typeof room.heldAt === "string" ? room.heldAt : new Date().toISOString(),
102
+ messages: room.messages,
103
+ });
104
+ }
105
+ if (rooms.size > 0)
106
+ this.rooms.set(session, rooms);
107
+ }
108
+ }
109
+ save() {
110
+ const out = {};
111
+ for (const [session, rooms] of this.rooms) {
112
+ if (rooms.size === 0)
113
+ continue;
114
+ out[session] = Object.fromEntries(rooms);
115
+ }
116
+ try {
117
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
118
+ if (Object.keys(out).length === 0) {
119
+ // Nothing held: remove the file rather than leaving `{}` behind, so the
120
+ // plaintext does not outlive the hold by a single byte more than it
121
+ // must.
122
+ fs.rmSync(this.filePath, { force: true });
123
+ return;
124
+ }
125
+ fs.writeFileSync(this.filePath, JSON.stringify(out, null, 2), { mode: 0o600 });
126
+ }
127
+ catch {
128
+ // Losing durability is not losing the hold: the in-memory copy still
129
+ // serves this process, which is exactly the behaviour we had before.
130
+ }
131
+ }
132
+ /**
133
+ * Hold a batch for one room.
134
+ *
135
+ * De-duplicated by message id: a cursor re-baseline (409 recovery re-reads
136
+ * from a watermark) can legitimately replay an event, and a room must not
137
+ * show the agent the same line twice when it finally re-arms.
138
+ */
139
+ add(session, conversationId, messages) {
140
+ const rooms = this.rooms.get(session) ?? new Map();
141
+ const room = rooms.get(conversationId) ?? { conversationId, heldAt: new Date().toISOString(), messages: [] };
142
+ for (const m of messages) {
143
+ if (!room.messages.some((held) => held.id === m.id))
144
+ room.messages.push(m);
145
+ }
146
+ rooms.set(conversationId, room);
147
+ this.rooms.set(session, rooms);
148
+ this.save();
149
+ }
150
+ /** Every room held for a session, oldest hold first. */
151
+ roomsFor(session) {
152
+ const rooms = this.rooms.get(session);
153
+ if (!rooms)
154
+ return [];
155
+ return [...rooms.values()].sort((a, b) => Date.parse(a.heldAt) - Date.parse(b.heldAt));
156
+ }
157
+ /** Drop one room's hold — called only once its messages are on the wire. */
158
+ clearRoom(session, conversationId) {
159
+ const rooms = this.rooms.get(session);
160
+ if (!rooms || !rooms.delete(conversationId))
161
+ return;
162
+ if (rooms.size === 0)
163
+ this.rooms.delete(session);
164
+ this.save();
165
+ }
166
+ /** How many messages are held for one session, across all its rooms. */
167
+ countFor(session) {
168
+ let n = 0;
169
+ for (const room of this.rooms.get(session)?.values() ?? [])
170
+ n += room.messages.length;
171
+ return n;
172
+ }
173
+ /** What `relay status` prints. Content is deliberately NOT included. */
174
+ summary() {
175
+ const out = [];
176
+ for (const [session, rooms] of this.rooms) {
177
+ for (const room of rooms.values()) {
178
+ out.push({ session, conversationId: room.conversationId, count: room.messages.length, heldAt: room.heldAt });
179
+ }
180
+ }
181
+ return out.sort((a, b) => Date.parse(a.heldAt) - Date.parse(b.heldAt));
182
+ }
183
+ }
184
+ exports.HeldStore = HeldStore;
@@ -0,0 +1,118 @@
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.MailboxWatcher = void 0;
37
+ const crypto = __importStar(require("crypto"));
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const mailbox_1 = require("./mailbox");
41
+ const parent_watch_1 = require("./parent-watch");
42
+ /**
43
+ * Watch the mailbox root for sessions arming and dying.
44
+ *
45
+ * `fs.watch` AND a periodic sweep, not one or the other. `fs.watch` gives
46
+ * instant pickup but is unreliable across filesystems — notably a tmpfs
47
+ * directory the agent created moments earlier — and a sweep alone would add
48
+ * seconds to every arming. The sweep is also the only thing that can notice a
49
+ * registration whose process has since died, because nothing on disk changes
50
+ * when a process exits and there is no event to watch for.
51
+ */
52
+ class MailboxWatcher {
53
+ opts;
54
+ seen = new Map();
55
+ isAlive;
56
+ sweepMs;
57
+ watcher;
58
+ timer;
59
+ constructor(opts) {
60
+ this.opts = opts;
61
+ this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
62
+ this.sweepMs = opts.sweepMs ?? 2_000;
63
+ }
64
+ start() {
65
+ fs.mkdirSync(this.opts.root, { recursive: true, mode: 0o700 });
66
+ try {
67
+ this.watcher = fs.watch(this.opts.root, { recursive: true }, () => this.sweep());
68
+ }
69
+ catch {
70
+ // Not every platform or filesystem supports recursive watching. The sweep
71
+ // below is the floor, so this degrades to slower pickup, never to none.
72
+ }
73
+ this.timer = setInterval(() => this.sweep(), this.sweepMs);
74
+ this.timer.unref();
75
+ this.sweep();
76
+ }
77
+ stop() {
78
+ this.watcher?.close();
79
+ if (this.timer)
80
+ clearInterval(this.timer);
81
+ }
82
+ sweep() {
83
+ let entries;
84
+ try {
85
+ entries = fs.readdirSync(this.opts.root, { withFileTypes: true });
86
+ }
87
+ catch {
88
+ return; // No root yet: nothing has armed. Not a fault.
89
+ }
90
+ for (const entry of entries) {
91
+ if (!entry.isDirectory())
92
+ continue;
93
+ const dir = path.join(this.opts.root, entry.name);
94
+ const reg = (0, mailbox_1.readRegistration)(dir);
95
+ if (!reg)
96
+ continue;
97
+ if (!this.isAlive(reg.pid)) {
98
+ this.seen.delete(reg.session);
99
+ fs.rmSync(dir, { recursive: true, force: true });
100
+ this.opts.onDrop(reg.session, `mailbox process ${reg.pid} is no longer running`);
101
+ continue;
102
+ }
103
+ // Identity is the registration's CONTENT, not its timestamp.
104
+ //
105
+ // `registeredAt` has millisecond resolution, so two armings inside one
106
+ // millisecond share a timestamp — which in a test is a flake and in
107
+ // production is a re-arm that goes unannounced. Hashing the content is
108
+ // exact: anything the daemon would act on differently is a different hash,
109
+ // and a byte-identical rewrite is one the daemon already has right.
110
+ const stamp = crypto.createHash("sha256").update(JSON.stringify(reg)).digest("hex");
111
+ if (this.seen.get(reg.session) === stamp)
112
+ continue;
113
+ this.seen.set(reg.session, stamp);
114
+ this.opts.onRegister(reg);
115
+ }
116
+ }
117
+ }
118
+ exports.MailboxWatcher = MailboxWatcher;
@@ -0,0 +1,320 @@
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.WAKE_FIFO = exports.REGISTER_FILE = void 0;
37
+ exports.resolveMailboxRoot = resolveMailboxRoot;
38
+ exports.mailboxRootCandidates = mailboxRootCandidates;
39
+ exports.pickUsableMailboxDir = pickUsableMailboxDir;
40
+ exports.sanitizeSessionDir = sanitizeSessionDir;
41
+ exports.sessionMailboxDir = sessionMailboxDir;
42
+ exports.writeRegistration = writeRegistration;
43
+ exports.readRegistration = readRegistration;
44
+ exports.registrationToTarget = registrationToTarget;
45
+ exports.createWakeFifo = createWakeFifo;
46
+ exports.ensureWakeFifo = ensureWakeFifo;
47
+ exports.wakeFifoPath = wakeFifoPath;
48
+ exports.awaitWakeFifo = awaitWakeFifo;
49
+ exports.writeWake = writeWake;
50
+ const child_process_1 = require("child_process");
51
+ const fs = __importStar(require("fs"));
52
+ const os = __importStar(require("os"));
53
+ const path = __importStar(require("path"));
54
+ /**
55
+ * Where a sandboxed agent and the relay meet on disk.
56
+ *
57
+ * THE AGENT CHOOSES, NOT THE DAEMON. Only the agent's own process knows what its
58
+ * sandbox permits: on 2026-08-30 a Codex shell could create files under /tmp but
59
+ * was refused `connect()` on a socket beside them, and `/run/user/1000` was
60
+ * granted as a writable root while the socket in it stayed unreachable. This is
61
+ * the same rule `SessionTarget.runtimeBin` already follows — the session
62
+ * identifies its own environment and the daemon does not guess for it.
63
+ *
64
+ * Order: an explicit override, then XDG_RUNTIME_DIR (user-private 0700 tmpfs,
65
+ * cleared on logout, so nothing outlives the session), then a uid-scoped
66
+ * directory under TMPDIR. The uid is in the tmp name because /tmp is shared: two
67
+ * users must never resolve to one root, and the 0700 mode the caller creates it
68
+ * with is what keeps either out of the other's.
69
+ */
70
+ function resolveMailboxRoot(env = process.env, uid = process.getuid?.() ?? 0) {
71
+ return mailboxRootCandidates(env, uid)[0];
72
+ }
73
+ /**
74
+ * Every root worth trying, best first.
75
+ *
76
+ * There is more than one because PREFERRING a root is not the same as being
77
+ * able to USE it. Measured 2026-08-30: inside bubblewrap, `/run/user/1000` is
78
+ * statable — the relay socket in it can be stat'ed and refused at `connect()` —
79
+ * but `mkdir` under it fails ENOENT. An agent that resolved one root and
80
+ * assumed it would work gave up there instead of trying `/tmp`, which the same
81
+ * sandbox creates files in happily.
82
+ *
83
+ * The daemon watches all of these, because it cannot know which one the agent
84
+ * turned out to be able to use.
85
+ */
86
+ function mailboxRootCandidates(env = process.env, uid = process.getuid?.() ?? 0) {
87
+ // An explicit override is EXCLUSIVE, not merely first. "Use this directory"
88
+ // is what someone setting it means, and treating it as a preference left both
89
+ // the daemon and `doctor` still reading the machine's real mailbox roots —
90
+ // which made the tests non-hermetic and, worse, let a leftover mailbox for a
91
+ // session of the same name hijack a delivery meant for another root.
92
+ if (env.BAYCHAT_MAILBOX_DIR)
93
+ return [env.BAYCHAT_MAILBOX_DIR];
94
+ const roots = [];
95
+ if (env.XDG_RUNTIME_DIR)
96
+ roots.push(path.join(env.XDG_RUNTIME_DIR, "baychat-mailbox"));
97
+ roots.push(path.join(env.TMPDIR ?? os.tmpdir(), `baychat-mailbox-${uid}`));
98
+ return [...new Set(roots)];
99
+ }
100
+ /**
101
+ * The first root this process can actually create — PROVED, not assumed.
102
+ *
103
+ * This is the agent's half of "the session identifies its own environment": it
104
+ * finds out by doing, because nothing else on the machine can tell it what its
105
+ * sandbox permits.
106
+ */
107
+ function pickUsableMailboxDir(session, env = process.env, uid = process.getuid?.() ?? 0, mkdir = (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 })) {
108
+ const failures = [];
109
+ for (const root of mailboxRootCandidates(env, uid)) {
110
+ // The SESSION directory, not the root. Measured 2026-08-30: the root often
111
+ // already exists because the unsandboxed daemon created it, so probing the
112
+ // root succeeds as a no-op and the real refusal only surfaces one level
113
+ // down — where the agent had already committed to a root it cannot use.
114
+ // Prove the exact directory you are about to write into.
115
+ const dir = sessionMailboxDir(root, session);
116
+ try {
117
+ mkdir(dir);
118
+ return dir;
119
+ }
120
+ catch (err) {
121
+ failures.push(`${dir} (${err.message})`);
122
+ }
123
+ }
124
+ throw new Error(`no usable mailbox directory — tried ${failures.join(", ")}`);
125
+ }
126
+ /**
127
+ * A session name is user input; it must never become a path traversal.
128
+ *
129
+ * Characters outside the safe set are COLLAPSED rather than dropped: dropping
130
+ * would let "a/b" and "ab" resolve to one directory, so two sessions would share
131
+ * a mailbox and each would take the other's wakes.
132
+ */
133
+ function sanitizeSessionDir(name) {
134
+ return name.replace(/[^A-Za-z0-9_-]/g, "_");
135
+ }
136
+ function sessionMailboxDir(root, session) {
137
+ return path.join(root, sanitizeSessionDir(session));
138
+ }
139
+ exports.REGISTER_FILE = "register.json";
140
+ exports.WAKE_FIFO = "wake.fifo";
141
+ function writeRegistration(dir, reg) {
142
+ // 0600: this file names the session's resume id, which is the handle for
143
+ // "continue that conversation". It is not public inside a shared /tmp.
144
+ fs.writeFileSync(path.join(dir, exports.REGISTER_FILE), JSON.stringify(reg, null, 2), { mode: 0o600 });
145
+ }
146
+ /**
147
+ * Read a registration, or `undefined` for anything we cannot fully trust.
148
+ *
149
+ * Never throws. The daemon watches this directory while the agent writes into
150
+ * it, so a truncated or half-flushed file is an ordinary event, not a fault —
151
+ * and the sweep behind the watcher picks it up a moment later anyway.
152
+ */
153
+ function readRegistration(dir) {
154
+ let raw;
155
+ try {
156
+ raw = fs.readFileSync(path.join(dir, exports.REGISTER_FILE), "utf8");
157
+ }
158
+ catch {
159
+ return undefined;
160
+ }
161
+ let parsed;
162
+ try {
163
+ parsed = JSON.parse(raw);
164
+ }
165
+ catch {
166
+ return undefined;
167
+ }
168
+ const reg = parsed;
169
+ if (typeof reg.session !== "string" || typeof reg.runtime !== "string")
170
+ return undefined;
171
+ if (typeof reg.fifo !== "string" || typeof reg.pid !== "number")
172
+ return undefined;
173
+ if (typeof reg.registeredAt !== "string")
174
+ return undefined;
175
+ return reg;
176
+ }
177
+ /** The registry cares about a SessionTarget, whichever channel described it. */
178
+ function registrationToTarget(reg) {
179
+ return {
180
+ name: reg.session,
181
+ runtime: reg.runtime,
182
+ resumeId: reg.resumeId,
183
+ resumeSource: reg.resumeSource,
184
+ resumeEvidence: reg.resumeEvidence,
185
+ resumeCwd: reg.resumeCwd,
186
+ cwd: reg.cwd,
187
+ runtimeBin: reg.runtimeBin,
188
+ ownerPid: reg.ownerPid,
189
+ // Persisted, so a LATER headless resume still knows this session is
190
+ // sandboxed and must be told to re-arm in the foreground. `status()`
191
+ // recomputes the live rung for display; this is the remembered one.
192
+ transport: "fifo",
193
+ };
194
+ }
195
+ /**
196
+ * Create this session's wake FIFO, replacing any previous one.
197
+ *
198
+ * Replacing rather than reusing is deliberate: a FIFO left behind by a crashed
199
+ * attach has no reader, and a stale reader from a previous arming could
200
+ * otherwise steal this session's next wake. A fresh inode makes both impossible.
201
+ *
202
+ * `mkfifo(1)` rather than a binding: Node has no `mkfifo`, and a sandboxed agent
203
+ * has been shown to run it (2026-08-30, `mkfifo -m 600` under bubblewrap).
204
+ */
205
+ function createWakeFifo(dir) {
206
+ const fifo = path.join(dir, exports.WAKE_FIFO);
207
+ fs.rmSync(fifo, { force: true });
208
+ (0, child_process_1.execFileSync)("mkfifo", ["-m", "600", fifo]);
209
+ return fifo;
210
+ }
211
+ /**
212
+ * Make sure a wake FIFO exists at this path — WITHOUT disturbing one that does.
213
+ *
214
+ * This is the daemon's entry point, and it is deliberately not `createWakeFifo`.
215
+ * That function unlinks and recreates, which was right when the AGENT owned the
216
+ * FIFO (a stale one from a crashed attach has no reader, and a fresh inode stops
217
+ * an old reader stealing the next wake). Once the daemon took ownership the risk
218
+ * inverted: an agent may be blocked on this FIFO right now, and unlinking it
219
+ * leaves that reader waiting forever on an inode nothing can reach while the
220
+ * daemon writes to a new one where nobody is.
221
+ *
222
+ * Measured 2026-08-30 23:54: Codex registered twice nine seconds apart, the
223
+ * second registration recreated the FIFO, and the next message was recorded
224
+ * `pending` with a live reader sitting on the orphaned inode. Same shape as the
225
+ * orphaned attach sockets earlier that evening — a wake sent somewhere nobody
226
+ * is listening.
227
+ *
228
+ * A path that exists but is NOT a FIFO is replaced: nothing can be blocked on a
229
+ * regular file, so there is no reader to orphan, and a wake written into one
230
+ * would be silently lost.
231
+ */
232
+ function ensureWakeFifo(dir) {
233
+ const fifo = path.join(dir, exports.WAKE_FIFO);
234
+ try {
235
+ if (fs.statSync(fifo).isFIFO())
236
+ return fifo;
237
+ }
238
+ catch {
239
+ /* not there — create it below */
240
+ }
241
+ return createWakeFifo(dir);
242
+ }
243
+ /** Where this session's FIFO will live, without creating it. */
244
+ function wakeFifoPath(dir) {
245
+ return path.join(dir, exports.WAKE_FIFO);
246
+ }
247
+ /**
248
+ * Wait for the daemon to create this session's FIFO.
249
+ *
250
+ * THE DAEMON CREATES IT, NOT THE AGENT. Node has no `mkfifo` binding, so making
251
+ * one means spawning `mkfifo(1)` — and spawning a helper binary is precisely
252
+ * what a sandbox restricts. Measured 2026-08-30: inside bubblewrap the agent
253
+ * could create its directory and write its registration, then died with
254
+ * `spawnSync mkfifo EPERM`.
255
+ *
256
+ * So the privileged step moved to the side that has the privilege. The agent
257
+ * declares the path it wants in `register.json`; the daemon, which is not
258
+ * sandboxed, creates the FIFO there; the agent waits for it to appear. Nothing
259
+ * the agent must do is now anything but opening and reading a file.
260
+ */
261
+ async function awaitWakeFifo(fifo, timeoutMs = 30_000, pollMs = 100) {
262
+ const deadline = Date.now() + timeoutMs;
263
+ for (;;) {
264
+ try {
265
+ if (fs.statSync(fifo).isFIFO())
266
+ return;
267
+ }
268
+ catch {
269
+ /* not there yet */
270
+ }
271
+ if (Date.now() >= deadline) {
272
+ throw new Error(`the relay did not create ${fifo} within ${Math.round(timeoutMs / 1000)}s — it may not be watching this mailbox root. Run \`baychat doctor\`.`);
273
+ }
274
+ await new Promise((r) => setTimeout(r, pollMs));
275
+ }
276
+ }
277
+ /**
278
+ * Hand one wake to a blocked reader, or say why we could not — NEVER block.
279
+ *
280
+ * `O_NONBLOCK` on a write-only FIFO open fails with ENXIO when no reader has it
281
+ * open. That is not an error condition: it is the honest answer "this session
282
+ * registered, but it is not waiting right now", and it must become `pending`
283
+ * rather than `delivered`.
284
+ *
285
+ * A completed write is the one piece of real evidence this transport has that a
286
+ * message was TAKEN rather than merely sent — which is more than either of the
287
+ * other two rungs can offer.
288
+ */
289
+ function writeWake(fifoPath, payload) {
290
+ let stat;
291
+ try {
292
+ stat = fs.statSync(fifoPath);
293
+ }
294
+ catch {
295
+ return { ok: false, reason: `no mailbox FIFO at ${fifoPath}` };
296
+ }
297
+ if (!stat.isFIFO())
298
+ return { ok: false, reason: `${fifoPath} is not a FIFO — refusing to write a wake into it` };
299
+ let fd;
300
+ try {
301
+ fd = fs.openSync(fifoPath, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK);
302
+ }
303
+ catch (err) {
304
+ const code = err.code;
305
+ if (code === "ENXIO")
306
+ return { ok: false, reason: "session registered a mailbox but is not waiting on it right now" };
307
+ return { ok: false, reason: `could not open ${fifoPath}: ${code ?? "unknown error"}` };
308
+ }
309
+ try {
310
+ fs.writeSync(fd, payload);
311
+ return { ok: true };
312
+ }
313
+ catch (err) {
314
+ // The reader went away mid-write. Nothing was taken; say so.
315
+ return { ok: false, reason: `reader left mid-wake: ${err.code ?? "write failed"}` };
316
+ }
317
+ finally {
318
+ fs.closeSync(fd);
319
+ }
320
+ }