baychat 0.13.1 → 0.14.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,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,319 @@
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
+ // Persisted, so a LATER headless resume still knows this session is
189
+ // sandboxed and must be told to re-arm in the foreground. `status()`
190
+ // recomputes the live rung for display; this is the remembered one.
191
+ transport: "fifo",
192
+ };
193
+ }
194
+ /**
195
+ * Create this session's wake FIFO, replacing any previous one.
196
+ *
197
+ * Replacing rather than reusing is deliberate: a FIFO left behind by a crashed
198
+ * attach has no reader, and a stale reader from a previous arming could
199
+ * otherwise steal this session's next wake. A fresh inode makes both impossible.
200
+ *
201
+ * `mkfifo(1)` rather than a binding: Node has no `mkfifo`, and a sandboxed agent
202
+ * has been shown to run it (2026-08-30, `mkfifo -m 600` under bubblewrap).
203
+ */
204
+ function createWakeFifo(dir) {
205
+ const fifo = path.join(dir, exports.WAKE_FIFO);
206
+ fs.rmSync(fifo, { force: true });
207
+ (0, child_process_1.execFileSync)("mkfifo", ["-m", "600", fifo]);
208
+ return fifo;
209
+ }
210
+ /**
211
+ * Make sure a wake FIFO exists at this path — WITHOUT disturbing one that does.
212
+ *
213
+ * This is the daemon's entry point, and it is deliberately not `createWakeFifo`.
214
+ * That function unlinks and recreates, which was right when the AGENT owned the
215
+ * FIFO (a stale one from a crashed attach has no reader, and a fresh inode stops
216
+ * an old reader stealing the next wake). Once the daemon took ownership the risk
217
+ * inverted: an agent may be blocked on this FIFO right now, and unlinking it
218
+ * leaves that reader waiting forever on an inode nothing can reach while the
219
+ * daemon writes to a new one where nobody is.
220
+ *
221
+ * Measured 2026-08-30 23:54: Codex registered twice nine seconds apart, the
222
+ * second registration recreated the FIFO, and the next message was recorded
223
+ * `pending` with a live reader sitting on the orphaned inode. Same shape as the
224
+ * orphaned attach sockets earlier that evening — a wake sent somewhere nobody
225
+ * is listening.
226
+ *
227
+ * A path that exists but is NOT a FIFO is replaced: nothing can be blocked on a
228
+ * regular file, so there is no reader to orphan, and a wake written into one
229
+ * would be silently lost.
230
+ */
231
+ function ensureWakeFifo(dir) {
232
+ const fifo = path.join(dir, exports.WAKE_FIFO);
233
+ try {
234
+ if (fs.statSync(fifo).isFIFO())
235
+ return fifo;
236
+ }
237
+ catch {
238
+ /* not there — create it below */
239
+ }
240
+ return createWakeFifo(dir);
241
+ }
242
+ /** Where this session's FIFO will live, without creating it. */
243
+ function wakeFifoPath(dir) {
244
+ return path.join(dir, exports.WAKE_FIFO);
245
+ }
246
+ /**
247
+ * Wait for the daemon to create this session's FIFO.
248
+ *
249
+ * THE DAEMON CREATES IT, NOT THE AGENT. Node has no `mkfifo` binding, so making
250
+ * one means spawning `mkfifo(1)` — and spawning a helper binary is precisely
251
+ * what a sandbox restricts. Measured 2026-08-30: inside bubblewrap the agent
252
+ * could create its directory and write its registration, then died with
253
+ * `spawnSync mkfifo EPERM`.
254
+ *
255
+ * So the privileged step moved to the side that has the privilege. The agent
256
+ * declares the path it wants in `register.json`; the daemon, which is not
257
+ * sandboxed, creates the FIFO there; the agent waits for it to appear. Nothing
258
+ * the agent must do is now anything but opening and reading a file.
259
+ */
260
+ async function awaitWakeFifo(fifo, timeoutMs = 30_000, pollMs = 100) {
261
+ const deadline = Date.now() + timeoutMs;
262
+ for (;;) {
263
+ try {
264
+ if (fs.statSync(fifo).isFIFO())
265
+ return;
266
+ }
267
+ catch {
268
+ /* not there yet */
269
+ }
270
+ if (Date.now() >= deadline) {
271
+ 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\`.`);
272
+ }
273
+ await new Promise((r) => setTimeout(r, pollMs));
274
+ }
275
+ }
276
+ /**
277
+ * Hand one wake to a blocked reader, or say why we could not — NEVER block.
278
+ *
279
+ * `O_NONBLOCK` on a write-only FIFO open fails with ENXIO when no reader has it
280
+ * open. That is not an error condition: it is the honest answer "this session
281
+ * registered, but it is not waiting right now", and it must become `pending`
282
+ * rather than `delivered`.
283
+ *
284
+ * A completed write is the one piece of real evidence this transport has that a
285
+ * message was TAKEN rather than merely sent — which is more than either of the
286
+ * other two rungs can offer.
287
+ */
288
+ function writeWake(fifoPath, payload) {
289
+ let stat;
290
+ try {
291
+ stat = fs.statSync(fifoPath);
292
+ }
293
+ catch {
294
+ return { ok: false, reason: `no mailbox FIFO at ${fifoPath}` };
295
+ }
296
+ if (!stat.isFIFO())
297
+ return { ok: false, reason: `${fifoPath} is not a FIFO — refusing to write a wake into it` };
298
+ let fd;
299
+ try {
300
+ fd = fs.openSync(fifoPath, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK);
301
+ }
302
+ catch (err) {
303
+ const code = err.code;
304
+ if (code === "ENXIO")
305
+ return { ok: false, reason: "session registered a mailbox but is not waiting on it right now" };
306
+ return { ok: false, reason: `could not open ${fifoPath}: ${code ?? "unknown error"}` };
307
+ }
308
+ try {
309
+ fs.writeSync(fd, payload);
310
+ return { ok: true };
311
+ }
312
+ catch (err) {
313
+ // The reader went away mid-write. Nothing was taken; say so.
314
+ return { ok: false, reason: `reader left mid-wake: ${err.code ?? "write failed"}` };
315
+ }
316
+ finally {
317
+ fs.closeSync(fd);
318
+ }
319
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ /**
3
+ * Tie an attach process's life to the session that started it.
4
+ *
5
+ * WHY THIS EXISTS. `baychat relay attach` is launched in the background by a
6
+ * session and then blocks. Nothing connected the two: when the session ended —
7
+ * a `/clear`, an exit, a crash — the attach kept running, was reparented to
8
+ * init/systemd, and kept its socket open. The daemon reads "socket open" as
9
+ * "session alive", so it wrote the next wake into a dead session's socket, the
10
+ * orphan printed it to a terminal nobody was reading, and the relay recorded
11
+ * `woken via attach` — a success, for a message that was destroyed.
12
+ *
13
+ * Measured on 2026-08-30: TWO orphans were stacked on one session name, and the
14
+ * registry still carried the dead session's resume id, so the eventual headless
15
+ * fallback resumed the OLD conversation and answered the room as if it were the
16
+ * live session. Both halves of that failure are this one missing link.
17
+ *
18
+ * The check is "is my original parent still alive", not "has my parent changed":
19
+ * Windows does not reparent orphans, so a changed ppid is a Unix-only signal,
20
+ * while `kill(pid, 0)` answers on both. PID reuse can in principle make a dead
21
+ * parent look alive; that costs us one stale attach, which is the situation we
22
+ * are already in, and never the reverse.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.processIsAlive = processIsAlive;
26
+ exports.watchParent = watchParent;
27
+ /** Does this process still exist? Signal 0 checks existence without delivering. */
28
+ function processIsAlive(pid) {
29
+ if (!Number.isInteger(pid) || pid <= 1)
30
+ return true; // 0/1 are not a session we can track.
31
+ try {
32
+ process.kill(pid, 0);
33
+ return true;
34
+ }
35
+ catch (err) {
36
+ // EPERM means it exists and belongs to someone else — alive, just not ours.
37
+ return err.code === "EPERM";
38
+ }
39
+ }
40
+ /**
41
+ * Call `onParentGone` once, as soon as `parentPid` is no longer running.
42
+ *
43
+ * `unref`ed so it can never be the reason a process stays up: an attach that is
44
+ * otherwise finished must still exit promptly.
45
+ */
46
+ function watchParent(parentPid, onParentGone, opts = {}) {
47
+ const intervalMs = opts.intervalMs ?? 5_000;
48
+ const isAlive = opts.isAlive ?? processIsAlive;
49
+ const setIv = opts.setInterval ?? setInterval;
50
+ const clearIv = opts.clearInterval ?? clearInterval;
51
+ let fired = false;
52
+ const timer = setIv(() => {
53
+ if (fired)
54
+ return;
55
+ if (isAlive(parentPid))
56
+ return;
57
+ fired = true;
58
+ clearIv(timer);
59
+ onParentGone();
60
+ }, intervalMs);
61
+ timer.unref?.();
62
+ return {
63
+ stop: () => {
64
+ fired = true;
65
+ clearIv(timer);
66
+ },
67
+ };
68
+ }
@@ -75,6 +75,7 @@ exports.resumeIdFromSessionEnv = resumeIdFromSessionEnv;
75
75
  exports.discoverClaudeResume = discoverClaudeResume;
76
76
  exports.discoverCodexResume = discoverCodexResume;
77
77
  exports.discoverResume = discoverResume;
78
+ exports.codexRoots = codexRoots;
78
79
  const fs = __importStar(require("fs"));
79
80
  const os = __importStar(require("os"));
80
81
  const path = __importStar(require("path"));
@@ -224,7 +225,7 @@ async function resumeIdFromSessionEnv(runtime, opts = {}) {
224
225
  if (!roll) {
225
226
  return {
226
227
  ok: false,
227
- reason: `$${varName} names ${truncate(raw, 40)} but no top-level Codex rollout under ${codexRoot(opts)} has that id — refusing to record an id we cannot corroborate`,
228
+ reason: `$${varName} names ${truncate(raw, 40)} but no top-level Codex rollout under ${codexRoots(opts).join(" or ")} has that id — refusing to record an id we cannot corroborate`,
228
229
  };
229
230
  }
230
231
  return {
@@ -279,10 +280,10 @@ async function discoverClaudeResume(sessionName, opts = {}) {
279
280
  * would refuse itself into permanent pending.
280
281
  */
281
282
  async function discoverCodexResume(sessionName, opts = {}) {
282
- const root = codexRoot(opts);
283
- const files = listCodexRollouts(root, opts);
283
+ const roots = codexRoots(opts);
284
+ const files = listCodexRollouts(roots, opts);
284
285
  if (files.length === 0) {
285
- return { ok: false, reason: `no Codex rollouts under ${short(root)} to search` };
286
+ return { ok: false, reason: `no Codex rollouts under ${roots.map(short).join(" or ")} to search` };
286
287
  }
287
288
  const byConversation = new Map();
288
289
  for (const file of files) {
@@ -298,7 +299,7 @@ async function discoverCodexResume(sessionName, opts = {}) {
298
299
  byConversation.set(meta.resumeId, { resumeId: meta.resumeId, file, cwd: meta.cwd });
299
300
  }
300
301
  }
301
- return oneOrRefuse([...byConversation.values()], sessionName, "Codex", root);
302
+ return oneOrRefuse([...byConversation.values()], sessionName, "Codex", roots.map(short).join(" or "));
302
303
  }
303
304
  /** Route to the runtime's discovery, or say the runtime has none. */
304
305
  async function discoverResume(target, opts = {}) {
@@ -415,8 +416,32 @@ function codexCommands(raw) {
415
416
  function claudeRoot(opts) {
416
417
  return opts.claudeProjectsDir ?? path.join(os.homedir(), ".claude", "projects");
417
418
  }
418
- function codexRoot(opts) {
419
- return opts.codexSessionsDir ?? path.join(os.homedir(), ".codex", "sessions");
419
+ /**
420
+ * Every directory a Codex on this machine may keep its rollouts in.
421
+ *
422
+ * NOT just `~/.codex/sessions`. A snap-installed Codex runs confined with its
423
+ * own HOME, so its rollouts land under `~/snap/codex/current/sessions` and the
424
+ * canonical directory never sees them. Searching only the canonical one means a
425
+ * snap session can never be identified, and is therefore permanently
426
+ * unreachable while detached — reported DELIVERY PENDING forever, with a reason
427
+ * that says no rollout recorded this attach, which is true and useless.
428
+ *
429
+ * Measured on 2026-08-30: 29 rollouts under ~/.codex/sessions from an npm
430
+ * install, and 1 under ~/snap/codex/34/sessions from the snap that was actually
431
+ * being run.
432
+ *
433
+ * An injected directory stays exclusive — a test that names a root means that
434
+ * root and not "that root plus whatever this machine happens to have".
435
+ */
436
+ function codexRoots(opts, home = os.homedir()) {
437
+ if (opts.codexSessionsDir)
438
+ return [opts.codexSessionsDir];
439
+ return [
440
+ path.join(home, ".codex", "sessions"),
441
+ // `current` is snap's symlink to the live revision, so this keeps working
442
+ // across a snap refresh.
443
+ path.join(home, "snap", "codex", "current", "sessions"),
444
+ ];
420
445
  }
421
446
  /** `~/.claude/projects/<slug>/<uuid>.jsonl`, newest first, bounded. */
422
447
  function listClaudeTranscripts(root, opts) {
@@ -443,8 +468,8 @@ function listClaudeTranscripts(root, opts) {
443
468
  }
444
469
  return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, opts.maxFiles ?? DEFAULT_MAX_FILES);
445
470
  }
446
- /** `~/.codex/sessions/**\/rollout-*.jsonl`, newest first, bounded. */
447
- function listCodexRollouts(root, opts) {
471
+ /** `<root>/**\/rollout-*.jsonl` across every root, newest first, bounded. */
472
+ function listCodexRollouts(roots, opts) {
448
473
  const cutoff = Date.now() - (opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS);
449
474
  const found = [];
450
475
  const walk = (dir, depth) => {
@@ -464,7 +489,10 @@ function listCodexRollouts(root, opts) {
464
489
  found.push({ file: p, mtimeMs });
465
490
  }
466
491
  };
467
- walk(root, 0);
492
+ for (const root of roots)
493
+ walk(root, 0);
494
+ // Sorted across ALL roots together: newest-first has to mean newest on this
495
+ // machine, not newest within whichever directory was searched first.
468
496
  return found
469
497
  .sort((a, b) => b.mtimeMs - a.mtimeMs)
470
498
  .slice(0, opts.maxFiles ?? DEFAULT_MAX_FILES)
@@ -488,7 +516,7 @@ async function readCodexMeta(file) {
488
516
  }
489
517
  /** Locate a Codex rollout by conversation id, ignoring subagent threads. */
490
518
  async function codexRolloutById(id, opts) {
491
- for (const file of listCodexRollouts(codexRoot(opts), opts)) {
519
+ for (const file of listCodexRollouts(codexRoots(opts), opts)) {
492
520
  const meta = await readCodexMeta(file);
493
521
  if (!meta || meta.isSubagent)
494
522
  continue;