ai-or-die 0.1.63 → 0.1.64

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.63",
3
+ "version": "0.1.64",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "dev": "node bin/supervisor.js --dev",
14
14
  "dev:direct": "node bin/ai-or-die.js --dev",
15
15
  "test": "mocha --exit test/*.test.js",
16
- "test:integration": "mocha --exit --timeout 20000 test/supervisor-integration.test.js",
16
+ "test:integration": "mocha --exit --timeout 60000 test/supervisor-integration.test.js test/integration/*.test.js",
17
17
  "test:browser": "npx playwright test --config e2e/playwright.config.js",
18
18
  "build:bundle": "node scripts/build-sea.js bundle",
19
19
  "build:sea": "node scripts/build-sea.js",
@@ -40,6 +40,7 @@
40
40
  "commander": "^12.1.0",
41
41
  "cors": "^2.8.5",
42
42
  "express": "^4.19.2",
43
+ "fuzzysort": "^3.1.0",
43
44
  "open": "^10.1.0",
44
45
  "selfsigned": "^2.4.1",
45
46
  "sherpa-onnx-node": "^1.12.24",
@@ -247,7 +247,17 @@ class BaseBridge {
247
247
  created: new Date(),
248
248
  active: true,
249
249
  killTimeout: null,
250
- writeQueue: Promise.resolve()
250
+ writeQueue: Promise.resolve(),
251
+ // PTY listener handles registered against ptyProcess.{onData,onExit,on('error')}.
252
+ // node-pty's onData/onExit return IDisposable objects with a .dispose()
253
+ // method; the EventEmitter-style .on('error', fn) path is wrapped in a
254
+ // synthetic disposable so it can be torn down through the same helper.
255
+ // Without explicit disposal, the closures keep dataBuffer/outputBatch/
256
+ // flushTimer alive, pinning the PTY wrapper and its file descriptors
257
+ // across thousands of session create/delete cycles — root cause of the
258
+ // weeks-long EMFILE / server-unresponsive symptom on Windows-primary
259
+ // production deployments.
260
+ _ptyDisposables: []
251
261
  };
252
262
 
253
263
  this.sessions.set(sessionId, session);
@@ -260,6 +270,10 @@ class BaseBridge {
260
270
  console.error(`${this.toolName} session ${sessionId}: no response within ${SPAWN_TIMEOUT_MS}ms, treating as spawn failure`);
261
271
  session.active = false;
262
272
  this.sessions.delete(sessionId);
273
+ // Dispose any listener handles we wired up before the timeout fired;
274
+ // otherwise the PTY object (and its FDs) cannot be GC'd even after
275
+ // the kill() below succeeds.
276
+ this._disposePtyDisposables(session, sessionId);
263
277
  try { ptyProcess.kill(); } catch (e) { /* ignore */ }
264
278
  onError(new Error(`${this.toolName} process did not respond within ${SPAWN_TIMEOUT_MS / 1000} seconds. The command may not be installed or may have hung during startup.`));
265
279
  }
