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