@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.
@@ -0,0 +1,777 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ /**
4
+ * Ports — the injected interfaces that keep the terminal CORE runtime-agnostic.
5
+ *
6
+ * The terminal core (manager / sessions / shells / host-lifecycle / host-client)
7
+ * must never import `electron` or `../db`. Instead it receives these small ports
8
+ * and EMITS events for the things that used to be direct DB writes. Genie's
9
+ * adapter (genie-adapter.ts + ipc.ts) is the ONE place that builds the Electron /
10
+ * SQLite implementations and subscribes to the events to persist + broadcast.
11
+ *
12
+ * This is Phase 1 of the @particle-academy/fancy-term-host extraction (see
13
+ * .ai/_discovery/fancy-term-host-extraction.md §3): inverting the coupling so the
14
+ * core can be lifted into a package near-mechanically. The shapes here match the
15
+ * brief's §3 interfaces verbatim.
16
+ */
17
+ /**
18
+ * Read-only settings access. Replaces the core's old `getAllSettings()` reach
19
+ * into `../db`. The adapter implements `{ get: k => getAllSettings()[k] }`.
20
+ */
21
+ interface SettingsProvider {
22
+ get(key: string): string | undefined;
23
+ }
24
+ /**
25
+ * At-rest cipher for snapshot bytes (T1). The adapter wraps Electron
26
+ * `safeStorage`; a plain-node consumer could pass a passthrough or libsodium
27
+ * implementation. `isAvailable()` mirrors `safeStorage.isEncryptionAvailable()`
28
+ * so the core can still take the plaintext-magic fallback when encryption is
29
+ * unavailable, exactly as before.
30
+ */
31
+ interface Encryptor {
32
+ isAvailable(): boolean;
33
+ encrypt(b: Buffer): Buffer;
34
+ decrypt(b: Buffer): Buffer;
35
+ }
36
+ /**
37
+ * Everything the snapshot store (T1) needs that used to come from `electron`:
38
+ * the base directory (was `app.getPath('userData')`) and the cipher (was
39
+ * `safeStorage`). The store appends `/sessions` under `baseDir` itself, matching
40
+ * the historical on-disk path.
41
+ */
42
+ interface SnapshotStoreConfig {
43
+ baseDir: string;
44
+ encryptor: Encryptor;
45
+ }
46
+ /**
47
+ * The detached-host spawn surface (T3). The connect-or-spawn-or-fallback LOGIC
48
+ * is core; only these three OS/Electron-specific operations are injected:
49
+ * - resolveHostScript(): dev vs asar.unpacked path resolution.
50
+ * - spawnDetached(): the ABI-correct exec (Electron: execPath +
51
+ * ELECTRON_RUN_AS_NODE), detached + unref.
52
+ * - userDataDir(): the directory pidfile/socket live under (was
53
+ * app.getPath('userData')).
54
+ */
55
+ interface HostSpawner {
56
+ resolveHostScript(): string | null;
57
+ spawnDetached(scriptPath: string, env: Record<string, string>): void;
58
+ userDataDir(): string;
59
+ }
60
+
61
+ /**
62
+ * Shared terminal-subsystem types.
63
+ *
64
+ * Lifted out of manager.ts so both the in-process manager and the Tier 3
65
+ * HostClient (which proxies the same shapes over a socket) can reference them
66
+ * without a circular import through the PtyBackend interface.
67
+ */
68
+ interface CreateTerminalOpts {
69
+ /** Stable id chosen by the renderer (ulid). The manager uses it as the key. */
70
+ id: string;
71
+ /** Working directory for the spawned shell. */
72
+ cwd: string;
73
+ /** Shell executable. Defaults to the user's preferred login shell per platform. */
74
+ shell?: string;
75
+ /** Extra args for the shell. */
76
+ args?: string[];
77
+ /** Initial cols × rows. Renderer should send a `resize` immediately after mount. */
78
+ cols?: number;
79
+ rows?: number;
80
+ /** Extra env overrides. Merged on top of process.env. */
81
+ env?: Record<string, string>;
82
+ }
83
+ interface TerminalInfo {
84
+ id: string;
85
+ pid: number;
86
+ shell: string;
87
+ }
88
+ interface AttachResult extends TerminalInfo {
89
+ /** True when an existing pty was returned (caller is "joining"). */
90
+ existing: boolean;
91
+ /** Bounded scrollback so a late-joining window can replay history. */
92
+ scrollback: string;
93
+ /**
94
+ * A previous-session snapshot to replay, present ONLY on a COLD spawn that
95
+ * found a snapshot on disk. On a warm reattach this is omitted — the live
96
+ * scrollback already covers the history. The renderer frames it as
97
+ * "— previous session —" then resets before the fresh shell.
98
+ */
99
+ snapshot?: {
100
+ serialized: string;
101
+ savedAt: number;
102
+ };
103
+ }
104
+
105
+ /**
106
+ * PtyBackend — the abstraction the IPC layer (main/terminal/ipc.ts), Tier 1
107
+ * (snapshots) and Tier 2 (retained set) talk to, instead of talking to a
108
+ * concrete pty pool directly.
109
+ *
110
+ * Two implementations:
111
+ *
112
+ * • InProcessBackend (the TerminalManager singleton) — node-pty instances live
113
+ * IN the Electron main process. This is today's behaviour and the T1/T2
114
+ * floor: snapshots survive a full quit, retained ptys survive a window
115
+ * detach (but die on a real quit, degrading to T1 snapshots).
116
+ *
117
+ * • HostClient (Tier 3) — node-pty instances live in a DETACHED headless
118
+ * pty-host process. The backend proxies every call over a local socket; the
119
+ * ptys (and the host's own scrollback ring buffer) SURVIVE A FULL QUIT of
120
+ * the Electron app, so reopening reattaches to the still-running shells.
121
+ *
122
+ * ipc.ts is oblivious to which one it holds: same method names, same shapes,
123
+ * same data/exit event subscription. The active backend is chosen once at
124
+ * startup (selectBackend, see manager.ts) and swapped behind this interface.
125
+ *
126
+ * The data/exit subscription mirrors EventEmitter semantics so the InProcess
127
+ * backend can BE an EventEmitter and satisfy this for free, while HostClient
128
+ * fans host-pushed messages out to the same callback shape.
129
+ */
130
+ interface PtyBackend {
131
+ /** Spawn (or rejoin an existing) pty for opts.id. */
132
+ create(opts: CreateTerminalOpts): AttachResult;
133
+ write(id: string, data: string): boolean;
134
+ resize(id: string, cols: number, rows: number): boolean;
135
+ /** Explicit kill (user delete). Clears retained flag + scrollback. */
136
+ kill(id: string): boolean;
137
+ /** Tear down every pty. Called on a real quit for the in-process backend;
138
+ * a NO-OP for the host client (the whole point of T3 is they survive). */
139
+ killAll(): void;
140
+ list(): TerminalInfo[];
141
+ isLive(id: string): boolean;
142
+ setRetained(id: string, retained: boolean): void;
143
+ isRetained(id: string): boolean;
144
+ retainedCount(): number;
145
+ retainedIds(): string[];
146
+ getScrollback(id: string): string | undefined;
147
+ /** Subscribe to pushed pty output. */
148
+ on(event: 'data', listener: (id: string, data: string) => void): this;
149
+ /** Subscribe to pty exit. */
150
+ on(event: 'exit', listener: (id: string, payload: {
151
+ exitCode: number;
152
+ signal?: number;
153
+ }) => void): this;
154
+ /**
155
+ * Subscribe to live-cwd changes learned from OSC-7 (debounced). Replaces the
156
+ * core's old direct `updateTerminalSpec({ live_cwd })` write — the adapter
157
+ * subscribes here and persists. The in-process backend emits this; the host
158
+ * client simply never fires it (the detached host tracks cwd on its own side
159
+ * and the client doesn't observe OSC-7), so a no-op subscription is harmless.
160
+ */
161
+ on(event: 'cwd', listener: (id: string, cwd: string) => void): this;
162
+ }
163
+ /**
164
+ * The typed core event surface (brief §3). The in-process backend EMITS these;
165
+ * Genie's adapter SUBSCRIBES and persists/broadcasts:
166
+ * - 'data' (id, data) — pushed pty output (unchanged)
167
+ * - 'exit' (id, { exitCode, signal }) — pty exit (unchanged)
168
+ * - 'cwd' (id, cwd) — was updateTerminalSpec({live_cwd})
169
+ * - 'snapshot' (id, bytes, at) — was the snapshot-pointer write in ipc
170
+ * - 'host-status' (status) — was the BrowserWindow fallback toast
171
+ *
172
+ * `snapshot` is emitted by the snapshot persistence path and `host-status` by
173
+ * the host lifecycle; both are surfaced here as the contract a packaged core
174
+ * exposes. In Genie's Phase-1 adapter the snapshot write + toast broadcast still
175
+ * originate in the adapter (ipc.ts / the injected host-status sink), so these two
176
+ * are documented rather than carried on PtyBackend itself.
177
+ */
178
+ interface HostStatus {
179
+ message: string;
180
+ level: 'info' | 'warn';
181
+ }
182
+
183
+ interface SnapshotRead {
184
+ serialized: string;
185
+ /** Epoch ms the file was last written (from its mtime). */
186
+ savedAt: number;
187
+ }
188
+ /**
189
+ * The snapshot persistence surface (T1). Returned by createSnapshotStore so the
190
+ * core can read/write/delete snapshots without knowing where the bytes live or
191
+ * how they're encrypted.
192
+ */
193
+ interface SnapshotStore {
194
+ /**
195
+ * Persist a snapshot for `id`. Returns the on-disk byte size (so the caller
196
+ * can record `snapshot_bytes`), or null when nothing was written (empty
197
+ * input or an I/O error — never throws).
198
+ */
199
+ writeSnapshot(id: string, serialized: string): number | null;
200
+ /**
201
+ * Read a snapshot for `id`, or null when absent / unreadable / corrupt.
202
+ * Never throws.
203
+ */
204
+ readSnapshot(id: string): SnapshotRead | null;
205
+ /** Best-effort delete. Never throws; a missing file is success. */
206
+ deleteSnapshot(id: string): void;
207
+ }
208
+ /**
209
+ * Build a SnapshotStore bound to the given base directory + Encryptor. All the
210
+ * gzip / trim / `.plain`-fallback / read-tolerates-corrupt logic is UNCHANGED
211
+ * from the original module — only `app.getPath` → `config.baseDir` and
212
+ * `safeStorage.*` → `config.encryptor.*`.
213
+ */
214
+ declare function createSnapshotStore(config: SnapshotStoreConfig): SnapshotStore;
215
+
216
+ /**
217
+ * Dependencies the in-process backend needs that used to be direct `../db` /
218
+ * `electron` reaches: a SettingsProvider (cwd-hook gating) and a SnapshotStore
219
+ * (cold-spawn restore). Injected by the composition root via
220
+ * configureInProcessBackend; defaults are inert so a test/pre-config load is
221
+ * harmless (no settings → cwd hook degrades to {}, no-op snapshot store → no
222
+ * restore), preserving the historical "db not ready → best-effort" behaviour.
223
+ */
224
+ interface BackendDeps {
225
+ settings: SettingsProvider;
226
+ snapshots: SnapshotStore;
227
+ }
228
+ /**
229
+ * InProcessBackend — node-pty instances owned directly by the Electron main
230
+ * process. This is the historical TerminalManager body verbatim; it now also
231
+ * formally implements the PtyBackend interface so the IPC layer can hold it
232
+ * behind that abstraction interchangeably with the Tier 3 HostClient.
233
+ *
234
+ * EventEmitter gives us the `on('data'|'exit', …)` half of PtyBackend for free.
235
+ */
236
+ declare class InProcessBackend extends EventEmitter implements PtyBackend {
237
+ private deps;
238
+ constructor(deps?: BackendDeps);
239
+ /** Swap injected deps (only used if configure lands after lazy construction). */
240
+ setDeps(deps: BackendDeps): void;
241
+ private readonly ptys;
242
+ private readonly scrollback;
243
+ private readonly shells;
244
+ /** Last cwd reported by each pty via OSC-7 (in-memory, authoritative). */
245
+ private readonly liveCwd;
246
+ /** Pending debounced cwd-persist timers, keyed by terminal id. */
247
+ private readonly cwdTimers;
248
+ /**
249
+ * Tier 2: ids that must keep their pty alive even with zero attached
250
+ * windows (a disabled-but-retained terminal — e.g. a dev server the user
251
+ * suspended). The IPC layer consults this in detachOwner: a retained id is
252
+ * left running on the last detach instead of killed, so re-enable reattaches
253
+ * to the LIVE session (scrollback replays) rather than spawning fresh.
254
+ * Insertion order is preserved so the cap can evict the oldest if needed.
255
+ */
256
+ private readonly retained;
257
+ /**
258
+ * Spawn a new pty for the given id, OR return the existing one if a
259
+ * window has already attached. Idempotent: a Stage window can attach
260
+ * to a spec that TheFloor is already running and get the same live
261
+ * shell + a buffered scrollback to catch up.
262
+ */
263
+ create(opts: CreateTerminalOpts): AttachResult;
264
+ private scheduleCwdPersist;
265
+ private cleanupCwd;
266
+ /** Last cwd reported by this pty via OSC-7, or undefined when unknown. */
267
+ getLiveCwd(id: string): string | undefined;
268
+ write(id: string, data: string): boolean;
269
+ resize(id: string, cols: number, rows: number): boolean;
270
+ kill(id: string): boolean;
271
+ killAll(): void;
272
+ list(): TerminalInfo[];
273
+ isLive(id: string): boolean;
274
+ /**
275
+ * Mark/unmark a terminal as retained. A retained terminal's pty is kept
276
+ * alive by the IPC layer even when its last window detaches. Returns the
277
+ * resulting retained-id set size. Retaining a terminal that isn't live is
278
+ * harmless (the flag simply has no pty to protect yet).
279
+ */
280
+ setRetained(id: string, retained: boolean): void;
281
+ isRetained(id: string): boolean;
282
+ /** Number of currently-retained terminals (for the resource cap). */
283
+ retainedCount(): number;
284
+ /** Snapshot of retained ids in insertion order (oldest first). */
285
+ retainedIds(): string[];
286
+ /**
287
+ * Buffered scrollback for a live pty (raw ANSI text), or undefined when the
288
+ * id has no pty. Tier 2 uses this to serialize a windowless retained pty at
289
+ * quit so its post-disable output still lands in a snapshot (T2→T1 degrade).
290
+ */
291
+ getScrollback(id: string): string | undefined;
292
+ }
293
+ /** Back-compat alias. The class was renamed InProcessBackend in Tier 3; existing
294
+ * imports of `TerminalManager` as a TYPE keep working. */
295
+ type TerminalManager = InProcessBackend;
296
+ /**
297
+ * Wire the in-process backend's settings + snapshot store. Must be called by the
298
+ * adapter at app-ready, before any terminal is created. Idempotent if the
299
+ * singleton hasn't been built yet; if it already exists this updates the deps it
300
+ * uses (the adapter calls this exactly once, before first use).
301
+ */
302
+ declare function configureInProcessBackend(deps: BackendDeps): void;
303
+ declare function inProcessBackend(): InProcessBackend;
304
+ declare function terminalManager(): PtyBackend;
305
+ /**
306
+ * Subscribers that want to follow whichever backend is active (the IPC layer's
307
+ * data/exit fan-out). Re-bound on every backend swap so events always come from
308
+ * the LIVE backend, not a stale one left behind after a fallback.
309
+ */
310
+ interface BackendEventHandlers {
311
+ onData: (id: string, data: string) => void;
312
+ onExit: (id: string, payload: {
313
+ exitCode: number;
314
+ signal?: number;
315
+ }) => void;
316
+ }
317
+ /**
318
+ * Register the data/exit fan-out once. Binds to the current active backend and
319
+ * is automatically re-bound to any backend swapped in via setActiveBackend, so
320
+ * the IPC layer never has to know the backend changed underneath it.
321
+ */
322
+ declare function subscribeBackendEvents(handlers: BackendEventHandlers): void;
323
+ /**
324
+ * Swap the active backend (Tier 3 connect/spawn success → HostClient). Idempotent
325
+ * and safe to call before any pty exists. Passing null reverts to the in-process
326
+ * backend — used by the graceful-fallback path when the host dies mid-session.
327
+ * Re-binds the IPC event fan-out to the new backend.
328
+ */
329
+ declare function setActiveBackend(backend: PtyBackend | null): void;
330
+ declare function defaultShell(): string;
331
+
332
+ declare class HostClient extends EventEmitter implements PtyBackend {
333
+ private readonly socketPath;
334
+ private readonly snapshots;
335
+ private socket;
336
+ private readonly decoder;
337
+ private seq;
338
+ private readonly pending;
339
+ private readonly mirror;
340
+ private readonly retained;
341
+ /** Host pid, learned from hello-ok — surfaced for diagnostics. */
342
+ hostPid: number;
343
+ private connected;
344
+ private constructor();
345
+ /**
346
+ * Connect to a running host at `socketPath`, perform the version handshake,
347
+ * and seed the local mirror from the host's live ptys (list + per-pty
348
+ * scrollback). Resolves to a ready client, or rejects on connect failure /
349
+ * version mismatch / timeout — the caller then falls back to in-process.
350
+ *
351
+ * `snapshots` is the injected snapshot store used by cold-create to surface
352
+ * any on-disk previous-session snapshot (was a direct `./sessions` import).
353
+ */
354
+ static connect(socketPath: string, snapshots: SnapshotStore, timeoutMs?: number): Promise<HostClient>;
355
+ private wireSocket;
356
+ private handleSocketError;
357
+ private handleHostMessage;
358
+ private nextSeq;
359
+ /** Send a request and await the correlated reply. */
360
+ private request;
361
+ /** Fire-and-forget send for messages with no reply (write/resize/kill/…). */
362
+ private send;
363
+ /** Seed the local mirror from the host's live ptys after a (re)connect. */
364
+ private seedFromHost;
365
+ /** Ids the host currently has live — used by the lifecycle layer to drive
366
+ * the reattach (renderer remounts these specs, replaying host scrollback). */
367
+ liveIds(): string[];
368
+ isConnected(): boolean;
369
+ /** Disconnect WITHOUT killing host ptys (before-quit leave-running). */
370
+ disconnect(): void;
371
+ create(opts: CreateTerminalOpts): AttachResult;
372
+ write(id: string, data: string): boolean;
373
+ resize(id: string, cols: number, rows: number): boolean;
374
+ kill(id: string): boolean;
375
+ /**
376
+ * NO-OP for the host backend. The whole point of Tier 3 is that ptys survive
377
+ * a full quit, so the before-quit teardown must NOT kill them. The lifecycle
378
+ * layer disconnects the client and leaves the host running instead.
379
+ */
380
+ killAll(): void;
381
+ list(): TerminalInfo[];
382
+ isLive(id: string): boolean;
383
+ setRetained(id: string, retained: boolean): void;
384
+ isRetained(id: string): boolean;
385
+ retainedCount(): number;
386
+ retainedIds(): string[];
387
+ getScrollback(id: string): string | undefined;
388
+ }
389
+
390
+ /**
391
+ * Tier 3 lifecycle: decide the backend at app-ready, manage the detached
392
+ * pty-host, and handle graceful fallback.
393
+ *
394
+ * The flow (initTerminalBackend):
395
+ * 1. If the `detached_terminals` setting is OFF (the default — see below) →
396
+ * use the in-process backend. Done. This is today's T1/T2 behaviour.
397
+ * 2. ON → try to CONNECT to an existing host (pidfile alive + version match +
398
+ * socket reachable). Success → HostClient, reattach existing ptys.
399
+ * 3. No usable host → SPAWN one detached, await its pidfile, then connect.
400
+ * 4. Any failure (spawn, timeout, version mismatch, socket error) → fall back
401
+ * to the in-process backend and surface a NON-FATAL toast. The app stays
402
+ * fully functional.
403
+ *
404
+ * SETTING DEFAULT — `detached_terminals` defaults OFF.
405
+ * Rationale: T3 is the heaviest tier and its #1 risk is the dev-vs-packaged
406
+ * host-script path. Shipping it default-ON would put every user on an
407
+ * unproven detached process the first launch after upgrade. Default-OFF means
408
+ * the proven in-process T1/T2 path remains the out-of-box experience; users
409
+ * opt in via Settings → Terminal → "Keep terminals running after quit".
410
+ *
411
+ * RUNTIME-AGNOSTIC: this module imports neither `electron` nor `../db`. The
412
+ * connect-or-spawn-or-fallback LOGIC is core; the Electron specifics are
413
+ * injected:
414
+ * - HostSpawner — resolveHostScript / spawnDetached / userDataDir
415
+ * (was app.getPath + child_process.spawn with execPath +
416
+ * ELECTRON_RUN_AS_NODE).
417
+ * - SettingsProvider — the `detached_terminals` read (was getAllSettings).
418
+ * - SnapshotStore — passed to HostClient for cold-create snapshot probe.
419
+ * - onHostStatus — the fallback toast sink, emits `host-status` instead of
420
+ * a direct BrowserWindow broadcast.
421
+ * Genie's adapter (genie-adapter.ts) supplies all four via configureHostLifecycle.
422
+ */
423
+ interface HostLifecycleDeps {
424
+ spawner: HostSpawner;
425
+ settings: SettingsProvider;
426
+ snapshots: SnapshotStore;
427
+ onHostStatus: (status: HostStatus) => void;
428
+ }
429
+ /**
430
+ * Wire the host lifecycle's injected ports. Called once by the adapter at
431
+ * app-ready, before initTerminalBackend. NEVER configured = in-process only
432
+ * (detachedEnabled below returns false defensively).
433
+ */
434
+ declare function configureHostLifecycle(d: HostLifecycleDeps): void;
435
+ /** True when the active backend is the detached host (diagnostics + before-quit). */
436
+ declare function isHostBacked(): boolean;
437
+ declare function getHostClient(): HostClient | null;
438
+ /**
439
+ * Initialise the terminal backend at app-ready. Returns the list of host pty ids
440
+ * that should be reattached by the renderer (empty for the in-process path or a
441
+ * cold host). NEVER throws — every failure degrades to in-process.
442
+ */
443
+ declare function initTerminalBackend(): Promise<{
444
+ host: boolean;
445
+ reattachIds: string[];
446
+ }>;
447
+ /**
448
+ * before-quit, host-backed: DO NOT kill the host ptys. Snapshot (T1) already ran
449
+ * via the normal before-quit path; here we just disconnect the client and leave
450
+ * the host running so the next launch reattaches.
451
+ */
452
+ declare function disconnectHostLeaveRunning(): void;
453
+
454
+ /**
455
+ * Absolute path to the bundled detached pty-host script (Tier 3), so a
456
+ * `HostSpawner` can launch it as a detached child without knowing this
457
+ * package's dist layout:
458
+ *
459
+ * ```ts
460
+ * import { spawn } from 'node:child_process';
461
+ * import { ptyHostScriptPath } from '@particle-academy/fancy-term-host';
462
+ *
463
+ * const child = spawn(process.execPath, [ptyHostScriptPath(), userDataDir], {
464
+ * detached: true, stdio: 'ignore',
465
+ * });
466
+ * child.unref();
467
+ * ```
468
+ *
469
+ * The host script is emitted alongside this module in `dist/` as
470
+ * `pty-host.js`. Works in both the ESM and CJS builds (esbuild fills in
471
+ * `import.meta.url` for the CJS output; `__dirname` is used when present).
472
+ */
473
+ declare function ptyHostScriptPath(): string;
474
+
475
+ /**
476
+ * Path + pidfile resolution for the detached pty-host (Tier 3).
477
+ *
478
+ * Kept ELECTRON-FREE on the resolution side that the host itself uses (the host
479
+ * is a plain node process — no `app`), so the userData path is passed IN. The
480
+ * in-app side (host-client lifecycle) imports `app` separately and feeds it here.
481
+ */
482
+ interface Pidfile {
483
+ pid: number;
484
+ socketPath: string;
485
+ protocolVersion: number;
486
+ startedAt: number;
487
+ }
488
+ /** Short, stable per-user hash so two OS users don't collide on the Windows
489
+ * pipe name (the pipe namespace is machine-global). */
490
+ declare function userHash(): string;
491
+ /**
492
+ * The local IPC transport address.
493
+ * • Windows: a named pipe `\\.\pipe\genie-ptyhost-<userhash>`. The default
494
+ * Windows pipe ACL is per-logon-session, so another user on the same machine
495
+ * can't open it — that's our ACL. (Documented; we don't tighten further.)
496
+ * • POSIX: a unix domain socket under userData (preferred — survives /tmp
497
+ * cleaners and is per-user by directory perms) named `ptyhost.sock`.
498
+ */
499
+ declare function socketPathFor(userDataDir: string): string;
500
+ declare function pidfilePath(userDataDir: string): string;
501
+ declare function writePidfile(userDataDir: string, pf: Pidfile): void;
502
+ declare function readPidfile(userDataDir: string): Pidfile | null;
503
+ declare function deletePidfile(userDataDir: string): void;
504
+ /** True when a process with `pid` is alive (signal 0 probes without killing). */
505
+ declare function isPidAlive(pid: number): boolean;
506
+ /**
507
+ * Decide whether an existing pidfile points at a usable host.
508
+ * Usable = pid alive AND protocol versions match. A stale/dead/mismatched
509
+ * pidfile means we must spawn a fresh host.
510
+ */
511
+ declare function pidfileUsable(pf: Pidfile | null): boolean;
512
+ /**
513
+ * Resolve the compiled pty-host script on disk, trying multiple candidate paths
514
+ * so it works in BOTH `npm run dev` (script at app/pty-host.js next to
515
+ * background.js) AND a packaged asar build. node-pty's native binding can't load
516
+ * from inside an asar, so the host (which requires node-pty) must run UNPACKED —
517
+ * `app.asar.unpacked/...`. We try the unpacked path first, then the in-asar path,
518
+ * then a dev-relative path. Returns the first that exists, or null.
519
+ *
520
+ * `dirname` is main/background's __dirname (the directory the compiled main
521
+ * bundle lives in). The host script is emitted alongside it as `pty-host.js`.
522
+ */
523
+ declare function resolveHostScript(dirname: string): string | null;
524
+
525
+ /**
526
+ * Pty-host wire protocol (Tier 3).
527
+ *
528
+ * The detached pty-host (main/terminal/pty-host.ts) and the in-app HostClient
529
+ * (main/terminal/host-client.ts) talk over a local IPC transport — a named pipe
530
+ * on Windows, a unix domain socket on POSIX — using a tiny length-prefixed JSON
531
+ * framing so there's no heavy dependency. This module is PURE (no electron, no
532
+ * node-pty, no net): just the message shapes + the encode/decode for the framing,
533
+ * so it can be imported by both ends AND unit-tested in isolation.
534
+ *
535
+ * Framing: each message is `[4-byte big-endian uint32 length][utf8 JSON body]`.
536
+ * The length prefix is the byte length of the JSON body. A FrameDecoder buffers
537
+ * partial reads and yields whole messages as they complete — TCP/pipe streams
538
+ * don't preserve message boundaries, so we can't assume one `data` event == one
539
+ * message.
540
+ */
541
+ /**
542
+ * Protocol version. Bumped whenever the message shapes change in a way that
543
+ * makes an old host incompatible with a new client (or vice-versa). The client
544
+ * refuses to attach to a host whose pidfile reports a different version and
545
+ * spawns a fresh host instead — see host-client.ts connect-or-spawn.
546
+ */
547
+ declare const PROTOCOL_VERSION = 1;
548
+ /** Requests the client sends to the host. `seq` correlates a reply. */
549
+ type ClientMessage = {
550
+ kind: 'hello';
551
+ seq: number;
552
+ protocolVersion: number;
553
+ } | {
554
+ kind: 'create';
555
+ seq: number;
556
+ opts: {
557
+ id: string;
558
+ cwd: string;
559
+ shell?: string;
560
+ args?: string[];
561
+ cols?: number;
562
+ rows?: number;
563
+ env?: Record<string, string>;
564
+ };
565
+ } | {
566
+ kind: 'write';
567
+ id: string;
568
+ data: string;
569
+ } | {
570
+ kind: 'resize';
571
+ id: string;
572
+ cols: number;
573
+ rows: number;
574
+ } | {
575
+ kind: 'kill';
576
+ id: string;
577
+ } | {
578
+ kind: 'list';
579
+ seq: number;
580
+ } | {
581
+ kind: 'set-retained';
582
+ id: string;
583
+ retained: boolean;
584
+ } | {
585
+ kind: 'get-scrollback';
586
+ seq: number;
587
+ id: string;
588
+ } | {
589
+ kind: 'ping';
590
+ seq: number;
591
+ };
592
+ /** Pushes + replies the host sends to the client. */
593
+ type HostMessage = {
594
+ kind: 'hello-ok';
595
+ seq: number;
596
+ protocolVersion: number;
597
+ pid: number;
598
+ } | {
599
+ kind: 'created';
600
+ seq: number;
601
+ result: {
602
+ id: string;
603
+ pid: number;
604
+ shell: string;
605
+ existing: boolean;
606
+ scrollback: string;
607
+ };
608
+ } | {
609
+ kind: 'list-result';
610
+ seq: number;
611
+ terminals: Array<{
612
+ id: string;
613
+ pid: number;
614
+ shell: string;
615
+ }>;
616
+ } | {
617
+ kind: 'scrollback-result';
618
+ seq: number;
619
+ scrollback: string | null;
620
+ } | {
621
+ kind: 'pong';
622
+ seq: number;
623
+ } | {
624
+ kind: 'data';
625
+ id: string;
626
+ data: string;
627
+ } | {
628
+ kind: 'exit';
629
+ id: string;
630
+ exitCode: number;
631
+ signal?: number;
632
+ };
633
+ type Frame = ClientMessage | HostMessage;
634
+ /** Encode a message as a length-prefixed JSON frame ready for the socket. */
635
+ declare function encodeFrame(msg: Frame): Buffer;
636
+ /**
637
+ * Streaming frame decoder. Feed it raw socket chunks via `push`; it returns the
638
+ * complete messages that became available (zero or more), buffering any partial
639
+ * tail until the rest arrives. One decoder per socket.
640
+ *
641
+ * Resilient by design: a malformed JSON body is skipped (the frame is consumed
642
+ * but yields nothing) rather than throwing — a corrupt frame must not wedge the
643
+ * whole stream. An absurd length prefix (> MAX_FRAME) is treated as a desync and
644
+ * the buffer is reset; the caller can decide whether to drop the connection.
645
+ */
646
+ declare class FrameDecoder {
647
+ private buffer;
648
+ /** Hard cap on a single frame (16 MB). Guards against a runaway/garbage
649
+ * length prefix allocating unbounded memory. node-pty data chunks are tiny;
650
+ * a serialized scrollback is bounded well under this. */
651
+ static readonly MAX_FRAME: number;
652
+ /** True when the last push hit an oversized/desynced frame. The caller
653
+ * should drop the connection — the stream can't be trusted to realign. */
654
+ desynced: boolean;
655
+ push(chunk: Buffer): Frame[];
656
+ }
657
+
658
+ /**
659
+ * Shell detection + default-shell resolution for the terminal subsystem.
660
+ *
661
+ * Mirrors main/editors.ts: probe well-known install paths, return what's
662
+ * actually present. Ids line up with fancy-term's BUILTIN_SHELLS so the
663
+ * renderer can map detections straight onto ShellProfile entries
664
+ * (cmd · powershell · pwsh · git-bash · bash · zsh · wsl).
665
+ *
666
+ * Default policy (Windows): Git Bash when detected — it's the shell the
667
+ * Tynn toolchain assumes — then pwsh, then Windows PowerShell, then cmd.
668
+ * On macOS/Linux the user's $SHELL wins, falling back to bash.
669
+ */
670
+ interface ShellInfo {
671
+ /** Stable id, matches fancy-term BUILTIN_SHELLS where possible. */
672
+ id: string;
673
+ /** Display label, e.g. "Git Bash". */
674
+ label: string;
675
+ /** Absolute executable path (or bare command when resolved via PATH). */
676
+ command: string;
677
+ /** Default args for an interactive session. */
678
+ args: string[];
679
+ }
680
+ declare function detectShells(): ShellInfo[];
681
+ /** Default policy: Git Bash > pwsh > powershell > cmd (win); $SHELL > bash (unix). */
682
+ declare function defaultShellId(detected: ShellInfo[]): string | null;
683
+ /**
684
+ * Split a manual "executable line" into command + args. Honors double
685
+ * quotes around the executable path ("C:\Program Files\Git\bin\bash.exe"
686
+ * --login -i). Single-token lines pass through untouched.
687
+ */
688
+ declare function parseCommandLine(line: string): {
689
+ command: string;
690
+ args: string[];
691
+ };
692
+ /** Coarse shell family, derived from the executable name, used to decide which
693
+ * OSC-7 prompt hook (if any) we can inject. */
694
+ type ShellKind = 'powershell' | 'bash' | 'zsh' | 'fish' | 'cmd' | 'other';
695
+ declare function shellKind(command: string): ShellKind;
696
+ /** The spawn additions (env + extra args) a shell needs to emit OSC-7 cwd. */
697
+ interface CwdHook {
698
+ /** Extra env entries to merge into the pty's environment. */
699
+ env: Record<string, string>;
700
+ /** Extra args to APPEND to the shell's launch args (PowerShell needs these). */
701
+ args: string[];
702
+ }
703
+ /**
704
+ * Build the spawn additions (env + args) that make a shell emit OSC-7 cwd
705
+ * reports on every prompt, so resumed terminals know where they were
706
+ * (Tier 1.5). Gated by the `track_cwd` setting (default ON). Returns an empty
707
+ * hook when tracking is off or the shell genuinely can't be hooked — the
708
+ * manager then degrades to the static cwd.
709
+ *
710
+ * Coverage (all overlay, never clobber the user's own prompt/rc):
711
+ * - **bash** — prepend an OSC-7 `printf` to `PROMPT_COMMAND` (env only). The
712
+ * portable, reliable case (Git Bash on Windows, bash on POSIX).
713
+ * - **zsh** — point `ZDOTDIR` at a generated dir whose `.zshrc` sources the
714
+ * user's real `.zshrc`, restores `ZDOTDIR`, then registers a `precmd`
715
+ * emitter; `.zshenv` sources the user's real `.zshenv` first.
716
+ * - **fish** — prepend a generated dir to `XDG_DATA_DIRS` carrying a
717
+ * `fish/vendor_conf.d/osc7.fish` that hooks `--on-event fish_prompt`
718
+ * (vendor conf overlays the user's config rather than replacing it).
719
+ * - **PowerShell (pwsh/powershell)** — write a profile shim that wraps any
720
+ * existing `prompt` and emits OSC-7, dot-sourced via appended
721
+ * `-NoExit -Command ". '<shim>'"` args (PS has no env-var prompt hook).
722
+ * - **cmd.exe (best-effort)** — set `PROMPT` to emit OSC-7 via the `$E`
723
+ * escape before the normal `$P$G`. Renders only where the console honors
724
+ * VT sequences in the prompt; otherwise degrades to static cwd.
725
+ *
726
+ * The emitted payload is always `file:///<path>` (empty authority, forward
727
+ * slashes / Windows drive) so it round-trips through {@link scanOsc7Cwd}.
728
+ */
729
+ declare function cwdHookSpawn(command: string, settings: SettingsProvider): CwdHook;
730
+ /**
731
+ * Back-compat env-only view of {@link cwdHookSpawn} — returns just the env
732
+ * additions. Shells that also need launch args (PowerShell) are only fully
733
+ * hooked via `cwdHookSpawn`; callers using this alone get the env half.
734
+ */
735
+ declare function cwdHookEnv(command: string, settings: SettingsProvider): Record<string, string>;
736
+ /**
737
+ * Resolve the user's configured default shell to a concrete spawn target.
738
+ * Reads the `terminal_shell` setting (a detected id, or 'custom' paired
739
+ * with `terminal_custom_cmd`). Anything unresolvable falls back to the
740
+ * detection-based default so the terminal always opens SOMETHING.
741
+ */
742
+ declare function resolveDefaultShell(settings: SettingsProvider): {
743
+ command: string;
744
+ args: string[];
745
+ };
746
+
747
+ /**
748
+ * OSC-7 cwd reporting (Tier 1.5).
749
+ *
750
+ * A shell that emits OSC-7 tells the terminal its current working directory on
751
+ * every prompt:
752
+ *
753
+ * ESC ] 7 ; file://HOST/PATH (BEL | ESC \)
754
+ *
755
+ * We scan raw pty output for these and parse out an absolute filesystem path so
756
+ * a fresh shell spawned on resume can start where the old one left off. The
757
+ * sequence is terminated by either BEL (\x07) or ST (ESC \, i.e. \x1b\x5c).
758
+ *
759
+ * Path forms handled:
760
+ * file:///home/user/proj → /home/user/proj
761
+ * file://hostname/home/user/proj → /home/user/proj (host ignored)
762
+ * file:///C:/Users/me/proj → C:\Users\me\proj (Windows drive)
763
+ * percent-encoded segments (%20) → decoded
764
+ */
765
+ /**
766
+ * Parse a `file://...` URL from an OSC-7 payload into a local filesystem path.
767
+ * Returns null when the payload isn't a usable file URL.
768
+ */
769
+ declare function parseFileUrl(payload: string): string | null;
770
+ /**
771
+ * Scan a chunk of pty output and return the LAST cwd reported via OSC-7, or
772
+ * null when the chunk contains no (parseable) OSC-7 sequence. We take the last
773
+ * one because a single chunk can carry several prompts; the most recent wins.
774
+ */
775
+ declare function scanOsc7Cwd(chunk: string): string | null;
776
+
777
+ export { type AttachResult, type BackendDeps, type ClientMessage, type CreateTerminalOpts, type CwdHook, type Encryptor, type Frame, FrameDecoder, HostClient, type HostMessage, type HostSpawner, type HostStatus, PROTOCOL_VERSION, type Pidfile, type PtyBackend, type SettingsProvider, type ShellInfo, type ShellKind, type SnapshotRead, type SnapshotStore, type SnapshotStoreConfig, type TerminalInfo, type TerminalManager, configureHostLifecycle, configureInProcessBackend, createSnapshotStore, cwdHookEnv, cwdHookSpawn, defaultShell, defaultShellId, deletePidfile, detectShells, disconnectHostLeaveRunning, encodeFrame, getHostClient, inProcessBackend, initTerminalBackend, isHostBacked, isPidAlive, parseCommandLine, parseFileUrl, pidfilePath, pidfileUsable, ptyHostScriptPath, readPidfile, resolveDefaultShell, resolveHostScript, scanOsc7Cwd, setActiveBackend, shellKind, socketPathFor, subscribeBackendEvents, terminalManager, userHash, writePidfile };