@@ -269,7 +283,7 @@ class BaseBridge {
269
283
  let outputBatch = '';
270
284
  let flushTimer = null;
271
285
 
272
- ptyProcess.onData((data) => {
286
+ const onDataDisposable = ptyProcess.onData((data) => {
273
287
  if (!receivedLifeSign) {
274
288
  receivedLifeSign = true;
275
289
  clearTimeout(spawnWatchdog);
@@ -300,8 +314,9 @@ class BaseBridge {
300
314
  });
301
315
  }
302
316
  });
317
+ this._addPtyDisposable(session, onDataDisposable);
303
318
 
304
- ptyProcess.onExit((exitCode, signal) => {
319
+ const onExitDisposable = ptyProcess.onExit((exitCode, signal) => {
305
320
  if (!receivedLifeSign) {
306
321
  receivedLifeSign = true;
307
322
  clearTimeout(spawnWatchdog);
@@ -312,14 +327,19 @@ class BaseBridge {
312
327
  clearTimeout(session.killTimeout);
313
328
  session.killTimeout = null;
314
329
  }
330
+ // Drain remaining disposables — the PTY is gone, but the wrappers
331
+ // still hold references to the data-buffer closures. Skip onExit
332
+ // self-disposal (node-pty already removed it on fire).
333
+ this._disposePtyDisposables(session, sessionId);
315
334
  if (this.sessions.has(sessionId)) {
316
335
  session.active = false;
317
336
  this.sessions.delete(sessionId);
318
337
  }
319
338
  onExit(exitCode, signal);
320
339
  });
340
+ this._addPtyDisposable(session, onExitDisposable);
321
341
 
322
- ptyProcess.on('error', (error) => {
342
+ const errorHandler = (error) => {
323
343
  if (!receivedLifeSign) {
324
344
  receivedLifeSign = true;
325
345
  clearTimeout(spawnWatchdog);
@@ -334,11 +354,26 @@ class BaseBridge {
334
354
  clearTimeout(session.killTimeout);
335
355
  session.killTimeout = null;
336
356
  }
357
+ this._disposePtyDisposables(session, sessionId);
337
358
  if (this.sessions.has(sessionId)) {
338
359
  session.active = false;
339
360
  this.sessions.delete(sessionId);
340
361
  }
341
362
  onError(error);
363
+ };
364
+ ptyProcess.on('error', errorHandler);
365
+ // EventEmitter-style 'error' handler doesn't return IDisposable, so wrap
366
+ // it in one so the same drain helper covers all three callsites.
367
+ this._addPtyDisposable(session, {
368
+ dispose: () => {
369
+ try {
370
+ if (typeof ptyProcess.off === 'function') {
371
+ ptyProcess.off('error', errorHandler);
372
+ } else if (typeof ptyProcess.removeListener === 'function') {
373
+ ptyProcess.removeListener('error', errorHandler);
374
+ }
375
+ } catch (_) { /* ignore */ }
376
+ }
342
377
  });
343
378
 
344
379
  console.log(`${this.toolName} session ${sessionId} started successfully`);
@@ -423,6 +458,43 @@ class BaseBridge {
423
458
  }
424
459
  }
425
460
 
461
+ /**
462
+ * Push an IDisposable handle onto a session's listener list so stopSession
463
+ * can drain it. The disposable shape is whatever node-pty's onData/onExit
464
+ * returns: an object with a .dispose() method. EventEmitter-style 'error'
465
+ * handlers are wrapped in a synthetic disposable at the registration site.
466
+ * @private
467
+ */
468
+ _addPtyDisposable(session, disposable) {
469
+ if (!session || !disposable || typeof disposable.dispose !== 'function') return;
470
+ if (!Array.isArray(session._ptyDisposables)) session._ptyDisposables = [];
471
+ session._ptyDisposables.push(disposable);
472
+ }
473
+
474
+ /**
475
+ * Drain a session's PTY listener disposables. Each .dispose() is wrapped
476
+ * in try/catch so one bad handle can't strand the others; the array is
477
+ * cleared on the way out so a re-entrant call is a safe no-op. Called from
478
+ * stopSession (manual teardown), the natural onExit callback (PTY exited
479
+ * on its own), the 'error' handler (PTY blew up), and the spawn-watchdog
480
+ * timeout path — every code path that retires a PTY object.
481
+ * @private
482
+ */
483
+ _disposePtyDisposables(session, sessionId) {
484
+ if (!session || !Array.isArray(session._ptyDisposables)) return;
485
+ const handles = session._ptyDisposables;
486
+ session._ptyDisposables = [];
487
+ for (const h of handles) {
488
+ try {
489
+ if (h && typeof h.dispose === 'function') h.dispose();
490
+ } catch (err) {
491
+ if (process.env.DEBUG) {
492
+ console.warn(`${this.toolName} session ${sessionId || '?'}: dispose() threw: ${err && err.message}`);
493
+ }
494
+ }
495
+ }
496
+ }
497
+
426
498
  async stopSession(sessionId) {
427
499
  const session = this.sessions.get(sessionId);
428
500
  if (!session) {
@@ -438,21 +510,42 @@ class BaseBridge {
438
510
  session.killTimeout = null;
439
511
  }
440
512
 
513
+ // Dispose every PTY listener we wired up at startSession time. This is
514
+ // the load-bearing fix for the EMFILE leak: without it, the onData /
515
+ // onExit / 'error' closures keep references to dataBuffer + outputBatch
516
+ // + flushTimer, pinning the underlying ptyProcess (and its FDs) past
517
+ // session disposal. We dispose BEFORE registering the temporary onExit
518
+ // waiter below so the temp handle is the only one left when kill()
519
+ // runs.
520
+ this._disposePtyDisposables(session, sessionId);
521
+
441
522
  if (!session.process) return;
442
523
 
443
524
  // Return a promise that resolves when the PTY process actually exits
444
525
  // (or after a bounded timeout), so callers can await clean shutdown.
445
526
  return new Promise((resolve) => {
527
+ let settled = false;
528
+ let waitDisposable = null;
529
+
446
530
  const cleanup = () => {
531
+ if (settled) return;
532
+ settled = true;
447
533
  if (session.killTimeout) {
448
534
  clearTimeout(session.killTimeout);
449
535
  session.killTimeout = null;
450
536
  }
537
+ // Tear down the temporary onExit waiter so it doesn't outlive the
538
+ // promise it backed (same FD-leak class as the main listeners).
539
+ if (waitDisposable && typeof waitDisposable.dispose === 'function') {
540
+ try { waitDisposable.dispose(); } catch (_) { /* ignore */ }
541
+ }
451
542
  resolve();
452
543
  };
453
544
 
454
545
  try {
455
- session.process.onExit(() => cleanup());
546
+ const handle = session.process.onExit(() => cleanup());
547
+ // node-pty returns an IDisposable; older mocks may return undefined.
548
+ if (handle && typeof handle.dispose === 'function') waitDisposable = handle;
456
549
  } catch (_) {
457
550
  // onExit may fail if process already exited
458
551
  }
@@ -466,7 +559,7 @@ class BaseBridge {
466
559
  // Bounded timeout: don't wait forever for ConPTY cleanup
467
560
  session.killTimeout = setTimeout(() => {
468
561
  session.killTimeout = null;
469
- resolve();
562
+ cleanup();
470
563
  }, 3000);
471
564
  });
472
565
  }
@@ -0,0 +1,213 @@
1
+ // src/osc7-parser.js — In-band CWD signal extractor for OSC 7 sequences.
2
+ //
3
+ // OSC 7 (Operating System Command, payload 7) is the de-facto cross-vendor
4
+ // protocol shells use to broadcast their current working directory: the byte
5
+ // sequence `ESC ] 7 ; file://<host><path> BEL` (or with `ESC \` as the
6
+ // String Terminator instead of BEL). Every modern terminal emulator parses
7
+ // it — VTE/GNOME Terminal, iTerm2, WezTerm, Konsole, Tilix — and that's
8
+ // what we leverage here. See ADR-0019 for the full rationale (vs PID
9
+ // polling / static-only) and the cross-platform path-handling story.
10
+ //
11
+ // Responsibilities:
12
+ //
13
+ // 1. Maintain a small per-instance pending buffer (cap 4 KB) so OSC 7
14
+ // sequences split across PTY chunks resolve correctly.
15
+ // 2. Match `\x1b]7;file://...` framing with either BEL (`\x07`) or ST
16
+ // (`\x1b\\`) terminator.
17
+ // 3. Decode the matched URI via Node's built-in `url.fileURLToPath()`.
18
+ // This handles POSIX (`file:///Users/foo`), Windows drive
19
+ // (`file:///C:/Users/foo` → `C:\Users\foo`), and Windows UNC
20
+ // (`file://server/share/foo` → `\\server\share\foo`) uniformly.
21
+ // 4. Tolerate junk: malformed URIs, non-`file://` schemes, missing
22
+ // terminators, and overflow are all silent skips. Never throws on
23
+ // input — production runs inside the PTY data hot path.
24
+ //
25
+ // Non-responsibilities:
26
+ //
27
+ // - Sandbox enforcement (caller's `validatePath()`).
28
+ // - WebSocket emit (caller decides on change).
29
+ // - Stripping the OSC 7 bytes from the output stream (we deliberately
30
+ // DO NOT strip — xterm.js silently ignores unknown OSC sequences and
31
+ // preserving the bytes keeps parity with native terminals).
32
+
33
+ 'use strict';
34
+
35
+ const url = require('url');
36
+
37
+ const PREFIX = '\x1b]7;';
38
+ const PREFIX_LEN = PREFIX.length; // 4 bytes
39
+ const BEL = '\x07';
40
+ const ST = '\x1b\\';
41
+ const MAX_PENDING = 4096;
42
+
43
+ class Osc7Parser {
44
+ constructor() {
45
+ /**
46
+ * Pending byte buffer between feeds. Holds whatever could not yet be
47
+ * resolved to a complete OSC 7 sequence — typically a partial sequence
48
+ * straddling a PTY chunk boundary. Capped at MAX_PENDING; on overflow
49
+ * the buffer is dropped and we resync at the next OSC 7 prefix.
50
+ */
51
+ this._buf = '';
52
+ }
53
+
54
+ /**
55
+ * Feed one PTY data chunk (or any string) into the parser. Returns an
56
+ * array of zero or more decoded absolute paths (in whatever form
57
+ * `url.fileURLToPath()` produces on the host platform).
58
+ *
59
+ * Safe to call with empty / null / undefined input — returns [].
60
+ *
61
+ * @param {string} chunk - Raw PTY bytes as a string. Multi-byte UTF-8 is
62
+ * passed through unchanged; the OSC 7 framing bytes (ESC, BEL, ;,
63
+ * file://) are all single-byte ASCII so per-character indexing is safe.
64
+ * @returns {string[]} Decoded paths, in feed order. Empty array if no
65
+ * complete sequence resolved this call.
66
+ */
67
+ feed(chunk) {
68
+ if (!chunk) return [];
69
+ if (typeof chunk !== 'string') chunk = String(chunk);
70
+ this._buf += chunk;
71
+
72
+ const results = [];
73
+
74
+ // Loop: each iteration either consumes one complete OSC 7 sequence,
75
+ // drops leading non-OSC bytes, or breaks out (no more starts found,
76
+ // or pending an unfinished sequence).
77
+ while (true) {
78
+ const start = this._buf.indexOf(PREFIX);
79
+ if (start < 0) {
80
+ // No OSC 7 prefix anywhere in the pending buffer. Drop everything
81
+ // — the bytes are plain output, retaining them serves no purpose
82
+ // and would let plain output drive the buffer to MAX_PENDING.
83
+ // Keep only the trailing 3 bytes in case a prefix straddles
84
+ // chunks (`\x1b]7` + `;file://...` arriving in two feeds).
85
+ this._buf = this._buf.length > PREFIX_LEN - 1
86
+ ? this._buf.slice(-(PREFIX_LEN - 1))
87
+ : this._buf;
88
+ // If even the truncated tail doesn't start with `\x1b`, drop it too.
89
+ if (this._buf.indexOf('\x1b') < 0) this._buf = '';
90
+ break;
91
+ }
92
+
93
+ // Drop bytes before the prefix — they're plain output, not part of
94
+ // any OSC 7 sequence we care about.
95
+ if (start > 0) this._buf = this._buf.slice(start);
96
+
97
+ // Search for the terminator AFTER the prefix bytes. Either BEL or
98
+ // ST may close the sequence; take whichever appears first.
99
+ const bel = this._buf.indexOf(BEL, PREFIX_LEN);
100
+ const st = this._buf.indexOf(ST, PREFIX_LEN);
101
+
102
+ let term = -1;
103
+ let termLen = 0;
104
+ if (bel >= 0 && (st < 0 || bel < st)) {
105
+ term = bel;
106
+ termLen = 1;
107
+ } else if (st >= 0) {
108
+ term = st;
109
+ termLen = 2;
110
+ }
111
+
112
+ if (term < 0) {
113
+ // Unterminated sequence — keep pending and wait for more bytes.
114
+ // Overflow guard: if the in-flight sequence exceeds the cap, drop
115
+ // and resync. A 4 KB OSC 7 path is pathological anyway (POSIX
116
+ // PATH_MAX is 4096, Windows MAX_PATH is 260) — anything larger is
117
+ // either a buggy emitter or an attacker probing the parser.
118
+ if (this._buf.length > MAX_PENDING) this._buf = '';
119
+ break;
120
+ }
121
+
122
+ // Extract the body between prefix and terminator, advance past the
123
+ // terminator, and try to decode.
124
+ const body = this._buf.slice(PREFIX_LEN, term);
125
+ this._buf = this._buf.slice(term + termLen);
126
+
127
+ // OSC 7 only meaningfully carries `file://` URIs. Other OSC 7
128
+ // payloads (some shells emit non-CWD info on the same channel) are
129
+ // silently ignored — same posture xterm.js takes.
130
+ if (!body.startsWith('file://')) continue;
131
+
132
+ try {
133
+ const p = decodeOsc7File(body);
134
+ results.push(p);
135
+ } catch (_) {
136
+ // Malformed URI (invalid host segment, bad percent encoding, etc.).
137
+ // Silent drop — there's no recovery path inside a PTY data stream.
138
+ // Optional debug:
139
+ if (process.env.DEBUG) {
140
+ // eslint-disable-next-line no-console
141
+ console.warn('osc7-parser: skipped malformed body:', JSON.stringify(body));
142
+ }
143
+ }
144
+ }
145
+
146
+ return results;
147
+ }
148
+
149
+ /**
150
+ * Reset the pending buffer. Called when a session is destroyed so the
151
+ * parser doesn't leak unfinished bytes across session lifetimes.
152
+ */
153
+ reset() {
154
+ this._buf = '';
155
+ }
156
+
157
+ /**
158
+ * Inspection helper for tests — returns the current pending buffer
159
+ * length without exposing the contents.
160
+ */
161
+ _bufLength() {
162
+ return this._buf.length;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Decode an OSC 7 file:// URI into a local path. Tolerant wrapper around
168
+ * Node's url.fileURLToPath() that handles the real-world case where shell
169
+ * prompt hooks emit `file://$HOSTNAME/...` (with the local machine's
170
+ * hostname, not `localhost`).
171
+ *
172
+ * Background: the documented bash hook in docs/specs/file-browser.md and
173
+ * the zsh / pwsh hooks all emit `file://$HOSTNAME$PWD` / `file://$HOST$PWD`
174
+ * / `file://$env:COMPUTERNAME/$p` respectively. On POSIX (Linux + macOS),
175
+ * Node's url.fileURLToPath() throws ERR_INVALID_FILE_URL_HOST for any
176
+ * host segment that isn't exactly `localhost` or empty — so the spec
177
+ * hooks never produced a usable path until this fallback was added.
178
+ *
179
+ * Behaviour:
180
+ * - Try fileURLToPath(body) first. If it succeeds (host is empty,
181
+ * "localhost", or — on Windows — a UNC server name), return the
182
+ * decoded path unchanged.
183
+ * - On POSIX, when fileURLToPath throws ERR_INVALID_FILE_URL_HOST,
184
+ * re-parse with the host segment stripped: `file://hostname/path`
185
+ * → `file:///path`. This mirrors what every consumer terminal
186
+ * (iTerm2, GNOME Terminal, WezTerm, etc.) does — they trust the
187
+ * path locally because the OSC 7 came from a shell on the same
188
+ * machine as the terminal emulator.
189
+ * - On Windows, the host segment is meaningful (UNC paths). Don't
190
+ * strip it — propagate the throw so the parser silently skips.
191
+ * - All other errors propagate so the caller can decide (the parser
192
+ * wraps in try/catch and silently skips).
193
+ */
194
+ function decodeOsc7File(body) {
195
+ try {
196
+ return url.fileURLToPath(body);
197
+ } catch (err) {
198
+ if (err && err.code === 'ERR_INVALID_FILE_URL_HOST' && process.platform !== 'win32') {
199
+ // POSIX: a non-localhost hostname in OSC 7 is the rule, not the
200
+ // exception (every shell hook emits $HOSTNAME / $HOST). Strip it
201
+ // and re-parse the path component as a local POSIX path.
202
+ const m = body.match(/^file:\/\/[^/]*(\/.*)$/);
203
+ if (m) {
204
+ return url.fileURLToPath('file://' + m[1]);
205
+ }
206
+ }
207
+ throw err;
208
+ }
209
+ }
210
+
211
+ module.exports = Osc7Parser;
212
+ module.exports.MAX_PENDING = MAX_PENDING;
213
+ module.exports._decodeOsc7File = decodeOsc7File;