@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/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/chunk-2DQJKTG5.js +127 -0
- package/dist/chunk-2DQJKTG5.js.map +1 -0
- package/dist/index.cjs +1182 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +777 -0
- package/dist/index.d.ts +777 -0
- package/dist/index.js +1015 -0
- package/dist/index.js.map +1 -0
- package/dist/pty-host.cjs +309 -0
- package/dist/pty-host.cjs.map +1 -0
- package/dist/pty-host.d.cts +2 -0
- package/dist/pty-host.d.ts +2 -0
- package/dist/pty-host.js +236 -0
- package/dist/pty-host.js.map +1 -0
- package/docs/persistence.md +92 -0
- package/docs/ports.md +229 -0
- package/package.json +79 -0
package/dist/pty-host.js
ADDED
|
@@ -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
|
+
}
|