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.
- package/README.md +146 -1
- package/dist/args.js +57 -0
- package/dist/client-paths.js +69 -0
- package/dist/commands.js +108 -0
- package/dist/connect.js +46 -14
- package/dist/credential-refresh.js +97 -0
- package/dist/doctor-command.js +154 -0
- package/dist/doctor.js +502 -0
- package/dist/index.js +40 -26
- package/dist/relay/adapters.js +82 -1
- package/dist/relay/codex-app-server.js +217 -0
- package/dist/relay/codex-queue.js +68 -0
- package/dist/relay/commands.js +177 -31
- package/dist/relay/daemon.js +259 -3
- package/dist/relay/mailbox-watcher.js +118 -0
- package/dist/relay/mailbox.js +319 -0
- package/dist/relay/parent-watch.js +68 -0
- package/dist/relay/resume.js +39 -11
- package/dist/relay/socket.js +98 -14
- package/dist/relay/spawn-env.js +69 -0
- package/dist/runtime-binary.js +269 -0
- package/dist/runtimes.js +118 -46
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/relay/resume.js
CHANGED
|
@@ -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 ${
|
|
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
|
|
283
|
-
const files = listCodexRollouts(
|
|
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(
|
|
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",
|
|
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
|
-
|
|
419
|
-
|
|
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
|
-
/**
|
|
447
|
-
function listCodexRollouts(
|
|
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
|
-
|
|
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(
|
|
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;
|