@particle-academy/fancy-term-host 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1015 @@
1
+ import { FrameDecoder, PROTOCOL_VERSION, encodeFrame, readPidfile, pidfileUsable, deletePidfile, resolveHostScript } from './chunk-2DQJKTG5.js';
2
+ export { FrameDecoder, PROTOCOL_VERSION, deletePidfile, encodeFrame, isPidAlive, pidfilePath, pidfileUsable, readPidfile, resolveHostScript, socketPathFor, userHash, writePidfile } from './chunk-2DQJKTG5.js';
3
+ import { spawn } from 'node-pty';
4
+ import { EventEmitter } from 'events';
5
+ import fs2 from 'fs';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import crypto from 'crypto';
9
+ import zlib from 'zlib';
10
+ import net from 'net';
11
+ import { fileURLToPath } from 'url';
12
+
13
+ // src/osc7.ts
14
+ var OSC7_RE = /\x1b\]7;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
15
+ function parseFileUrl(payload) {
16
+ if (!payload.startsWith("file://")) return null;
17
+ let rest = payload.slice("file://".length);
18
+ const slash = rest.indexOf("/");
19
+ if (slash === -1) return null;
20
+ let pathPart = rest.slice(slash);
21
+ let decoded;
22
+ try {
23
+ decoded = decodeURIComponent(pathPart);
24
+ } catch {
25
+ decoded = pathPart;
26
+ }
27
+ const winDrive = /^\/([A-Za-z]):(.*)$/.exec(decoded);
28
+ if (winDrive) {
29
+ return `${winDrive[1]}:${winDrive[2]}`.replace(/\//g, "\\");
30
+ }
31
+ return decoded;
32
+ }
33
+ function scanOsc7Cwd(chunk) {
34
+ if (chunk.indexOf("\x1B]7;") === -1) return null;
35
+ let last = null;
36
+ OSC7_RE.lastIndex = 0;
37
+ let m;
38
+ while (m = OSC7_RE.exec(chunk)) {
39
+ const cwd = parseFileUrl(m[1]);
40
+ if (cwd) last = cwd;
41
+ }
42
+ return last;
43
+ }
44
+ function firstExisting(paths) {
45
+ for (const p of paths) {
46
+ try {
47
+ if (p && fs2.existsSync(p)) return p;
48
+ } catch {
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ function windowsCandidates() {
54
+ const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
55
+ const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
56
+ const localAppData = process.env.LOCALAPPDATA ?? "";
57
+ const systemRoot = process.env.SystemRoot ?? "C:\\Windows";
58
+ return [
59
+ {
60
+ id: "git-bash",
61
+ label: "Git Bash",
62
+ args: ["--login", "-i"],
63
+ paths: [
64
+ path.join(programFiles, "Git", "bin", "bash.exe"),
65
+ path.join(programFilesX86, "Git", "bin", "bash.exe"),
66
+ localAppData ? path.join(localAppData, "Programs", "Git", "bin", "bash.exe") : ""
67
+ ]
68
+ },
69
+ {
70
+ id: "pwsh",
71
+ label: "PowerShell 7",
72
+ args: ["-NoLogo"],
73
+ paths: [
74
+ path.join(programFiles, "PowerShell", "7", "pwsh.exe"),
75
+ localAppData ? path.join(localAppData, "Microsoft", "WindowsApps", "pwsh.exe") : ""
76
+ ]
77
+ },
78
+ {
79
+ id: "powershell",
80
+ label: "Windows PowerShell",
81
+ args: ["-NoLogo"],
82
+ paths: [
83
+ path.join(
84
+ systemRoot,
85
+ "System32",
86
+ "WindowsPowerShell",
87
+ "v1.0",
88
+ "powershell.exe"
89
+ )
90
+ ]
91
+ },
92
+ {
93
+ id: "cmd",
94
+ label: "Command Prompt",
95
+ args: [],
96
+ paths: [process.env.COMSPEC ?? path.join(systemRoot, "System32", "cmd.exe")]
97
+ },
98
+ {
99
+ id: "wsl",
100
+ label: "WSL",
101
+ args: [],
102
+ paths: [path.join(systemRoot, "System32", "wsl.exe")]
103
+ }
104
+ ];
105
+ }
106
+ function unixCandidates() {
107
+ return [
108
+ {
109
+ id: "zsh",
110
+ label: "zsh",
111
+ args: ["-l"],
112
+ paths: ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh", "/opt/homebrew/bin/zsh"]
113
+ },
114
+ {
115
+ id: "bash",
116
+ label: "bash",
117
+ args: ["-l"],
118
+ paths: ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
119
+ },
120
+ {
121
+ id: "fish",
122
+ label: "fish",
123
+ args: ["-l"],
124
+ paths: ["/usr/bin/fish", "/usr/local/bin/fish", "/opt/homebrew/bin/fish"]
125
+ }
126
+ ];
127
+ }
128
+ function detectShells() {
129
+ const candidates = process.platform === "win32" ? windowsCandidates() : unixCandidates();
130
+ const found = [];
131
+ for (const c of candidates) {
132
+ const command = firstExisting(c.paths);
133
+ if (command) found.push({ id: c.id, label: c.label, command, args: c.args });
134
+ }
135
+ if (process.platform !== "win32") {
136
+ const login = process.env.SHELL;
137
+ if (login && !found.some((s) => s.command === login) && fs2.existsSync(login)) {
138
+ found.unshift({
139
+ id: path.basename(login),
140
+ label: path.basename(login),
141
+ command: login,
142
+ args: ["-l"]
143
+ });
144
+ }
145
+ }
146
+ return found;
147
+ }
148
+ function defaultShellId(detected) {
149
+ const order = process.platform === "win32" ? ["git-bash", "pwsh", "powershell", "cmd"] : detected.map((s) => s.id);
150
+ for (const id of order) {
151
+ if (detected.some((s) => s.id === id)) return id;
152
+ }
153
+ return detected[0]?.id ?? null;
154
+ }
155
+ function parseCommandLine(line) {
156
+ const trimmed = line.trim();
157
+ if (!trimmed) return { command: "", args: [] };
158
+ const tokens = [];
159
+ const re = /"([^"]*)"|(\S+)/g;
160
+ let m;
161
+ while (m = re.exec(trimmed)) tokens.push(m[1] ?? m[2]);
162
+ return { command: tokens[0] ?? "", args: tokens.slice(1) };
163
+ }
164
+ function shellKind(command) {
165
+ const base = path.basename(command).toLowerCase();
166
+ if (base.includes("pwsh") || base.includes("powershell")) return "powershell";
167
+ if (base.startsWith("zsh")) return "zsh";
168
+ if (base.startsWith("bash")) return "bash";
169
+ if (base.startsWith("fish")) return "fish";
170
+ if (base.startsWith("cmd")) return "cmd";
171
+ return "other";
172
+ }
173
+ function hookDir() {
174
+ const seed = `${os.userInfo().username}|${os.hostname()}`;
175
+ const hash = crypto.createHash("sha1").update(seed).digest("hex").slice(0, 12);
176
+ const dir = path.join(os.tmpdir(), "fancy-term-host", hash);
177
+ fs2.mkdirSync(dir, { recursive: true });
178
+ return dir;
179
+ }
180
+ function writeShim(file, contents) {
181
+ try {
182
+ fs2.writeFileSync(file, contents, "utf8");
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+ function cwdHookSpawn(command, settings) {
189
+ const empty = { env: {}, args: [] };
190
+ if (settings.get("track_cwd") === "off") return empty;
191
+ const kind = shellKind(command);
192
+ const host = os.hostname();
193
+ if (kind === "bash") {
194
+ const emit = `printf '\\033]7;file://${host}%s\\033\\\\' "$PWD"`;
195
+ const prev = process.env.PROMPT_COMMAND ? "; " + process.env.PROMPT_COMMAND : "";
196
+ return { env: { PROMPT_COMMAND: `${emit}${prev}` }, args: [] };
197
+ }
198
+ if (kind === "zsh") {
199
+ const orig = process.env.ZDOTDIR || os.homedir();
200
+ const dir = hookDir();
201
+ const okEnv = writeShim(
202
+ path.join(dir, ".zshenv"),
203
+ `# fancy-term-host (generated)
204
+ [ -f "${orig}/.zshenv" ] && source "${orig}/.zshenv"
205
+ `
206
+ );
207
+ const okRc = writeShim(
208
+ path.join(dir, ".zshrc"),
209
+ `# fancy-term-host (generated)
210
+ ZDOTDIR="${orig}"
211
+ [ -f "${orig}/.zshrc" ] && source "${orig}/.zshrc"
212
+ __fth_osc7() { printf '\\033]7;file://%s\\033\\\\' "$PWD" }
213
+ typeset -ga precmd_functions
214
+ precmd_functions+=(__fth_osc7)
215
+ `
216
+ );
217
+ return okEnv && okRc ? { env: { ZDOTDIR: dir }, args: [] } : empty;
218
+ }
219
+ if (kind === "fish") {
220
+ const dir = hookDir();
221
+ const confDir = path.join(dir, "fish", "vendor_conf.d");
222
+ try {
223
+ fs2.mkdirSync(confDir, { recursive: true });
224
+ } catch {
225
+ return empty;
226
+ }
227
+ const ok = writeShim(
228
+ path.join(confDir, "osc7.fish"),
229
+ `# fancy-term-host (generated)
230
+ function __fth_osc7 --on-event fish_prompt
231
+ printf '\\x1b]7;file://%s\\x1b\\\\' "$PWD"
232
+ end
233
+ `
234
+ );
235
+ if (!ok) return empty;
236
+ const existing = process.env.XDG_DATA_DIRS || "/usr/local/share:/usr/share";
237
+ return { env: { XDG_DATA_DIRS: `${dir}${path.delimiter}${existing}` }, args: [] };
238
+ }
239
+ if (kind === "powershell") {
240
+ const dir = hookDir();
241
+ const shim = path.join(dir, "osc7-profile.ps1");
242
+ const ok = writeShim(
243
+ shim,
244
+ `# fancy-term-host (generated)
245
+ $global:__fthPrev = $function:prompt
246
+ function global:prompt {
247
+ $p = ($PWD.ProviderPath -replace '\\\\','/')
248
+ [Console]::Write("$([char]27)]7;file:///$p$([char]27)\\")
249
+ if ($global:__fthPrev) { & $global:__fthPrev } else { "PS $($PWD.ProviderPath)> " }
250
+ }
251
+ `
252
+ );
253
+ if (!ok) return empty;
254
+ return { env: {}, args: ["-NoExit", "-Command", `. '${shim}'`] };
255
+ }
256
+ if (kind === "cmd") {
257
+ return { env: { PROMPT: `$E]7;file:///$P$E\\$P$G` }, args: [] };
258
+ }
259
+ return empty;
260
+ }
261
+ function cwdHookEnv(command, settings) {
262
+ return cwdHookSpawn(command, settings).env;
263
+ }
264
+ function resolveDefaultShell(settings) {
265
+ const detected = detectShells();
266
+ const terminalShell = settings.get("terminal_shell");
267
+ if (terminalShell === "custom") {
268
+ const parsed = parseCommandLine(settings.get("terminal_custom_cmd") ?? "");
269
+ if (parsed.command) return parsed;
270
+ }
271
+ const pick = detected.find((s) => s.id === terminalShell) ?? detected.find((s) => s.id === defaultShellId(detected));
272
+ if (pick) return { command: pick.command, args: pick.args };
273
+ if (process.platform === "win32") {
274
+ return { command: process.env.COMSPEC ?? "cmd.exe", args: [] };
275
+ }
276
+ return { command: process.env.SHELL ?? "/bin/bash", args: [] };
277
+ }
278
+
279
+ // src/manager.ts
280
+ var SCROLLBACK_MAX = 1e6;
281
+ var CWD_PERSIST_DEBOUNCE_MS = 750;
282
+ var inertDeps = {
283
+ settings: { get: () => void 0 },
284
+ snapshots: {
285
+ writeSnapshot: () => null,
286
+ readSnapshot: () => null,
287
+ deleteSnapshot: () => {
288
+ }
289
+ }
290
+ };
291
+ var InProcessBackend = class extends EventEmitter {
292
+ constructor(deps2 = inertDeps) {
293
+ super();
294
+ this.deps = deps2;
295
+ this.ptys = /* @__PURE__ */ new Map();
296
+ this.scrollback = /* @__PURE__ */ new Map();
297
+ this.shells = /* @__PURE__ */ new Map();
298
+ /** Last cwd reported by each pty via OSC-7 (in-memory, authoritative). */
299
+ this.liveCwd = /* @__PURE__ */ new Map();
300
+ /** Pending debounced cwd-persist timers, keyed by terminal id. */
301
+ this.cwdTimers = /* @__PURE__ */ new Map();
302
+ /**
303
+ * Tier 2: ids that must keep their pty alive even with zero attached
304
+ * windows (a disabled-but-retained terminal — e.g. a dev server the user
305
+ * suspended). The IPC layer consults this in detachOwner: a retained id is
306
+ * left running on the last detach instead of killed, so re-enable reattaches
307
+ * to the LIVE session (scrollback replays) rather than spawning fresh.
308
+ * Insertion order is preserved so the cap can evict the oldest if needed.
309
+ */
310
+ this.retained = /* @__PURE__ */ new Set();
311
+ }
312
+ /** Swap injected deps (only used if configure lands after lazy construction). */
313
+ setDeps(deps2) {
314
+ this.deps = deps2;
315
+ }
316
+ /**
317
+ * Spawn a new pty for the given id, OR return the existing one if a
318
+ * window has already attached. Idempotent: a Stage window can attach
319
+ * to a spec that TheFloor is already running and get the same live
320
+ * shell + a buffered scrollback to catch up.
321
+ */
322
+ create(opts) {
323
+ const existing = this.ptys.get(opts.id);
324
+ if (existing) {
325
+ return {
326
+ id: opts.id,
327
+ pid: existing.pid,
328
+ shell: this.shells.get(opts.id) ?? existing.process,
329
+ existing: true,
330
+ scrollback: this.scrollback.get(opts.id) ?? ""
331
+ };
332
+ }
333
+ const shell = opts.shell ?? defaultShell();
334
+ const baseArgs = opts.args ?? defaultShellArgs(shell);
335
+ const hook = cwdHookSpawn(shell, this.deps.settings);
336
+ const args = hook.args.length ? [...baseArgs, ...hook.args] : baseArgs;
337
+ const env = {
338
+ ...process.env,
339
+ ...hook.env,
340
+ ...opts.env ?? {}
341
+ };
342
+ env.TERM = env.TERM || "xterm-256color";
343
+ const pty = spawn(shell, args, {
344
+ name: "xterm-color",
345
+ cwd: opts.cwd,
346
+ cols: opts.cols ?? 80,
347
+ rows: opts.rows ?? 24,
348
+ env
349
+ });
350
+ this.ptys.set(opts.id, pty);
351
+ this.shells.set(opts.id, shell);
352
+ this.scrollback.set(opts.id, "");
353
+ pty.onData((data) => {
354
+ const buf = this.scrollback.get(opts.id) ?? "";
355
+ const next = buf + data;
356
+ this.scrollback.set(
357
+ opts.id,
358
+ next.length > SCROLLBACK_MAX ? next.slice(-SCROLLBACK_MAX) : next
359
+ );
360
+ const cwd = scanOsc7Cwd(data);
361
+ if (cwd && cwd !== this.liveCwd.get(opts.id)) {
362
+ this.liveCwd.set(opts.id, cwd);
363
+ this.scheduleCwdPersist(opts.id, cwd);
364
+ }
365
+ this.emit("data", opts.id, data);
366
+ });
367
+ pty.onExit(({ exitCode, signal }) => {
368
+ this.cleanupCwd(opts.id);
369
+ this.ptys.delete(opts.id);
370
+ this.scrollback.delete(opts.id);
371
+ this.shells.delete(opts.id);
372
+ this.retained.delete(opts.id);
373
+ this.emit("exit", opts.id, { exitCode, signal });
374
+ });
375
+ const snap = this.deps.snapshots.readSnapshot(opts.id);
376
+ return {
377
+ id: opts.id,
378
+ pid: pty.pid,
379
+ shell,
380
+ existing: false,
381
+ scrollback: "",
382
+ snapshot: snap ?? void 0
383
+ };
384
+ }
385
+ scheduleCwdPersist(id, cwd) {
386
+ const existing = this.cwdTimers.get(id);
387
+ if (existing) clearTimeout(existing);
388
+ const t = setTimeout(() => {
389
+ this.cwdTimers.delete(id);
390
+ this.emit("cwd", id, cwd);
391
+ }, CWD_PERSIST_DEBOUNCE_MS);
392
+ if (typeof t.unref === "function") t.unref();
393
+ this.cwdTimers.set(id, t);
394
+ }
395
+ cleanupCwd(id) {
396
+ const t = this.cwdTimers.get(id);
397
+ if (t) {
398
+ clearTimeout(t);
399
+ this.cwdTimers.delete(id);
400
+ }
401
+ const cwd = this.liveCwd.get(id);
402
+ if (cwd) this.emit("cwd", id, cwd);
403
+ this.liveCwd.delete(id);
404
+ }
405
+ /** Last cwd reported by this pty via OSC-7, or undefined when unknown. */
406
+ getLiveCwd(id) {
407
+ return this.liveCwd.get(id);
408
+ }
409
+ write(id, data) {
410
+ const pty = this.ptys.get(id);
411
+ if (!pty) return false;
412
+ pty.write(data);
413
+ return true;
414
+ }
415
+ resize(id, cols, rows) {
416
+ const pty = this.ptys.get(id);
417
+ if (!pty) return false;
418
+ pty.resize(Math.max(1, cols | 0), Math.max(1, rows | 0));
419
+ return true;
420
+ }
421
+ kill(id) {
422
+ const pty = this.ptys.get(id);
423
+ if (!pty) return false;
424
+ try {
425
+ pty.kill();
426
+ } catch {
427
+ }
428
+ this.cleanupCwd(id);
429
+ this.ptys.delete(id);
430
+ this.scrollback.delete(id);
431
+ this.shells.delete(id);
432
+ this.retained.delete(id);
433
+ return true;
434
+ }
435
+ killAll() {
436
+ for (const id of Array.from(this.ptys.keys())) {
437
+ this.kill(id);
438
+ }
439
+ }
440
+ list() {
441
+ return Array.from(this.ptys.entries()).map(([id, pty]) => ({
442
+ id,
443
+ pid: pty.pid,
444
+ shell: this.shells.get(id) ?? pty.process
445
+ }));
446
+ }
447
+ isLive(id) {
448
+ return this.ptys.has(id);
449
+ }
450
+ // --- Tier 2: retained-PTY (disabled-not-deleted) -----------------------
451
+ /**
452
+ * Mark/unmark a terminal as retained. A retained terminal's pty is kept
453
+ * alive by the IPC layer even when its last window detaches. Returns the
454
+ * resulting retained-id set size. Retaining a terminal that isn't live is
455
+ * harmless (the flag simply has no pty to protect yet).
456
+ */
457
+ setRetained(id, retained) {
458
+ if (retained) this.retained.add(id);
459
+ else this.retained.delete(id);
460
+ }
461
+ isRetained(id) {
462
+ return this.retained.has(id);
463
+ }
464
+ /** Number of currently-retained terminals (for the resource cap). */
465
+ retainedCount() {
466
+ return this.retained.size;
467
+ }
468
+ /** Snapshot of retained ids in insertion order (oldest first). */
469
+ retainedIds() {
470
+ return Array.from(this.retained);
471
+ }
472
+ /**
473
+ * Buffered scrollback for a live pty (raw ANSI text), or undefined when the
474
+ * id has no pty. Tier 2 uses this to serialize a windowless retained pty at
475
+ * quit so its post-disable output still lands in a snapshot (T2→T1 degrade).
476
+ */
477
+ getScrollback(id) {
478
+ return this.scrollback.get(id);
479
+ }
480
+ };
481
+ var configuredDeps = inertDeps;
482
+ function configureInProcessBackend(deps2) {
483
+ configuredDeps = deps2;
484
+ if (inProcess) inProcess.setDeps(deps2);
485
+ }
486
+ var inProcess = null;
487
+ function inProcessBackend() {
488
+ if (!inProcess) inProcess = new InProcessBackend(configuredDeps);
489
+ return inProcess;
490
+ }
491
+ var active = null;
492
+ function terminalManager() {
493
+ if (!active) active = inProcessBackend();
494
+ return active;
495
+ }
496
+ var eventHandlers = null;
497
+ var boundBackends = /* @__PURE__ */ new WeakSet();
498
+ function bindEvents(backend) {
499
+ if (!eventHandlers) return;
500
+ if (boundBackends.has(backend)) return;
501
+ boundBackends.add(backend);
502
+ backend.on("data", eventHandlers.onData);
503
+ backend.on("exit", eventHandlers.onExit);
504
+ }
505
+ function subscribeBackendEvents(handlers) {
506
+ eventHandlers = handlers;
507
+ bindEvents(terminalManager());
508
+ }
509
+ function setActiveBackend(backend) {
510
+ const next = backend ?? inProcessBackend();
511
+ if (next === active) return;
512
+ active = next;
513
+ bindEvents(active);
514
+ }
515
+ function defaultShell() {
516
+ if (process.platform === "win32") {
517
+ return process.env.COMSPEC ?? "cmd.exe";
518
+ }
519
+ return process.env.SHELL ?? "/bin/bash";
520
+ }
521
+ function defaultShellArgs(shell) {
522
+ const base = shell.toLowerCase();
523
+ if (base.endsWith("powershell.exe") || base.endsWith("pwsh.exe")) {
524
+ return ["-NoLogo"];
525
+ }
526
+ return [];
527
+ }
528
+ var MAX_SERIALIZED_BYTES = 256 * 1024;
529
+ var MAGIC_ENCRYPTED = 1;
530
+ var MAGIC_PLAINTEXT = 0;
531
+ var warnedNoEncryption = false;
532
+ function trimTail(serialized) {
533
+ const buf = Buffer.from(serialized, "utf8");
534
+ if (buf.length <= MAX_SERIALIZED_BYTES) return serialized;
535
+ return buf.subarray(buf.length - MAX_SERIALIZED_BYTES).toString("utf8");
536
+ }
537
+ function createSnapshotStore(config) {
538
+ const { encryptor } = config;
539
+ function sessionsDir() {
540
+ const dir = path.join(config.baseDir, "sessions");
541
+ if (!fs2.existsSync(dir)) fs2.mkdirSync(dir, { recursive: true });
542
+ return dir;
543
+ }
544
+ function snapPath(id) {
545
+ const safe = id.replace(/[^A-Za-z0-9_-]/g, "");
546
+ return path.join(sessionsDir(), `${safe}.snap`);
547
+ }
548
+ function encryptionAvailable() {
549
+ try {
550
+ return encryptor.isAvailable();
551
+ } catch {
552
+ return false;
553
+ }
554
+ }
555
+ function writeSnapshot(id, serialized) {
556
+ try {
557
+ if (!serialized) return null;
558
+ const trimmed = trimTail(serialized);
559
+ const gz = zlib.gzipSync(Buffer.from(trimmed, "utf8"));
560
+ let body;
561
+ let magic;
562
+ if (encryptionAvailable()) {
563
+ magic = MAGIC_ENCRYPTED;
564
+ body = encryptor.encrypt(Buffer.from(gz.toString("base64"), "utf8"));
565
+ } else {
566
+ if (!warnedNoEncryption) {
567
+ warnedNoEncryption = true;
568
+ console.warn(
569
+ "[sessions] OS encryption unavailable \u2014 writing terminal snapshots as plaintext gzip. Install libsecret/gnome-keyring on Linux to encrypt them at rest."
570
+ );
571
+ }
572
+ magic = MAGIC_PLAINTEXT;
573
+ body = gz;
574
+ }
575
+ const out = Buffer.concat([Buffer.from([magic]), body]);
576
+ const target = snapPath(id);
577
+ const tmp = `${target}.tmp`;
578
+ fs2.writeFileSync(tmp, out);
579
+ fs2.renameSync(tmp, target);
580
+ return out.length;
581
+ } catch {
582
+ return null;
583
+ }
584
+ }
585
+ function readSnapshot(id) {
586
+ try {
587
+ const file = snapPath(id);
588
+ const stat = fs2.statSync(file);
589
+ const raw = fs2.readFileSync(file);
590
+ if (raw.length < 2) return null;
591
+ const magic = raw[0];
592
+ const body = raw.subarray(1);
593
+ let gz;
594
+ if (magic === MAGIC_ENCRYPTED) {
595
+ if (!encryptionAvailable()) return null;
596
+ const b64 = encryptor.decrypt(body).toString("utf8");
597
+ gz = Buffer.from(b64, "base64");
598
+ } else if (magic === MAGIC_PLAINTEXT) {
599
+ gz = body;
600
+ } else {
601
+ return null;
602
+ }
603
+ const serialized = zlib.gunzipSync(gz).toString("utf8");
604
+ return { serialized, savedAt: stat.mtimeMs };
605
+ } catch {
606
+ return null;
607
+ }
608
+ }
609
+ function deleteSnapshot(id) {
610
+ try {
611
+ fs2.rmSync(snapPath(id), { force: true });
612
+ } catch {
613
+ }
614
+ }
615
+ return { writeSnapshot, readSnapshot, deleteSnapshot };
616
+ }
617
+ var SCROLLBACK_MAX2 = 1e6;
618
+ var HostClient = class _HostClient extends EventEmitter {
619
+ constructor(socketPath, snapshots) {
620
+ super();
621
+ this.socketPath = socketPath;
622
+ this.snapshots = snapshots;
623
+ this.socket = null;
624
+ this.decoder = new FrameDecoder();
625
+ this.seq = 0;
626
+ this.pending = /* @__PURE__ */ new Map();
627
+ this.mirror = /* @__PURE__ */ new Map();
628
+ this.retained = /* @__PURE__ */ new Set();
629
+ /** Host pid, learned from hello-ok — surfaced for diagnostics. */
630
+ this.hostPid = 0;
631
+ this.connected = false;
632
+ }
633
+ /**
634
+ * Connect to a running host at `socketPath`, perform the version handshake,
635
+ * and seed the local mirror from the host's live ptys (list + per-pty
636
+ * scrollback). Resolves to a ready client, or rejects on connect failure /
637
+ * version mismatch / timeout — the caller then falls back to in-process.
638
+ *
639
+ * `snapshots` is the injected snapshot store used by cold-create to surface
640
+ * any on-disk previous-session snapshot (was a direct `./sessions` import).
641
+ */
642
+ static connect(socketPath, snapshots, timeoutMs = 3e3) {
643
+ return new Promise((resolve, reject) => {
644
+ const client2 = new _HostClient(socketPath, snapshots);
645
+ const sock = net.createConnection(socketPath);
646
+ let settled = false;
647
+ const timer = setTimeout(() => {
648
+ if (settled) return;
649
+ settled = true;
650
+ try {
651
+ sock.destroy();
652
+ } catch {
653
+ }
654
+ reject(new Error("pty-host connect timeout"));
655
+ }, timeoutMs);
656
+ sock.on("error", (err) => {
657
+ if (settled) {
658
+ client2.handleSocketError(err);
659
+ return;
660
+ }
661
+ settled = true;
662
+ clearTimeout(timer);
663
+ reject(err);
664
+ });
665
+ sock.once("connect", async () => {
666
+ client2.socket = sock;
667
+ client2.wireSocket(sock);
668
+ try {
669
+ const hello = await client2.request({
670
+ kind: "hello",
671
+ seq: client2.nextSeq(),
672
+ protocolVersion: PROTOCOL_VERSION
673
+ });
674
+ if (hello.protocolVersion !== PROTOCOL_VERSION) {
675
+ throw new Error(
676
+ `pty-host protocol mismatch: host=${hello.protocolVersion} client=${PROTOCOL_VERSION}`
677
+ );
678
+ }
679
+ client2.hostPid = hello.pid;
680
+ client2.connected = true;
681
+ await client2.seedFromHost();
682
+ settled = true;
683
+ clearTimeout(timer);
684
+ resolve(client2);
685
+ } catch (err) {
686
+ settled = true;
687
+ clearTimeout(timer);
688
+ try {
689
+ sock.destroy();
690
+ } catch {
691
+ }
692
+ reject(err);
693
+ }
694
+ });
695
+ });
696
+ }
697
+ wireSocket(sock) {
698
+ sock.on("data", (chunk) => {
699
+ const frames = this.decoder.push(chunk);
700
+ if (this.decoder.desynced) {
701
+ this.handleSocketError(new Error("pty-host stream desync"));
702
+ return;
703
+ }
704
+ for (const frame of frames) this.handleHostMessage(frame);
705
+ });
706
+ sock.on("close", () => {
707
+ if (this.connected) {
708
+ this.connected = false;
709
+ this.emit("error", new Error("pty-host connection closed"));
710
+ }
711
+ });
712
+ }
713
+ handleSocketError(err) {
714
+ if (!this.connected) return;
715
+ this.connected = false;
716
+ this.emit("error", err);
717
+ }
718
+ handleHostMessage(msg) {
719
+ switch (msg.kind) {
720
+ case "data": {
721
+ const entry = this.mirror.get(msg.id);
722
+ if (entry) {
723
+ const next = entry.scrollback + msg.data;
724
+ entry.scrollback = next.length > SCROLLBACK_MAX2 ? next.slice(-SCROLLBACK_MAX2) : next;
725
+ }
726
+ this.emit("data", msg.id, msg.data);
727
+ return;
728
+ }
729
+ case "exit": {
730
+ this.mirror.delete(msg.id);
731
+ this.retained.delete(msg.id);
732
+ this.emit("exit", msg.id, {
733
+ exitCode: msg.exitCode,
734
+ signal: msg.signal
735
+ });
736
+ return;
737
+ }
738
+ default: {
739
+ const seq = msg.seq;
740
+ if (seq != null) {
741
+ const resolver = this.pending.get(seq);
742
+ if (resolver) {
743
+ this.pending.delete(seq);
744
+ resolver(msg);
745
+ }
746
+ }
747
+ }
748
+ }
749
+ }
750
+ nextSeq() {
751
+ return ++this.seq;
752
+ }
753
+ /** Send a request and await the correlated reply. */
754
+ request(msg) {
755
+ return new Promise((resolve, reject) => {
756
+ if (!this.socket) {
757
+ reject(new Error("pty-host not connected"));
758
+ return;
759
+ }
760
+ this.pending.set(msg.seq, resolve);
761
+ try {
762
+ this.socket.write(encodeFrame(msg));
763
+ } catch (err) {
764
+ this.pending.delete(msg.seq);
765
+ reject(err);
766
+ }
767
+ });
768
+ }
769
+ /** Fire-and-forget send for messages with no reply (write/resize/kill/…). */
770
+ send(msg) {
771
+ if (!this.socket) return;
772
+ try {
773
+ this.socket.write(encodeFrame(msg));
774
+ } catch {
775
+ }
776
+ }
777
+ /** Seed the local mirror from the host's live ptys after a (re)connect. */
778
+ async seedFromHost() {
779
+ const listed = await this.request({
780
+ kind: "list",
781
+ seq: this.nextSeq()
782
+ });
783
+ for (const t of listed.terminals) {
784
+ const sb = await this.request({
785
+ kind: "get-scrollback",
786
+ seq: this.nextSeq(),
787
+ id: t.id
788
+ });
789
+ this.mirror.set(t.id, {
790
+ pid: t.pid,
791
+ shell: t.shell,
792
+ scrollback: sb.scrollback ?? ""
793
+ });
794
+ }
795
+ }
796
+ /** Ids the host currently has live — used by the lifecycle layer to drive
797
+ * the reattach (renderer remounts these specs, replaying host scrollback). */
798
+ liveIds() {
799
+ return Array.from(this.mirror.keys());
800
+ }
801
+ isConnected() {
802
+ return this.connected;
803
+ }
804
+ /** Disconnect WITHOUT killing host ptys (before-quit leave-running). */
805
+ disconnect() {
806
+ this.connected = false;
807
+ if (this.socket) {
808
+ try {
809
+ this.socket.end();
810
+ } catch {
811
+ }
812
+ this.socket = null;
813
+ }
814
+ }
815
+ // --- PtyBackend ---------------------------------------------------------
816
+ create(opts) {
817
+ const existing = this.mirror.get(opts.id);
818
+ if (existing) {
819
+ return {
820
+ id: opts.id,
821
+ pid: existing.pid,
822
+ shell: existing.shell,
823
+ existing: true,
824
+ scrollback: existing.scrollback
825
+ };
826
+ }
827
+ this.mirror.set(opts.id, { pid: 0, shell: opts.shell ?? "", scrollback: "" });
828
+ this.request({ kind: "create", seq: this.nextSeq(), opts }).then((reply) => {
829
+ if (reply.kind !== "created") return;
830
+ const entry = this.mirror.get(opts.id);
831
+ if (entry) {
832
+ entry.pid = reply.result.pid;
833
+ entry.shell = reply.result.shell;
834
+ }
835
+ }).catch(() => {
836
+ });
837
+ const snap = this.snapshots.readSnapshot(opts.id);
838
+ return {
839
+ id: opts.id,
840
+ pid: 0,
841
+ shell: opts.shell ?? "",
842
+ existing: false,
843
+ scrollback: "",
844
+ snapshot: snap ?? void 0
845
+ };
846
+ }
847
+ write(id, data) {
848
+ if (!this.mirror.has(id)) return false;
849
+ this.send({ kind: "write", id, data });
850
+ return true;
851
+ }
852
+ resize(id, cols, rows) {
853
+ if (!this.mirror.has(id)) return false;
854
+ this.send({
855
+ kind: "resize",
856
+ id,
857
+ cols: Math.max(1, cols | 0),
858
+ rows: Math.max(1, rows | 0)
859
+ });
860
+ return true;
861
+ }
862
+ kill(id) {
863
+ const had = this.mirror.delete(id);
864
+ this.retained.delete(id);
865
+ this.send({ kind: "kill", id });
866
+ return had;
867
+ }
868
+ /**
869
+ * NO-OP for the host backend. The whole point of Tier 3 is that ptys survive
870
+ * a full quit, so the before-quit teardown must NOT kill them. The lifecycle
871
+ * layer disconnects the client and leaves the host running instead.
872
+ */
873
+ killAll() {
874
+ }
875
+ list() {
876
+ return Array.from(this.mirror.entries()).map(([id, e]) => ({
877
+ id,
878
+ pid: e.pid,
879
+ shell: e.shell
880
+ }));
881
+ }
882
+ isLive(id) {
883
+ return this.mirror.has(id);
884
+ }
885
+ setRetained(id, retained) {
886
+ if (retained) this.retained.add(id);
887
+ else this.retained.delete(id);
888
+ this.send({ kind: "set-retained", id, retained });
889
+ }
890
+ isRetained(id) {
891
+ return this.retained.has(id);
892
+ }
893
+ retainedCount() {
894
+ return this.retained.size;
895
+ }
896
+ retainedIds() {
897
+ return Array.from(this.retained);
898
+ }
899
+ getScrollback(id) {
900
+ return this.mirror.get(id)?.scrollback;
901
+ }
902
+ };
903
+
904
+ // src/host-lifecycle.ts
905
+ var deps = null;
906
+ function configureHostLifecycle(d) {
907
+ deps = d;
908
+ }
909
+ var client = null;
910
+ var usingHost = false;
911
+ function status(message, level = "warn") {
912
+ deps?.onHostStatus({ message, level });
913
+ }
914
+ function detachedEnabled() {
915
+ try {
916
+ return deps?.settings.get("detached_terminals") === "on";
917
+ } catch {
918
+ return false;
919
+ }
920
+ }
921
+ function isHostBacked() {
922
+ return usingHost && !!client && client.isConnected();
923
+ }
924
+ function getHostClient() {
925
+ return client;
926
+ }
927
+ async function awaitUsableHost(userData, timeoutMs = 4e3) {
928
+ const deadline = Date.now() + timeoutMs;
929
+ while (Date.now() < deadline) {
930
+ const pf = readPidfile(userData);
931
+ if (pidfileUsable(pf)) return true;
932
+ await new Promise((r) => setTimeout(r, 100));
933
+ }
934
+ return false;
935
+ }
936
+ async function initTerminalBackend() {
937
+ setActiveBackend(inProcessBackend());
938
+ if (!deps || !detachedEnabled()) {
939
+ return { host: false, reattachIds: [] };
940
+ }
941
+ const { spawner, snapshots } = deps;
942
+ const userData = spawner.userDataDir();
943
+ try {
944
+ const hostScript = spawner.resolveHostScript();
945
+ let pf = readPidfile(userData);
946
+ if (!pidfileUsable(pf)) {
947
+ deletePidfile(userData);
948
+ if (!hostScript) {
949
+ status(
950
+ "Detached terminals unavailable (host not found) \u2014 using in-process. Sessions won't survive a full quit."
951
+ );
952
+ return { host: false, reattachIds: [] };
953
+ }
954
+ spawner.spawnDetached(hostScript, { GENIE_USERDATA: userData });
955
+ const up = await awaitUsableHost(userData);
956
+ if (!up) {
957
+ status(
958
+ "Detached terminals unavailable (host didn't start) \u2014 using in-process. Sessions won't survive a full quit."
959
+ );
960
+ return { host: false, reattachIds: [] };
961
+ }
962
+ pf = readPidfile(userData);
963
+ }
964
+ if (!pf) {
965
+ status(
966
+ "Detached terminals unavailable \u2014 using in-process. Sessions won't survive a full quit."
967
+ );
968
+ return { host: false, reattachIds: [] };
969
+ }
970
+ client = await HostClient.connect(pf.socketPath, snapshots);
971
+ client.on("error", onHostError);
972
+ setActiveBackend(client);
973
+ usingHost = true;
974
+ return { host: true, reattachIds: client.liveIds() };
975
+ } catch (err) {
976
+ console.error("[host-lifecycle] falling back to in-process:", err);
977
+ try {
978
+ client?.disconnect();
979
+ } catch {
980
+ }
981
+ client = null;
982
+ usingHost = false;
983
+ setActiveBackend(inProcessBackend());
984
+ status(
985
+ "Detached terminals unavailable \u2014 using in-process. Sessions won't survive a full quit."
986
+ );
987
+ return { host: false, reattachIds: [] };
988
+ }
989
+ }
990
+ function onHostError(err) {
991
+ if (!usingHost) return;
992
+ console.error("[host-lifecycle] host connection lost:", err.message);
993
+ usingHost = false;
994
+ client = null;
995
+ setActiveBackend(inProcessBackend());
996
+ status(
997
+ "Detached terminal host stopped \u2014 switched to in-process. Open terminals may need reopening."
998
+ );
999
+ }
1000
+ function disconnectHostLeaveRunning() {
1001
+ if (client) {
1002
+ try {
1003
+ client.disconnect();
1004
+ } catch {
1005
+ }
1006
+ }
1007
+ }
1008
+ function ptyHostScriptPath() {
1009
+ const here = typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url));
1010
+ return resolveHostScript(here) ?? path.join(here, "pty-host.js");
1011
+ }
1012
+
1013
+ export { HostClient, configureHostLifecycle, configureInProcessBackend, createSnapshotStore, cwdHookEnv, cwdHookSpawn, defaultShell, defaultShellId, detectShells, disconnectHostLeaveRunning, getHostClient, inProcessBackend, initTerminalBackend, isHostBacked, parseCommandLine, parseFileUrl, ptyHostScriptPath, resolveDefaultShell, scanOsc7Cwd, setActiveBackend, shellKind, subscribeBackendEvents, terminalManager };
1014
+ //# sourceMappingURL=index.js.map
1015
+ //# sourceMappingURL=index.js.map