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.
- package/README.md +146 -1
- package/dist/args.js +72 -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 +521 -0
- package/dist/index.js +47 -26
- package/dist/relay/adapters.js +100 -1
- package/dist/relay/codex-app-server.js +217 -0
- package/dist/relay/codex-queue.js +68 -0
- package/dist/relay/commands.js +440 -36
- package/dist/relay/daemon.js +547 -17
- package/dist/relay/held.js +184 -0
- package/dist/relay/mailbox-watcher.js +118 -0
- package/dist/relay/mailbox.js +320 -0
- package/dist/relay/owner-pid.js +129 -0
- package/dist/relay/parent-watch.js +68 -0
- package/dist/relay/registry.js +11 -0
- package/dist/relay/resume.js +39 -11
- package/dist/relay/socket.js +146 -14
- package/dist/relay/spawn-env.js +69 -0
- package/dist/runtime-binary.js +283 -0
- package/dist/runtimes.js +126 -46
- package/package.json +2 -2
|
@@ -0,0 +1,129 @@
|
|
|
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.procTable = void 0;
|
|
37
|
+
exports.ownerPidFor = ownerPidFor;
|
|
38
|
+
exports.isRuntimeCommand = isRuntimeCommand;
|
|
39
|
+
exports.currentOwnerPid = currentOwnerPid;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
/** How far to walk before giving up. Deep enough for shell wrappers, bounded so a cycle cannot hang an attach. */
|
|
43
|
+
const MAX_HOPS = 12;
|
|
44
|
+
/**
|
|
45
|
+
* The nearest ancestor that IS this runtime, or undefined when none is found.
|
|
46
|
+
*
|
|
47
|
+
* Undefined rather than a fallback to `ppid` on purpose: a witness that lies is
|
|
48
|
+
* worse than no witness. With none, the daemon keeps its previous behaviour and
|
|
49
|
+
* may spawn headlessly — the old bug. With a wrong one it spawns headlessly
|
|
50
|
+
* while the session is alive — the worse bug, and a silent one.
|
|
51
|
+
*/
|
|
52
|
+
function ownerPidFor(runtime, startPid, table) {
|
|
53
|
+
let pid = startPid;
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
|
56
|
+
if (pid <= 1 || seen.has(pid))
|
|
57
|
+
return undefined;
|
|
58
|
+
seen.add(pid);
|
|
59
|
+
if (isRuntimeCommand(table.commandOf(pid), runtime))
|
|
60
|
+
return pid;
|
|
61
|
+
const parent = table.parentOf(pid);
|
|
62
|
+
if (parent === undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
pid = parent;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether a command line IS the runtime, rather than merely mentioning it.
|
|
70
|
+
*
|
|
71
|
+
* Only the first two tokens are considered — the executable, and the script when
|
|
72
|
+
* that executable is an interpreter. The attach process itself runs
|
|
73
|
+
* `node …/baychat relay attach --runtime claude`, which CONTAINS "claude" as an
|
|
74
|
+
* argument; matching anywhere on the line would make every attach its own owner
|
|
75
|
+
* and defeat the check entirely.
|
|
76
|
+
*/
|
|
77
|
+
function isRuntimeCommand(command, runtime) {
|
|
78
|
+
if (!command)
|
|
79
|
+
return false;
|
|
80
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
81
|
+
if (tokens.length === 0)
|
|
82
|
+
return false;
|
|
83
|
+
if (namesRuntime(tokens[0], runtime))
|
|
84
|
+
return true;
|
|
85
|
+
// An interpreter's SCRIPT is the real executable: `node …/codex/bin/codex.js`
|
|
86
|
+
// is Codex. Only consulted behind an interpreter, and only for something that
|
|
87
|
+
// looks like a path — otherwise `grep claude syslog` would match its own
|
|
88
|
+
// search term, and every attach would match its own `--runtime claude`.
|
|
89
|
+
if (!INTERPRETERS.has(path.basename(tokens[0]).toLowerCase()))
|
|
90
|
+
return false;
|
|
91
|
+
const script = tokens[1];
|
|
92
|
+
if (!script || !/[\\/]/.test(script))
|
|
93
|
+
return false;
|
|
94
|
+
return namesRuntime(script, runtime);
|
|
95
|
+
}
|
|
96
|
+
const INTERPRETERS = new Set(["node", "node.exe", "bun", "deno", "python", "python3"]);
|
|
97
|
+
/** `claude`, `claude.exe`, `codex.js` — the runtime's binary, however spelled. Not `claudette`. */
|
|
98
|
+
function namesRuntime(token, runtime) {
|
|
99
|
+
const base = path.basename(token).toLowerCase();
|
|
100
|
+
return base === runtime || base.startsWith(`${runtime}.`);
|
|
101
|
+
}
|
|
102
|
+
/** Reads the live process tree from /proc. Returns nothing where /proc is absent (macOS, Windows). */
|
|
103
|
+
exports.procTable = {
|
|
104
|
+
parentOf(pid) {
|
|
105
|
+
try {
|
|
106
|
+
// Field 4 of /proc/<pid>/stat is ppid. Read past the comm field, which is
|
|
107
|
+
// parenthesised and may itself contain spaces.
|
|
108
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
109
|
+
const after = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
|
|
110
|
+
const ppid = Number(after[1]);
|
|
111
|
+
return Number.isFinite(ppid) ? ppid : undefined;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
commandOf(pid) {
|
|
118
|
+
try {
|
|
119
|
+
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim() || undefined;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
/** The owning runtime process for THIS attach, read from the live tree. */
|
|
127
|
+
function currentOwnerPid(runtime) {
|
|
128
|
+
return ownerPidFor(runtime, process.ppid, exports.procTable);
|
|
129
|
+
}
|
|
@@ -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/registry.js
CHANGED
|
@@ -121,6 +121,17 @@ class SessionRegistry {
|
|
|
121
121
|
if (t)
|
|
122
122
|
t.attached = attached;
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Record that a delivery to this session actually landed.
|
|
126
|
+
*
|
|
127
|
+
* Separate from `setAttached` because it means something stronger: attached is
|
|
128
|
+
* a state that can be claimed, this is an event that was observed.
|
|
129
|
+
*/
|
|
130
|
+
markDelivered(name, at) {
|
|
131
|
+
const t = this.sessions.get(name);
|
|
132
|
+
if (t)
|
|
133
|
+
t.lastDeliveredAt = at;
|
|
134
|
+
}
|
|
124
135
|
/**
|
|
125
136
|
* Drop targets whose session is no longer live server-side.
|
|
126
137
|
*
|
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;
|
package/dist/relay/socket.js
CHANGED
|
@@ -34,11 +34,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.socketPath = socketPath;
|
|
37
|
+
exports.unconfineRuntimeDir = unconfineRuntimeDir;
|
|
37
38
|
exports.isNamedPipe = isNamedPipe;
|
|
38
39
|
exports.pidFilePath = pidFilePath;
|
|
39
40
|
exports.createFrameReader = createFrameReader;
|
|
40
41
|
exports.writeFrame = writeFrame;
|
|
42
|
+
exports.writeFrameAck = writeFrameAck;
|
|
43
|
+
exports.probeSocketDetailed = probeSocketDetailed;
|
|
41
44
|
exports.probeSocket = probeSocket;
|
|
45
|
+
exports.describeProbeFailure = describeProbeFailure;
|
|
42
46
|
exports.unlinkStaleSocket = unlinkStaleSocket;
|
|
43
47
|
const fs = __importStar(require("fs"));
|
|
44
48
|
const net = __importStar(require("net"));
|
|
@@ -76,9 +80,34 @@ function socketPath() {
|
|
|
76
80
|
}
|
|
77
81
|
const runtimeDir = process.env.XDG_RUNTIME_DIR;
|
|
78
82
|
if (runtimeDir)
|
|
79
|
-
return path.join(runtimeDir, "baychat-relay.sock");
|
|
83
|
+
return path.join(unconfineRuntimeDir(runtimeDir), "baychat-relay.sock");
|
|
80
84
|
return path.join((0, config_1.configDir)(), "relay.sock");
|
|
81
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* The runtime dir the RELAY uses, as seen from inside a snap.
|
|
88
|
+
*
|
|
89
|
+
* A confined snap does not get the user's `XDG_RUNTIME_DIR`: snapd rewrites it
|
|
90
|
+
* to a private subdirectory, `/run/user/1000/snap.codex`. A snap-installed Codex
|
|
91
|
+
* therefore computes a socket path no relay has ever listened on, finds nothing,
|
|
92
|
+
* and reports "no relay is running" — while the real socket sits one level up,
|
|
93
|
+
* readable and connectable by the same uid. Verified on 2026-08-30: from inside
|
|
94
|
+
* `snap run --shell codex`, `/run/user/1000/baychat-relay.sock` connects fine;
|
|
95
|
+
* only the path was wrong.
|
|
96
|
+
*
|
|
97
|
+
* This is RECURRING_MISTAKES §24 again — "where it keeps its state" is not
|
|
98
|
+
* "where this copy reads" — and the same shape as `codexRoots`, which already
|
|
99
|
+
* has to know that a snap Codex keeps its rollouts somewhere else.
|
|
100
|
+
*
|
|
101
|
+
* Guarded on `SNAP` so an ordinary process is never second-guessed, and it only
|
|
102
|
+
* strips a segment that actually looks like snapd's, so a user whose runtime dir
|
|
103
|
+
* legitimately ends in something else is left alone.
|
|
104
|
+
*/
|
|
105
|
+
function unconfineRuntimeDir(runtimeDir, env = process.env) {
|
|
106
|
+
if (!env.SNAP)
|
|
107
|
+
return runtimeDir;
|
|
108
|
+
const parent = path.dirname(runtimeDir);
|
|
109
|
+
return /^snap\.[^/\\]+$/.test(path.basename(runtimeDir)) ? parent : runtimeDir;
|
|
110
|
+
}
|
|
82
111
|
/** A pipe name may not contain a backslash — it would read as another level of
|
|
83
112
|
* the pipe namespace — and Windows usernames legally can (`DOMAIN\user` reaches
|
|
84
113
|
* `os.userInfo()` that way on some setups). Everything outside the safe set is
|
|
@@ -129,31 +158,134 @@ function writeFrame(sock, frame) {
|
|
|
129
158
|
sock.write(JSON.stringify(frame) + "\n");
|
|
130
159
|
}
|
|
131
160
|
/**
|
|
132
|
-
*
|
|
161
|
+
* Write a frame and WAIT for the socket to take it, surfacing the failure if it
|
|
162
|
+
* does not.
|
|
163
|
+
*
|
|
164
|
+
* `writeFrame` is fire-and-forget. `sock.write()` buffers and returns, so a peer
|
|
165
|
+
* that has already gone produces an EPIPE on the socket's `error` event LATER —
|
|
166
|
+
* by which time the caller has recorded `woken`. That is a lost message filed as
|
|
167
|
+
* delivered, which is the single outcome `relay status` exists to make
|
|
168
|
+
* impossible: every other failure is at least visible as pending.
|
|
169
|
+
*
|
|
170
|
+
* Three ways this settles, and all three are needed. The write callback covers
|
|
171
|
+
* the ordinary case; the `error` event covers an EPIPE that arrives without one;
|
|
172
|
+
* `close` covers a peer that vanishes while the bytes are still buffered, which
|
|
173
|
+
* reports no error at all.
|
|
133
174
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* to
|
|
137
|
-
*
|
|
175
|
+
* HONEST LIMIT — this is a FLUSH confirmation, not a receiver acknowledgement.
|
|
176
|
+
* It proves the bytes left this process, not that the agent read them. A true
|
|
177
|
+
* end-to-end ack needs an `ack` frame back from the attach client, and every
|
|
178
|
+
* already-installed client would have to be taught to send one before the
|
|
179
|
+
* daemon could require it. What this closes is the failure that was actually
|
|
180
|
+
* observed — a dead peer recorded as woken — not the whole class.
|
|
138
181
|
*/
|
|
139
|
-
function
|
|
182
|
+
function writeFrameAck(sock, frame) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
if (sock.destroyed || sock.writableEnded) {
|
|
185
|
+
reject(new Error("the attach socket is already closed"));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
let settled = false;
|
|
189
|
+
const settle = (err) => {
|
|
190
|
+
if (settled)
|
|
191
|
+
return;
|
|
192
|
+
settled = true;
|
|
193
|
+
sock.off("error", onError);
|
|
194
|
+
sock.off("close", onClose);
|
|
195
|
+
if (err)
|
|
196
|
+
reject(err);
|
|
197
|
+
else
|
|
198
|
+
resolve();
|
|
199
|
+
};
|
|
200
|
+
const onError = (err) => settle(err);
|
|
201
|
+
const onClose = () => settle(new Error("the attach socket closed before the frame was flushed"));
|
|
202
|
+
sock.once("error", onError);
|
|
203
|
+
sock.once("close", onClose);
|
|
204
|
+
sock.write(JSON.stringify(frame) + "\n", (err) => settle(err ?? undefined));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Probe the attach endpoint, KEEPING THE REASON IT FAILED.
|
|
209
|
+
*
|
|
210
|
+
* The boolean version of this threw the errno away, and every distinct failure
|
|
211
|
+
* arrived at the user as one sentence: "no relay is running". That sentence is
|
|
212
|
+
* true for exactly one of them. A sandboxed or differently-elevated shell that
|
|
213
|
+
* is REFUSED the pipe (EACCES/EPERM) is told the daemon does not exist, so the
|
|
214
|
+
* obvious next move is `relay start` — which spawns a SECOND daemon, and two
|
|
215
|
+
* daemons on one credential is a materially worse state than the one we were
|
|
216
|
+
* diagnosing. A wedged daemon that never accepts is likewise reported as absent.
|
|
217
|
+
*
|
|
218
|
+
* Measured on 2026-08-30: a Codex session on Windows reported "no relay is
|
|
219
|
+
* running" while a healthy relay (pid 16388) held four of its messages, and the
|
|
220
|
+
* CLI could not say which of these it had hit. Hence the reason code.
|
|
221
|
+
*/
|
|
222
|
+
function probeSocketDetailed(sockPath, timeoutMs = 1_000) {
|
|
140
223
|
return new Promise((resolve) => {
|
|
141
224
|
// The existence check is a cheap way to skip a connect that cannot succeed —
|
|
142
225
|
// but a named pipe has no directory entry, so on Windows it answers "false"
|
|
143
226
|
// for a perfectly healthy daemon. Skipped there; the connect below is the
|
|
144
227
|
// honest test on every platform anyway.
|
|
145
|
-
if (!isNamedPipe(sockPath)
|
|
146
|
-
|
|
228
|
+
if (!isNamedPipe(sockPath)) {
|
|
229
|
+
// `fs.existsSync` cannot be used here: it answers false for BOTH "there is
|
|
230
|
+
// nothing at this path" and "you may not look at this path", which is the
|
|
231
|
+
// very conflation this function exists to end. `statSync` throws an errno
|
|
232
|
+
// that separates them.
|
|
233
|
+
try {
|
|
234
|
+
fs.statSync(sockPath);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
const code = err.code;
|
|
238
|
+
if (code === "ENOENT")
|
|
239
|
+
return resolve({ alive: false, reason: "absent" });
|
|
240
|
+
const reason = code === "EACCES" || code === "EPERM" ? "denied" : "error";
|
|
241
|
+
return resolve({ alive: false, reason, code, detail: err.message });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
147
244
|
const sock = net.createConnection(sockPath);
|
|
148
|
-
const done = (
|
|
245
|
+
const done = (result) => {
|
|
149
246
|
sock.destroy();
|
|
150
|
-
resolve(
|
|
247
|
+
resolve(result);
|
|
151
248
|
};
|
|
152
|
-
sock.setTimeout(timeoutMs, () => done(false));
|
|
153
|
-
sock.on("connect", () => done(true));
|
|
154
|
-
sock.on("error", () =>
|
|
249
|
+
sock.setTimeout(timeoutMs, () => done({ alive: false, reason: "timeout" }));
|
|
250
|
+
sock.on("connect", () => done({ alive: true }));
|
|
251
|
+
sock.on("error", (err) => {
|
|
252
|
+
const code = err.code;
|
|
253
|
+
// ENOENT/ECONNREFUSED genuinely mean nothing is listening. EACCES/EPERM
|
|
254
|
+
// mean something IS there and we were refused — the opposite diagnosis.
|
|
255
|
+
const reason = code === "ENOENT" || code === "ECONNREFUSED" ? "absent" : code === "EACCES" || code === "EPERM" ? "denied" : "error";
|
|
256
|
+
done({ alive: false, reason, code, detail: err.message });
|
|
257
|
+
});
|
|
155
258
|
});
|
|
156
259
|
}
|
|
260
|
+
function probeSocket(sockPath, timeoutMs = 1_000) {
|
|
261
|
+
return probeSocketDetailed(sockPath, timeoutMs).then((r) => r.alive);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Turn a failed probe into a sentence that names the ACTUAL obstacle.
|
|
265
|
+
*
|
|
266
|
+
* Only `absent` may suggest `relay start`; the others must not, because
|
|
267
|
+
* starting a second daemon is the wrong move for all of them.
|
|
268
|
+
*/
|
|
269
|
+
function describeProbeFailure(sockPath, result) {
|
|
270
|
+
if (result.alive)
|
|
271
|
+
return "";
|
|
272
|
+
switch (result.reason) {
|
|
273
|
+
case "absent":
|
|
274
|
+
return "no relay is running — start one with `baychat relay start`";
|
|
275
|
+
case "denied":
|
|
276
|
+
return (`a relay endpoint exists at ${sockPath} but this process was refused it (${result.code}). ` +
|
|
277
|
+
`That is a permission boundary, not a missing daemon — do NOT run \`relay start\`, which would ` +
|
|
278
|
+
`create a second one. It usually means this shell runs under a different token than the relay ` +
|
|
279
|
+
`(an elevated or sandboxed shell), so run from a normal shell as the same user, or restart the ` +
|
|
280
|
+
`relay from this shell.`);
|
|
281
|
+
case "timeout":
|
|
282
|
+
return (`a relay is listening at ${sockPath} but did not accept a connection within ${1_000}ms — it is ` +
|
|
283
|
+
`probably wedged rather than absent. Check \`relay status\` from the shell that started it, and ` +
|
|
284
|
+
`stop that daemon before starting another.`);
|
|
285
|
+
default:
|
|
286
|
+
return `could not reach the relay at ${sockPath}: ${result.code ?? "unknown error"}${result.detail ? ` (${result.detail})` : ""}`;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
157
289
|
/** Remove a socket file we have already proven dead.
|
|
158
290
|
*
|
|
159
291
|
* A no-op for a named pipe, which has nothing on disk to remove: Windows tears
|
|
@@ -0,0 +1,69 @@
|
|
|
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.headlessSpawnEnv = headlessSpawnEnv;
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
/**
|
|
39
|
+
* The environment a headless turn is spawned with.
|
|
40
|
+
*
|
|
41
|
+
* WHY THIS EXISTS. A runtime installed by npm is a JavaScript file with a
|
|
42
|
+
* `#!/usr/bin/env node` shebang, so running it asks the SPAWNING process's PATH
|
|
43
|
+
* to find `node`. The relay daemon runs under systemd, whose PATH is a minimal
|
|
44
|
+
* system one — it does not contain nvm's node, or any node at all on a machine
|
|
45
|
+
* where node was never installed system-wide.
|
|
46
|
+
*
|
|
47
|
+
* Measured 2026-08-31 00:06: the headless rung became reachable for the first
|
|
48
|
+
* time and every wake died instantly with
|
|
49
|
+
* `/usr/bin/env: 'node': No such file or directory`, exit 127. The binary was
|
|
50
|
+
* correct and present; nothing could run it.
|
|
51
|
+
*
|
|
52
|
+
* This is `SessionTarget.runtimeBin` one layer down. That rule says the session
|
|
53
|
+
* identifies its own binary because only it can; this says the daemon must also
|
|
54
|
+
* hand that binary an environment it can actually start in — and the daemon is
|
|
55
|
+
* the only process that knows where its own node lives (`process.execPath`,
|
|
56
|
+
* which is exactly the interpreter a shebang is looking for).
|
|
57
|
+
*
|
|
58
|
+
* PREPENDED, not replaced: a runtime may legitimately need the rest of the
|
|
59
|
+
* inherited PATH to find its own helpers, and clobbering it would trade this
|
|
60
|
+
* failure for a subtler one.
|
|
61
|
+
*/
|
|
62
|
+
function headlessSpawnEnv(env = process.env, execPath = process.execPath) {
|
|
63
|
+
const nodeDir = path.dirname(execPath);
|
|
64
|
+
const current = env.PATH ?? "";
|
|
65
|
+
const parts = current.split(path.delimiter).filter(Boolean);
|
|
66
|
+
if (parts.includes(nodeDir))
|
|
67
|
+
return env;
|
|
68
|
+
return { ...env, PATH: [nodeDir, ...parts].join(path.delimiter) };
|
|
69
|
+
}
|