ai-or-die 0.1.63 → 0.1.65
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 +3 -2
- package/src/base-bridge.js +99 -6
- package/src/osc7-parser.js +213 -0
- package/src/public/app.js +506 -7
- package/src/public/auth.js +152 -27
- package/src/public/components/feedback.css +23 -0
- package/src/public/components/file-browser.css +139 -0
- package/src/public/feedback-manager.js +216 -0
- package/src/public/file-browser.js +610 -92
- package/src/public/file-find.js +526 -0
- package/src/public/generic-drop-handler.js +389 -0
- package/src/public/index.html +14 -1
- package/src/public/splits.js +7 -1
- package/src/server.js +980 -32
- package/src/terminal-bridge.js +219 -2
- package/src/utils/file-watcher.js +125 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-or-die",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.65",
|
|
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
|
|
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",
|
package/src/base-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|