@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,236 @@
1
+ import { socketPathFor, FrameDecoder, pidfilePath, PROTOCOL_VERSION, encodeFrame } from './chunk-2DQJKTG5.js';
2
+ import net from 'net';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { spawn } from 'node-pty';
6
+
7
+ var SCROLLBACK_MAX = 1e6;
8
+ var IDLE_TIMEOUT_MS = 10 * 60 * 1e3;
9
+ var IDLE_CHECK_MS = 60 * 1e3;
10
+ var userData = process.env.GENIE_USERDATA;
11
+ if (!userData) {
12
+ process.exit(2);
13
+ }
14
+ var ptys = /* @__PURE__ */ new Map();
15
+ var clients = /* @__PURE__ */ new Set();
16
+ var lastActivity = Date.now();
17
+ function broadcast(msg) {
18
+ const frame = encodeFrame(msg);
19
+ for (const sock of clients) {
20
+ try {
21
+ sock.write(frame);
22
+ } catch {
23
+ }
24
+ }
25
+ }
26
+ function createPty(opts) {
27
+ const existing = ptys.get(opts.id);
28
+ if (existing) {
29
+ return {
30
+ pid: existing.pty.pid,
31
+ shell: existing.shell,
32
+ existing: true,
33
+ scrollback: existing.scrollback
34
+ };
35
+ }
36
+ const shell = opts.shell ?? defaultShell();
37
+ const env = { ...process.env, ...opts.env ?? {} };
38
+ env.TERM = env.TERM || "xterm-256color";
39
+ const pty = spawn(shell, opts.args ?? [], {
40
+ name: "xterm-color",
41
+ cwd: opts.cwd,
42
+ cols: opts.cols ?? 80,
43
+ rows: opts.rows ?? 24,
44
+ env
45
+ });
46
+ const entry = { pty, shell, scrollback: "" };
47
+ ptys.set(opts.id, entry);
48
+ pty.onData((data) => {
49
+ const next = entry.scrollback + data;
50
+ entry.scrollback = next.length > SCROLLBACK_MAX ? next.slice(-SCROLLBACK_MAX) : next;
51
+ broadcast({ kind: "data", id: opts.id, data });
52
+ });
53
+ pty.onExit(({ exitCode, signal }) => {
54
+ ptys.delete(opts.id);
55
+ broadcast({ kind: "exit", id: opts.id, exitCode, signal });
56
+ lastActivity = Date.now();
57
+ });
58
+ return { pid: pty.pid, shell, existing: false, scrollback: "" };
59
+ }
60
+ function defaultShell() {
61
+ if (process.platform === "win32") return process.env.COMSPEC ?? "cmd.exe";
62
+ return process.env.SHELL ?? "/bin/bash";
63
+ }
64
+ function handleClientMessage(sock, msg) {
65
+ lastActivity = Date.now();
66
+ switch (msg.kind) {
67
+ case "hello":
68
+ reply(sock, {
69
+ kind: "hello-ok",
70
+ seq: msg.seq,
71
+ protocolVersion: PROTOCOL_VERSION,
72
+ pid: process.pid
73
+ });
74
+ break;
75
+ case "create": {
76
+ const r = createPty(msg.opts);
77
+ reply(sock, {
78
+ kind: "created",
79
+ seq: msg.seq,
80
+ result: {
81
+ id: msg.opts.id,
82
+ pid: r.pid,
83
+ shell: r.shell,
84
+ existing: r.existing,
85
+ scrollback: r.scrollback
86
+ }
87
+ });
88
+ break;
89
+ }
90
+ case "write": {
91
+ const e = ptys.get(msg.id);
92
+ if (e) e.pty.write(msg.data);
93
+ break;
94
+ }
95
+ case "resize": {
96
+ const e = ptys.get(msg.id);
97
+ if (e) {
98
+ try {
99
+ e.pty.resize(Math.max(1, msg.cols | 0), Math.max(1, msg.rows | 0));
100
+ } catch {
101
+ }
102
+ }
103
+ break;
104
+ }
105
+ case "kill": {
106
+ const e = ptys.get(msg.id);
107
+ if (e) {
108
+ try {
109
+ e.pty.kill();
110
+ } catch {
111
+ }
112
+ ptys.delete(msg.id);
113
+ }
114
+ break;
115
+ }
116
+ case "list":
117
+ reply(sock, {
118
+ kind: "list-result",
119
+ seq: msg.seq,
120
+ terminals: Array.from(ptys.entries()).map(([id, e]) => ({
121
+ id,
122
+ pid: e.pty.pid,
123
+ shell: e.shell
124
+ }))
125
+ });
126
+ break;
127
+ case "set-retained":
128
+ break;
129
+ case "get-scrollback":
130
+ reply(sock, {
131
+ kind: "scrollback-result",
132
+ seq: msg.seq,
133
+ scrollback: ptys.get(msg.id)?.scrollback ?? null
134
+ });
135
+ break;
136
+ case "ping":
137
+ reply(sock, { kind: "pong", seq: msg.seq });
138
+ break;
139
+ }
140
+ }
141
+ function reply(sock, msg) {
142
+ try {
143
+ sock.write(encodeFrame(msg));
144
+ } catch {
145
+ }
146
+ }
147
+ function startServer(socketPath2) {
148
+ if (process.platform !== "win32") {
149
+ try {
150
+ fs.rmSync(socketPath2, { force: true });
151
+ } catch {
152
+ }
153
+ try {
154
+ fs.mkdirSync(path.dirname(socketPath2), { recursive: true });
155
+ } catch {
156
+ }
157
+ }
158
+ const server = net.createServer((sock) => {
159
+ clients.add(sock);
160
+ lastActivity = Date.now();
161
+ const decoder = new FrameDecoder();
162
+ sock.on("data", (chunk) => {
163
+ const frames = decoder.push(chunk);
164
+ if (decoder.desynced) {
165
+ try {
166
+ sock.destroy();
167
+ } catch {
168
+ }
169
+ return;
170
+ }
171
+ for (const f of frames) handleClientMessage(sock, f);
172
+ });
173
+ const drop = () => {
174
+ clients.delete(sock);
175
+ lastActivity = Date.now();
176
+ };
177
+ sock.on("close", drop);
178
+ sock.on("error", drop);
179
+ });
180
+ server.on("error", (err) => {
181
+ console.error("[pty-host] server error:", err.message);
182
+ process.exit(3);
183
+ });
184
+ server.listen(socketPath2, () => {
185
+ try {
186
+ writePidfileLocal(socketPath2);
187
+ } catch (err) {
188
+ console.error("[pty-host] pidfile write failed:", err.message);
189
+ }
190
+ });
191
+ const idle = setInterval(() => {
192
+ if (ptys.size === 0 && clients.size === 0 && Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
193
+ cleanupAndExit(socketPath2, server);
194
+ }
195
+ }, IDLE_CHECK_MS);
196
+ if (typeof idle.unref === "function") idle.unref();
197
+ }
198
+ function writePidfileLocal(socketPath2) {
199
+ const target = pidfilePath(userData);
200
+ const tmp = `${target}.tmp`;
201
+ fs.writeFileSync(
202
+ tmp,
203
+ JSON.stringify({
204
+ pid: process.pid,
205
+ socketPath: socketPath2,
206
+ protocolVersion: PROTOCOL_VERSION,
207
+ startedAt: Date.now()
208
+ })
209
+ );
210
+ fs.renameSync(tmp, target);
211
+ }
212
+ function cleanupAndExit(socketPath2, server) {
213
+ try {
214
+ const pf = JSON.parse(fs.readFileSync(pidfilePath(userData), "utf8"));
215
+ if (pf?.pid === process.pid) fs.rmSync(pidfilePath(userData), { force: true });
216
+ } catch {
217
+ }
218
+ if (process.platform !== "win32") {
219
+ try {
220
+ fs.rmSync(socketPath2, { force: true });
221
+ } catch {
222
+ }
223
+ }
224
+ try {
225
+ server.close();
226
+ } catch {
227
+ }
228
+ process.exit(0);
229
+ }
230
+ var socketPath = socketPathFor(userData);
231
+ process.on("uncaughtException", (err) => {
232
+ console.error("[pty-host] uncaught:", err);
233
+ });
234
+ startServer(socketPath);
235
+ //# sourceMappingURL=pty-host.js.map
236
+ //# sourceMappingURL=pty-host.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/pty-host.ts"],"names":["socketPath"],"mappings":"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","file":"pty-host.js","sourcesContent":["/**\n * Genie detached pty-host (Tier 3).\n *\n * A HEADLESS Node process — NO electron import — that owns the real node-pty\n * instances so they survive a full quit of the Electron app. The in-app\n * HostClient connects over a local socket (named pipe on Windows, unix domain\n * socket on POSIX) and proxies create/write/resize/kill; the host pushes back\n * `data`/`exit`. The host keeps its OWN scrollback ring buffer per pty so a\n * reattach AFTER a full quit can replay history.\n *\n * Launched detached by background.ts:\n * spawn(process.execPath, [hostScript], {\n * detached: true, stdio: 'ignore',\n * env: { ELECTRON_RUN_AS_NODE: '1', GENIE_USERDATA: <userData>, … }\n * }).unref()\n *\n * ELECTRON_RUN_AS_NODE makes Electron's binary run as plain Node so node-pty's\n * native ABI matches the one the app was built against (critical — a system Node\n * with a different ABI would fail to load the .node).\n *\n * Self-terminates after an idle period with zero live ptys AND no connected\n * client, so a host can never become a forever-orphan.\n */\n\nimport net from 'node:net';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { spawn, IPty } from 'node-pty';\nimport {\n encodeFrame,\n FrameDecoder,\n PROTOCOL_VERSION,\n type ClientMessage,\n type HostMessage,\n} from './host-protocol';\nimport { socketPathFor, pidfilePath } from './host-locate';\n\nconst SCROLLBACK_MAX = 1_000_000;\n/** Self-exit after this long with no ptys AND no connected client. */\nconst IDLE_TIMEOUT_MS = 10 * 60 * 1000;\nconst IDLE_CHECK_MS = 60 * 1000;\n\nconst userData = process.env.GENIE_USERDATA;\nif (!userData) {\n // Without a userData path we can't write a pidfile the client can find.\n process.exit(2);\n}\n\ninterface HostPty {\n pty: IPty;\n shell: string;\n scrollback: string;\n}\n\nconst ptys = new Map<string, HostPty>();\nconst clients = new Set<net.Socket>();\nlet lastActivity = Date.now();\n\nfunction broadcast(msg: HostMessage): void {\n const frame = encodeFrame(msg);\n for (const sock of clients) {\n try {\n sock.write(frame);\n } catch {\n /* dropped client — close handler cleans it up */\n }\n }\n}\n\nfunction createPty(opts: {\n id: string;\n cwd: string;\n shell?: string;\n args?: string[];\n cols?: number;\n rows?: number;\n env?: Record<string, string>;\n}): { pid: number; shell: string; existing: boolean; scrollback: string } {\n const existing = ptys.get(opts.id);\n if (existing) {\n return {\n pid: existing.pty.pid,\n shell: existing.shell,\n existing: true,\n scrollback: existing.scrollback,\n };\n }\n const shell = opts.shell ?? defaultShell();\n const env = { ...process.env, ...(opts.env ?? {}) } as Record<string, string>;\n env.TERM = env.TERM || 'xterm-256color';\n\n const pty = spawn(shell, opts.args ?? [], {\n name: 'xterm-color',\n cwd: opts.cwd,\n cols: opts.cols ?? 80,\n rows: opts.rows ?? 24,\n env,\n });\n\n const entry: HostPty = { pty, shell, scrollback: '' };\n ptys.set(opts.id, entry);\n\n pty.onData((data) => {\n const next = entry.scrollback + data;\n entry.scrollback =\n next.length > SCROLLBACK_MAX ? next.slice(-SCROLLBACK_MAX) : next;\n broadcast({ kind: 'data', id: opts.id, data });\n });\n pty.onExit(({ exitCode, signal }) => {\n ptys.delete(opts.id);\n broadcast({ kind: 'exit', id: opts.id, exitCode, signal });\n lastActivity = Date.now();\n });\n\n return { pid: pty.pid, shell, existing: false, scrollback: '' };\n}\n\nfunction defaultShell(): string {\n if (process.platform === 'win32') return process.env.COMSPEC ?? 'cmd.exe';\n return process.env.SHELL ?? '/bin/bash';\n}\n\nfunction handleClientMessage(sock: net.Socket, msg: ClientMessage): void {\n lastActivity = Date.now();\n switch (msg.kind) {\n case 'hello':\n reply(sock, {\n kind: 'hello-ok',\n seq: msg.seq,\n protocolVersion: PROTOCOL_VERSION,\n pid: process.pid,\n });\n break;\n case 'create': {\n const r = createPty(msg.opts);\n reply(sock, {\n kind: 'created',\n seq: msg.seq,\n result: {\n id: msg.opts.id,\n pid: r.pid,\n shell: r.shell,\n existing: r.existing,\n scrollback: r.scrollback,\n },\n });\n break;\n }\n case 'write': {\n const e = ptys.get(msg.id);\n if (e) e.pty.write(msg.data);\n break;\n }\n case 'resize': {\n const e = ptys.get(msg.id);\n if (e) {\n try {\n e.pty.resize(Math.max(1, msg.cols | 0), Math.max(1, msg.rows | 0));\n } catch {\n /* transient 0×0 during layout */\n }\n }\n break;\n }\n case 'kill': {\n const e = ptys.get(msg.id);\n if (e) {\n try {\n e.pty.kill();\n } catch {\n /* already exited */\n }\n ptys.delete(msg.id);\n }\n break;\n }\n case 'list':\n reply(sock, {\n kind: 'list-result',\n seq: msg.seq,\n terminals: Array.from(ptys.entries()).map(([id, e]) => ({\n id,\n pid: e.pty.pid,\n shell: e.shell,\n })),\n });\n break;\n case 'set-retained':\n // The host keeps EVERYTHING alive across quit regardless; the\n // retained flag is meaningful to the client (fallback/UX). The host\n // only needs to not-die, which it doesn't. Acknowledge by no-op.\n break;\n case 'get-scrollback':\n reply(sock, {\n kind: 'scrollback-result',\n seq: msg.seq,\n scrollback: ptys.get(msg.id)?.scrollback ?? null,\n });\n break;\n case 'ping':\n reply(sock, { kind: 'pong', seq: msg.seq });\n break;\n }\n}\n\nfunction reply(sock: net.Socket, msg: HostMessage): void {\n try {\n sock.write(encodeFrame(msg));\n } catch {\n /* client gone */\n }\n}\n\nfunction startServer(socketPath: string): void {\n // On POSIX a stale socket file blocks bind; remove it first. (On Windows the\n // pipe namespace handles this.)\n if (process.platform !== 'win32') {\n try {\n fs.rmSync(socketPath, { force: true });\n } catch {\n /* ignore */\n }\n try {\n fs.mkdirSync(path.dirname(socketPath), { recursive: true });\n } catch {\n /* ignore */\n }\n }\n\n const server = net.createServer((sock) => {\n clients.add(sock);\n lastActivity = Date.now();\n const decoder = new FrameDecoder();\n sock.on('data', (chunk: Buffer) => {\n const frames = decoder.push(chunk);\n if (decoder.desynced) {\n try {\n sock.destroy();\n } catch {\n /* ignore */\n }\n return;\n }\n for (const f of frames) handleClientMessage(sock, f as ClientMessage);\n });\n const drop = () => {\n clients.delete(sock);\n lastActivity = Date.now();\n };\n sock.on('close', drop);\n sock.on('error', drop);\n });\n\n server.on('error', (err) => {\n // EADDRINUSE: another host beat us to it. Exit quietly — the client will\n // connect to the winner.\n // eslint-disable-next-line no-console\n console.error('[pty-host] server error:', (err as Error).message);\n process.exit(3);\n });\n\n server.listen(socketPath, () => {\n try {\n writePidfileLocal(socketPath);\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error('[pty-host] pidfile write failed:', (err as Error).message);\n }\n });\n\n // Idle watchdog: exit when nothing is running and nobody is connected.\n const idle = setInterval(() => {\n if (ptys.size === 0 && clients.size === 0 && Date.now() - lastActivity > IDLE_TIMEOUT_MS) {\n cleanupAndExit(socketPath, server);\n }\n }, IDLE_CHECK_MS);\n if (typeof idle.unref === 'function') idle.unref();\n}\n\nfunction writePidfileLocal(socketPath: string): void {\n const target = pidfilePath(userData!);\n const tmp = `${target}.tmp`;\n fs.writeFileSync(\n tmp,\n JSON.stringify({\n pid: process.pid,\n socketPath,\n protocolVersion: PROTOCOL_VERSION,\n startedAt: Date.now(),\n }),\n );\n fs.renameSync(tmp, target);\n}\n\nfunction cleanupAndExit(socketPath: string, server: net.Server): void {\n try {\n // Only remove the pidfile if it still points at US (avoid clobbering a\n // successor host that took over the socket).\n const pf = JSON.parse(fs.readFileSync(pidfilePath(userData!), 'utf8'));\n if (pf?.pid === process.pid) fs.rmSync(pidfilePath(userData!), { force: true });\n } catch {\n /* ignore */\n }\n if (process.platform !== 'win32') {\n try {\n fs.rmSync(socketPath, { force: true });\n } catch {\n /* ignore */\n }\n }\n try {\n server.close();\n } catch {\n /* ignore */\n }\n process.exit(0);\n}\n\n// --- main ------------------------------------------------------------------\n\nconst socketPath = socketPathFor(userData);\n\n// A dead-mans-switch so we don't keep a host with no shells AND no client when\n// the parent vanished without a clean disconnect: covered by the idle watchdog.\nprocess.on('uncaughtException', (err) => {\n // eslint-disable-next-line no-console\n console.error('[pty-host] uncaught:', err);\n});\n\nstartServer(socketPath);\n"]}
@@ -0,0 +1,92 @@
1
+ # Persistence model (T1 / T2 / T3) + OSC-7 cwd
2
+
3
+ A terminal is expensive context — scrollback, the running shell, the working
4
+ directory. `fancy-term-host` keeps that context alive across three escalating
5
+ tiers, all switchable behind one `PtyBackend` interface so the wire code never
6
+ changes.
7
+
8
+ ## The one interface
9
+
10
+ Both implementations satisfy the same `PtyBackend`:
11
+
12
+ - **`InProcessBackend`** — PTYs live in *this* Node process. The T1/T2 floor.
13
+ - **`HostClient`** — PTYs live in a *detached* pty-host process (T3); every call
14
+ is proxied over a local socket.
15
+
16
+ `create` / `write` / `resize` / `kill` / `list` / `isLive`, the Tier-2 retained
17
+ methods, and the `data` / `exit` / `cwd` events are identical on both. The active
18
+ backend is chosen once at startup and swapped behind the interface.
19
+
20
+ ## T1 — snapshot & replay
21
+
22
+ On capture, a terminal's serialized state is (optionally) encrypted via your
23
+ [`Encryptor`](./ports.md#2-encryptor--at-rest-cipher-for-snapshots-t1), gzipped,
24
+ and written to `<baseDir>/sessions/<id>.snap` by the store from
25
+ `createSnapshotStore({ baseDir, encryptor })`. When encryption is unavailable
26
+ (`isAvailable() === false`) the bytes are stored as **plaintext gzip** with a
27
+ magic marker, so the same file path round-trips either way. A cold start reads
28
+ the snapshot back and replays it, so a reopened terminal shows where it was —
29
+ even after a full process exit.
30
+
31
+ ## T2 — retained PTYs
32
+
33
+ Flag a terminal with `backend.setRetained(id, true)` and its PTY is **not killed
34
+ when the last window detaches** — the live shell keeps running and its scrollback
35
+ replays on reattach (`getScrollback(id)`), instead of degrading to a T1 replay of
36
+ stale state. `retainedCount()` / `retainedIds()` let a host cap how many live
37
+ shells it keeps. A real quit still tears these down (that's what T3 is for).
38
+
39
+ ## T3 — detached host
40
+
41
+ The **pty-host** is a separate, headless Node process that owns the PTYs and
42
+ their scrollback ring buffers. Because it's a different process, the shells
43
+ **survive a full quit** of your app — reopening reattaches to them live.
44
+
45
+ - **Transport.** Windows: a named pipe `\\.\pipe\…-<userHash>` (per-logon-session
46
+ ACL). POSIX: a unix socket under `userDataDir` (`ptyhost.sock`), per-user by
47
+ directory perms. See `socketPathFor()`.
48
+ - **Discovery.** A pidfile (`ptyhost.json`: `{ pid, socketPath, protocolVersion,
49
+ startedAt }`) lets a fresh client find a running host. `pidfileUsable()` checks
50
+ the pid is alive *and* the protocol version matches; a stale/dead/mismatched
51
+ pidfile means spawn a fresh host.
52
+ - **Lifecycle.** `configureHostLifecycle({ spawner, settings, snapshots,
53
+ onHostStatus })` then `initTerminalBackend()` does connect-or-spawn-or-fall-
54
+ back: connect to a usable host, else spawn one via your
55
+ [`HostSpawner`](./ports.md#4-hostspawner--launch-the-detached-pty-host-t3), else
56
+ fall back to the in-process backend. T3 is gated by the `detached_terminals`
57
+ setting (default **off**).
58
+ - **Self-exit.** The host shuts itself down once it owns zero PTYs and has zero
59
+ connected clients past an idle window, so it never lingers.
60
+
61
+ ## OSC-7 cwd tracking (Tier 1.5)
62
+
63
+ So a resumed shell starts in the *right directory*, the host learns each
64
+ terminal's cwd from **OSC-7** escape sequences the shell emits on every prompt:
65
+
66
+ ```
67
+ ESC ] 7 ; file://HOST/PATH (BEL | ST)
68
+ ```
69
+
70
+ `scanOsc7Cwd(chunk)` parses the last such report out of raw PTY output;
71
+ `InProcessBackend` watches its own data stream, debounces, and **emits a `cwd`
72
+ event** (rather than writing a DB) so your adapter can persist it. Windows drive
73
+ paths (`file:///C:/Users/...`) and percent-encoding are handled.
74
+
75
+ ### Injecting the prompt hook
76
+
77
+ Shells don't emit OSC-7 unless told to. `cwdHookSpawn(command, settings)` returns
78
+ the env + launch-arg additions that make each shell report its cwd — **overlaying
79
+ your config, never replacing it** — gated by `track_cwd` (default on):
80
+
81
+ | Shell | Mechanism |
82
+ |---|---|
83
+ | **bash** | prepends an OSC-7 `printf` to `PROMPT_COMMAND` (env only) |
84
+ | **zsh** | generated `ZDOTDIR`; its `.zshrc` restores `ZDOTDIR`, sources your real `.zshrc`/`.zshenv`, then appends a `precmd` emitter |
85
+ | **fish** | generated `vendor_conf.d/osc7.fish` overlaid via `XDG_DATA_DIRS` (hooks `--on-event fish_prompt`) |
86
+ | **PowerShell** | dot-sourced profile shim wrapping your existing `prompt`, loaded via appended `-NoExit -Command ". '<shim>'"` |
87
+ | **cmd.exe** | `PROMPT` using the `$E` escape — **best-effort**, only where the console interprets VT sequences in the prompt; otherwise degrades to the static cwd |
88
+
89
+ The manager applies `cwdHookSpawn` automatically on `create()`. Generated shims
90
+ live under `os.tmpdir()/fancy-term-host/<userHash>/`. Any shell family that can't
91
+ be hooked returns an empty hook, and the terminal simply uses the static `cwd`
92
+ you passed to `create()`.
package/docs/ports.md ADDED
@@ -0,0 +1,229 @@
1
+ # Implementing the four ports
2
+
3
+ `fancy-term-host`'s core is **runtime-agnostic**: it never imports `electron`, a
4
+ database, or any host framework. Everything host-specific is injected through
5
+ four small ports. You implement them once for your environment; the core does
6
+ snapshot/replay, retained PTYs, the detached host, and the OSC-7 cwd hook on top.
7
+
8
+ ```ts
9
+ import type {
10
+ SettingsProvider,
11
+ Encryptor,
12
+ SnapshotStoreConfig, // { baseDir, encryptor }
13
+ HostSpawner,
14
+ } from "@particle-academy/fancy-term-host";
15
+ ```
16
+
17
+ The reference implementation is Genie's Electron adapter (`genie-adapter.ts` +
18
+ `ipc.ts` in the [genie](https://github.com/Renaissance-Analytics/genie) repo) —
19
+ the *one* place that imports `electron` + the DB and builds these ports. The
20
+ annotated excerpts below are drawn from it; alongside each is the **plain-Node**
21
+ shape you'd write for a non-Electron host.
22
+
23
+ ---
24
+
25
+ ## 1. `SettingsProvider` — read-only config
26
+
27
+ Gates the OSC-7 cwd hook (`track_cwd`, default on) and T3 (`detached_terminals`,
28
+ default off), plus the default-shell resolution keys (`terminal_shell`,
29
+ `terminal_custom_cmd`).
30
+
31
+ ```ts
32
+ export interface SettingsProvider {
33
+ get(key: string): string | undefined;
34
+ }
35
+ ```
36
+
37
+ **Genie (over its SQLite settings table):**
38
+
39
+ ```ts
40
+ export function dbSettingsProvider(): SettingsProvider {
41
+ return {
42
+ get: (key) => {
43
+ try {
44
+ return (getAllSettings() as Record<string, string | undefined>)[key];
45
+ } catch {
46
+ return undefined; // db not ready → best-effort defaults
47
+ }
48
+ },
49
+ };
50
+ }
51
+ ```
52
+
53
+ **Plain Node** — a literal, env, or JSON file is enough:
54
+
55
+ ```ts
56
+ const settings: SettingsProvider = {
57
+ get: (k) => ({ track_cwd: "on", detached_terminals: "off" })[k],
58
+ };
59
+ ```
60
+
61
+ > A missing key returns `undefined`, and the core applies its own default — so a
62
+ > `{ get: () => undefined }` provider is a valid (all-defaults) starting point.
63
+
64
+ ---
65
+
66
+ ## 2. `Encryptor` — at-rest cipher for snapshots (T1)
67
+
68
+ Wraps whatever encryption your host has. `isAvailable()` decides whether
69
+ snapshots are encrypted or fall back to **plaintext gzip** (the store handles the
70
+ fallback for you, exactly as before).
71
+
72
+ ```ts
73
+ export interface Encryptor {
74
+ isAvailable(): boolean;
75
+ encrypt(b: Buffer): Buffer;
76
+ decrypt(b: Buffer): Buffer;
77
+ }
78
+ ```
79
+
80
+ **Genie (over Electron `safeStorage`):**
81
+
82
+ ```ts
83
+ export function electronEncryptor(): Encryptor {
84
+ return {
85
+ isAvailable: () => {
86
+ try { return safeStorage.isEncryptionAvailable(); } catch { return false; }
87
+ },
88
+ encrypt: (b) => safeStorage.encryptString(b.toString("utf8")),
89
+ decrypt: (b) => Buffer.from(safeStorage.decryptString(b), "utf8"),
90
+ };
91
+ }
92
+ ```
93
+
94
+ **Plain Node** — a passthrough (plaintext) to start, or libsodium/`node:crypto`
95
+ for real at-rest encryption:
96
+
97
+ ```ts
98
+ const passthrough: Encryptor = {
99
+ isAvailable: () => false, // → snapshots stored as plaintext gzip
100
+ encrypt: (b) => b,
101
+ decrypt: (b) => b,
102
+ };
103
+ ```
104
+
105
+ You don't construct the store by hand — pass `{ baseDir, encryptor }` to
106
+ `createSnapshotStore(...)`. It appends `/sessions` under `baseDir` and writes
107
+ `<id>.snap`, matching the historical on-disk layout.
108
+
109
+ ```ts
110
+ const snapshots = createSnapshotStore({ baseDir: userDataDir, encryptor });
111
+ ```
112
+
113
+ ---
114
+
115
+ ## 3. `SnapshotStoreConfig` — where snapshots live
116
+
117
+ Just the two inputs the store needs (the cipher above + a base directory). The
118
+ store itself is the core's `createSnapshotStore`.
119
+
120
+ ```ts
121
+ export interface SnapshotStoreConfig {
122
+ baseDir: string; // was app.getPath("userData")
123
+ encryptor: Encryptor;
124
+ }
125
+ ```
126
+
127
+ **Genie** builds it once and shares the instance between the core backends and
128
+ its quit-time snapshot flow:
129
+
130
+ ```ts
131
+ snapshotStore = createSnapshotStore({
132
+ baseDir: app.getPath("userData"),
133
+ encryptor: electronEncryptor(),
134
+ });
135
+ ```
136
+
137
+ **Plain Node** — any writable directory you control:
138
+
139
+ ```ts
140
+ const snapshots = createSnapshotStore({ baseDir: "/var/app/state", encryptor });
141
+ ```
142
+
143
+ ---
144
+
145
+ ## 4. `HostSpawner` — launch the detached pty-host (T3)
146
+
147
+ Only three OS-specific operations are injected; the *connect-or-spawn-or-fall-
148
+ back* logic is core.
149
+
150
+ ```ts
151
+ export interface HostSpawner {
152
+ resolveHostScript(): string | null; // path to the pty-host script
153
+ spawnDetached(scriptPath: string, env: Record<string, string>): void;
154
+ userDataDir(): string; // pidfile/socket live here
155
+ }
156
+ ```
157
+
158
+ **Genie (Electron — runs the host as plain Node via `ELECTRON_RUN_AS_NODE`):**
159
+
160
+ ```ts
161
+ export function electronHostSpawner(dirname: string): HostSpawner {
162
+ return {
163
+ resolveHostScript: () => resolveHostScriptAt(dirname),
164
+ userDataDir: () => app.getPath("userData"),
165
+ spawnDetached: (scriptPath, env) => {
166
+ const child = spawn(process.execPath, [scriptPath], {
167
+ detached: true,
168
+ stdio: "ignore",
169
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", ...env },
170
+ });
171
+ child.unref();
172
+ },
173
+ };
174
+ }
175
+ ```
176
+
177
+ **Plain Node** — use the package's own `ptyHostScriptPath()` to locate the
178
+ bundled host, and `process.execPath` (the node binary) to run it:
179
+
180
+ ```ts
181
+ import { ptyHostScriptPath } from "@particle-academy/fancy-term-host";
182
+ import { spawn } from "node:child_process";
183
+
184
+ const hostSpawner: HostSpawner = {
185
+ resolveHostScript: () => ptyHostScriptPath(),
186
+ userDataDir: () => "/var/app/state",
187
+ spawnDetached: (scriptPath, env) => {
188
+ const child = spawn(process.execPath, [scriptPath], {
189
+ detached: true,
190
+ stdio: "ignore",
191
+ env: { ...process.env, ...env },
192
+ });
193
+ child.unref();
194
+ },
195
+ };
196
+ ```
197
+
198
+ ---
199
+
200
+ ## Composition
201
+
202
+ Wire the ports once, before spawning any terminal:
203
+
204
+ ```ts
205
+ import {
206
+ configureInProcessBackend,
207
+ inProcessBackend,
208
+ configureHostLifecycle,
209
+ } from "@particle-academy/fancy-term-host";
210
+
211
+ // T1/T2: settings (cwd-hook gating) + snapshot store (cold-spawn restore).
212
+ configureInProcessBackend({ settings, snapshots });
213
+
214
+ // The core EMITS 'cwd' (from OSC-7) instead of writing a DB directly — subscribe
215
+ // and persist it yourself if you want live-cwd to survive a restart.
216
+ inProcessBackend().on("cwd", (id, cwd) => persistLiveCwd(id, cwd));
217
+
218
+ // T3 (optional): spawner + settings + snapshot store + a host-status sink.
219
+ configureHostLifecycle({
220
+ spawner: hostSpawner,
221
+ settings,
222
+ snapshots,
223
+ onHostStatus: (s) => notify(s.level, s.message),
224
+ });
225
+ ```
226
+
227
+ Genie's `wireTerminalAdapter()` is exactly this, plus DB persistence of the
228
+ emitted `cwd` events and a `BrowserWindow` broadcast for host-status — a useful
229
+ template for any host that needs to durably mirror live state.
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@particle-academy/fancy-term-host",
3
+ "version": "0.1.0",
4
+ "description": "Headless Node terminal backend for @particle-academy/fancy-term — owns the PTYs (node-pty) and the T1/T2/T3 persistence engine (snapshot+replay, retained PTYs, detached pty-host) behind four injected ports. OS-agnostic, zero hard third-party deps.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Particle-Academy/fancy-term-host.git"
8
+ },
9
+ "homepage": "https://github.com/Particle-Academy/fancy-term-host#readme",
10
+ "bugs": "https://github.com/Particle-Academy/fancy-term-host/issues",
11
+ "type": "module",
12
+ "main": "./dist/index.cjs",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "import": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "require": {
22
+ "types": "./dist/index.d.cts",
23
+ "default": "./dist/index.cjs"
24
+ }
25
+ },
26
+ "./pty-host": {
27
+ "import": "./dist/pty-host.js",
28
+ "require": "./dist/pty-host.cjs"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "docs",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsup",
40
+ "dev": "tsup --watch",
41
+ "lint": "tsc --noEmit",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest",
44
+ "clean": "rm -rf dist",
45
+ "prepublishOnly": "tsup"
46
+ },
47
+ "keywords": [
48
+ "terminal",
49
+ "pty",
50
+ "node-pty",
51
+ "xterm",
52
+ "shell",
53
+ "fancy",
54
+ "fancy-term",
55
+ "human-plus",
56
+ "osc7",
57
+ "agent"
58
+ ],
59
+ "peerDependencies": {
60
+ "node-pty": "^1.0.0"
61
+ },
62
+ "overrides": {
63
+ "esbuild": "^0.28.1"
64
+ },
65
+ "devDependencies": {
66
+ "@types/node": "^20.0.0",
67
+ "node-pty": "^1.0.0",
68
+ "tsup": "^8.5.0",
69
+ "typescript": "^5.8.0",
70
+ "vitest": "^3.2.0"
71
+ },
72
+ "engines": {
73
+ "node": ">=18"
74
+ },
75
+ "publishConfig": {
76
+ "access": "public"
77
+ },
78
+ "license": "MIT"
79
+ }