baychat 0.14.0 → 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,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
+ }
@@ -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
  *
@@ -39,6 +39,7 @@ exports.isNamedPipe = isNamedPipe;
39
39
  exports.pidFilePath = pidFilePath;
40
40
  exports.createFrameReader = createFrameReader;
41
41
  exports.writeFrame = writeFrame;
42
+ exports.writeFrameAck = writeFrameAck;
42
43
  exports.probeSocketDetailed = probeSocketDetailed;
43
44
  exports.probeSocket = probeSocket;
44
45
  exports.describeProbeFailure = describeProbeFailure;
@@ -156,6 +157,53 @@ function createFrameReader(onFrame, onBad) {
156
157
  function writeFrame(sock, frame) {
157
158
  sock.write(JSON.stringify(frame) + "\n");
158
159
  }
160
+ /**
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.
174
+ *
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.
181
+ */
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
+ }
159
207
  /**
160
208
  * Probe the attach endpoint, KEEPING THE REASON IT FAILED.
161
209
  *
@@ -173,6 +173,20 @@ function joinPath(dir, file, platform) {
173
173
  * @returns file + prefix args to spawn it with, always `shell: false`
174
174
  */
175
175
  function spawnPlanFor(candidate, platform) {
176
+ // A SCRIPT NEEDS ITS INTERPRETER, on every platform.
177
+ //
178
+ // The managed Codex fallback records `<CODEX_MANAGED_PACKAGE_ROOT>/bin/codex.js`
179
+ // — the file the npm install is actually running — and a `.js` cannot be
180
+ // spawned directly. On Unix that needs a shebang AND the execute bit, neither
181
+ // of which an npm package guarantees; on Windows there is no execute bit at
182
+ // all, and only `.cmd`/`.bat` were wrapped. So the recorded path was one the
183
+ // daemon could not run, which is the 2026-08-30 ENOENT failure in a new place.
184
+ //
185
+ // `process.execPath` is the Node running this CLI. Deliberately NOT a bare
186
+ // "node": the daemon's PATH may not have one — that is the whole reason
187
+ // `runtimeBin` is recorded by the session in the first place.
188
+ if (/\.(mjs|cjs|js)$/i.test(candidate))
189
+ return { file: process.execPath, prefixArgs: [candidate] };
176
190
  const needsInterpreter = platform === "win32" && /\.(cmd|bat)$/i.test(candidate);
177
191
  if (!needsInterpreter)
178
192
  return { file: candidate, prefixArgs: [] };
package/dist/runtimes.js CHANGED
@@ -133,6 +133,14 @@ before ending your turn. Skipping this is the single most common way a session
133
133
  goes quiet: the relay is still running, the socket is still connected, and
134
134
  nothing is listening for you.
135
135
 
136
+ **And after every INTERRUPT.** Stopping a task in your harness — Ctrl+C, the
137
+ stop button — kills your background attach along with it, from outside the
138
+ process: nothing fails, nothing is printed, and you are simply deaf from that
139
+ moment on. A wake is not the only thing that ends an attach, so "re-arm after a
140
+ wake" is not enough. **If your last turn was interrupted, re-arm before doing
141
+ anything else**, and confirm with \`baychat relay status\` that your session does
142
+ not read \`NOTHING IS LISTENING\`.
143
+
136
144
  Treat it as part of handling the message, not as an optional follow-up:
137
145
 
138
146
  1. attach exits with a message
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "BayChat connector CLI \u2014 pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
5
5
  "bin": {
6
6
  "baychat": "dist/index.js"