@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Particle Academy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<p align="left"><img src="./art/fancy-ui.svg" alt="Fancy UI" height="28"></p>
|
|
2
|
+
|
|
3
|
+
# @particle-academy/fancy-term-host
|
|
4
|
+
|
|
5
|
+
**The headless Node terminal backend for [`@particle-academy/fancy-term`](https://www.npmjs.com/package/@particle-academy/fancy-term).**
|
|
6
|
+
|
|
7
|
+
`fancy-term` is the browser-side React `<Terminal>` — it renders xterm.js and
|
|
8
|
+
deliberately **never spawns a shell**. `fancy-term-host` is the other half: the
|
|
9
|
+
Node process that **owns the PTYs** (via `node-pty`) and a **T1/T2/T3 persistence
|
|
10
|
+
engine** — snapshot+replay, retained PTYs, and a detached pty-host — behind four
|
|
11
|
+
small **injected ports**. It runs anywhere Node runs (Electron main, a Laravel
|
|
12
|
+
queue worker, a plain server): OS-agnostic by construction, with **zero hard
|
|
13
|
+
third-party dependencies** (`node-pty` is a peer the consumer builds).
|
|
14
|
+
|
|
15
|
+
It can't live inside `fancy-term` or the other UI packages — `node-pty` is a
|
|
16
|
+
native addon that would break their browser builds — so it's an independent
|
|
17
|
+
sibling you install alongside.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @particle-academy/fancy-term-host node-pty
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`node-pty` is a **peer dependency**: you own its native build (and, under
|
|
26
|
+
Electron, the `asar-unpack` so its `.node` binary loads outside the archive).
|
|
27
|
+
|
|
28
|
+
## Wire it up
|
|
29
|
+
|
|
30
|
+
You provide four ports (see [`docs/ports.md`](./docs/ports.md)); the core does
|
|
31
|
+
the rest. The minimal in-process (Tier 1/2) setup:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import {
|
|
35
|
+
configureInProcessBackend,
|
|
36
|
+
inProcessBackend,
|
|
37
|
+
createSnapshotStore,
|
|
38
|
+
type SettingsProvider,
|
|
39
|
+
type Encryptor,
|
|
40
|
+
} from "@particle-academy/fancy-term-host";
|
|
41
|
+
|
|
42
|
+
// 1. Settings (gates the cwd hook + T3). Defaults are sensible — track_cwd on.
|
|
43
|
+
const settings: SettingsProvider = { get: (k) => undefined };
|
|
44
|
+
|
|
45
|
+
// 2. A cipher for at-rest snapshots. A passthrough is fine to start with.
|
|
46
|
+
const encryptor: Encryptor = {
|
|
47
|
+
isAvailable: () => false, // → snapshots stored as plaintext gzip
|
|
48
|
+
encrypt: (b) => b,
|
|
49
|
+
decrypt: (b) => b,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// 3. Snapshot store (T1) rooted under a writable dir (`<dir>/sessions/...`).
|
|
53
|
+
const snapshots = createSnapshotStore({ baseDir: "/var/app/userData", encryptor });
|
|
54
|
+
|
|
55
|
+
// 4. Configure + grab the backend.
|
|
56
|
+
configureInProcessBackend({ settings, snapshots });
|
|
57
|
+
const backend = inProcessBackend();
|
|
58
|
+
|
|
59
|
+
// Spawn a shell and stream it to a fancy-term <Terminal> on the client.
|
|
60
|
+
const { id, scrollback } = backend.create({ id: "t1", cols: 80, rows: 24 });
|
|
61
|
+
backend.on("data", (tid, data) => sendToClient(tid, data)); // → <Terminal output>
|
|
62
|
+
backend.on("exit", (tid) => closeOnClient(tid));
|
|
63
|
+
|
|
64
|
+
// Client keystrokes (fancy-term `onData`) come back here:
|
|
65
|
+
onClientData("t1", (d) => backend.write("t1", d));
|
|
66
|
+
onClientResize("t1", (cols, rows) => backend.resize("t1", cols, rows));
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The backend's `create` / `write` / `resize` / `kill` / `list` and its
|
|
70
|
+
`data` / `exit` / `cwd` events are the whole surface the wire needs. Pair it
|
|
71
|
+
with `fancy-term`'s controlled `output` buffer and `onData` and you have a live
|
|
72
|
+
terminal that an agent can also inhabit (via the `registerTerminalBridge` in
|
|
73
|
+
`@particle-academy/agent-integrations`).
|
|
74
|
+
|
|
75
|
+
## Persistence tiers
|
|
76
|
+
|
|
77
|
+
Switchable behind one `PtyBackend` interface (see [`docs/persistence.md`](./docs/persistence.md)):
|
|
78
|
+
|
|
79
|
+
- **T1 — snapshot & replay.** Session state is serialized, (optionally)
|
|
80
|
+
encrypted, gzipped, and written to `<baseDir>/sessions/<id>.snap`. A cold
|
|
81
|
+
start replays it so a reopened terminal shows where it was.
|
|
82
|
+
- **T2 — retained PTYs.** A PTY flagged `setRetained(true)` survives a window
|
|
83
|
+
detach (the live shell keeps running; scrollback replays on reattach) instead
|
|
84
|
+
of being killed.
|
|
85
|
+
- **T3 — detached host.** PTYs live in a separate headless **pty-host** process
|
|
86
|
+
that survives a full quit of the app. The backend proxies calls over a named
|
|
87
|
+
pipe (Windows) / unix socket (POSIX); reopening reattaches to the still-running
|
|
88
|
+
shells.
|
|
89
|
+
|
|
90
|
+
### Spawning the detached host (T3)
|
|
91
|
+
|
|
92
|
+
The bundled host script is resolvable without knowing the dist layout:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { spawn } from "node:child_process";
|
|
96
|
+
import { ptyHostScriptPath } from "@particle-academy/fancy-term-host";
|
|
97
|
+
|
|
98
|
+
const child = spawn(process.execPath, [ptyHostScriptPath(), userDataDir], {
|
|
99
|
+
detached: true,
|
|
100
|
+
stdio: "ignore",
|
|
101
|
+
});
|
|
102
|
+
child.unref();
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Wrap that in a `HostSpawner` and pass it to `configureHostLifecycle(...)` to let
|
|
106
|
+
the core connect-or-spawn-or-fall-back automatically. (`require.resolve(
|
|
107
|
+
"@particle-academy/fancy-term-host/pty-host")` also resolves the script.)
|
|
108
|
+
|
|
109
|
+
## cwd tracking (OSC-7)
|
|
110
|
+
|
|
111
|
+
The host learns each terminal's working directory from **OSC-7** escape
|
|
112
|
+
sequences the shell emits on every prompt, so a resumed shell can start where the
|
|
113
|
+
old one left off. `fancy-term-host` injects the prompt hook for you, gated by the
|
|
114
|
+
`track_cwd` setting (default **on**):
|
|
115
|
+
|
|
116
|
+
| Shell | Mechanism | Status |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| **bash** | prepends an OSC-7 `printf` to `PROMPT_COMMAND` (env) | ✅ |
|
|
119
|
+
| **zsh** | generated `ZDOTDIR` whose rc sources yours + adds a `precmd` | ✅ |
|
|
120
|
+
| **fish** | generated `vendor_conf.d` via `XDG_DATA_DIRS` (`--on-event fish_prompt`) | ✅ |
|
|
121
|
+
| **PowerShell** | dot-sourced profile shim wrapping your `prompt` (appended launch args) | ✅ |
|
|
122
|
+
| **cmd.exe** | `PROMPT` with the `$E` escape | ⚠️ best-effort (only where the console honors VT in the prompt) |
|
|
123
|
+
|
|
124
|
+
Every hook **overlays** your shell config — it never clobbers your prompt or rc.
|
|
125
|
+
Any shell that can't be hooked degrades silently to the static `cwd`.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## ⭐ Star Fancy UI
|
|
134
|
+
|
|
135
|
+
If this package is useful to you, a quick ⭐ on the repo really helps us build a
|
|
136
|
+
better kit. Thank you!
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
// src/host-protocol.ts
|
|
7
|
+
var PROTOCOL_VERSION = 1;
|
|
8
|
+
var LENGTH_BYTES = 4;
|
|
9
|
+
function encodeFrame(msg) {
|
|
10
|
+
const body = Buffer.from(JSON.stringify(msg), "utf8");
|
|
11
|
+
const header = Buffer.allocUnsafe(LENGTH_BYTES);
|
|
12
|
+
header.writeUInt32BE(body.length, 0);
|
|
13
|
+
return Buffer.concat([header, body]);
|
|
14
|
+
}
|
|
15
|
+
var _FrameDecoder = class _FrameDecoder {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.buffer = Buffer.alloc(0);
|
|
18
|
+
/** True when the last push hit an oversized/desynced frame. The caller
|
|
19
|
+
* should drop the connection — the stream can't be trusted to realign. */
|
|
20
|
+
this.desynced = false;
|
|
21
|
+
}
|
|
22
|
+
push(chunk) {
|
|
23
|
+
this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;
|
|
24
|
+
const out = [];
|
|
25
|
+
for (; ; ) {
|
|
26
|
+
if (this.buffer.length < LENGTH_BYTES) break;
|
|
27
|
+
const len = this.buffer.readUInt32BE(0);
|
|
28
|
+
if (len > _FrameDecoder.MAX_FRAME) {
|
|
29
|
+
this.desynced = true;
|
|
30
|
+
this.buffer = Buffer.alloc(0);
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
if (this.buffer.length < LENGTH_BYTES + len) break;
|
|
34
|
+
const body = this.buffer.subarray(LENGTH_BYTES, LENGTH_BYTES + len);
|
|
35
|
+
this.buffer = this.buffer.subarray(LENGTH_BYTES + len);
|
|
36
|
+
try {
|
|
37
|
+
out.push(JSON.parse(body.toString("utf8")));
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/** Hard cap on a single frame (16 MB). Guards against a runaway/garbage
|
|
45
|
+
* length prefix allocating unbounded memory. node-pty data chunks are tiny;
|
|
46
|
+
* a serialized scrollback is bounded well under this. */
|
|
47
|
+
_FrameDecoder.MAX_FRAME = 16 * 1024 * 1024;
|
|
48
|
+
var FrameDecoder = _FrameDecoder;
|
|
49
|
+
function userHash() {
|
|
50
|
+
const seed = `${os.userInfo().username}|${os.hostname()}`;
|
|
51
|
+
return crypto.createHash("sha1").update(seed).digest("hex").slice(0, 12);
|
|
52
|
+
}
|
|
53
|
+
function socketPathFor(userDataDir) {
|
|
54
|
+
if (process.platform === "win32") {
|
|
55
|
+
return `\\\\.\\pipe\\genie-ptyhost-${userHash()}`;
|
|
56
|
+
}
|
|
57
|
+
const candidate = path.join(userDataDir, "ptyhost.sock");
|
|
58
|
+
if (candidate.length < 100) return candidate;
|
|
59
|
+
return path.join(os.tmpdir(), `genie-ptyhost-${userHash()}.sock`);
|
|
60
|
+
}
|
|
61
|
+
function pidfilePath(userDataDir) {
|
|
62
|
+
return path.join(userDataDir, "ptyhost.json");
|
|
63
|
+
}
|
|
64
|
+
function writePidfile(userDataDir, pf) {
|
|
65
|
+
const target = pidfilePath(userDataDir);
|
|
66
|
+
const tmp = `${target}.tmp`;
|
|
67
|
+
fs.writeFileSync(tmp, JSON.stringify(pf));
|
|
68
|
+
fs.renameSync(tmp, target);
|
|
69
|
+
}
|
|
70
|
+
function readPidfile(userDataDir) {
|
|
71
|
+
try {
|
|
72
|
+
const raw = fs.readFileSync(pidfilePath(userDataDir), "utf8");
|
|
73
|
+
const pf = JSON.parse(raw);
|
|
74
|
+
if (typeof pf.pid !== "number" || typeof pf.socketPath !== "string" || typeof pf.protocolVersion !== "number") {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return pf;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function deletePidfile(userDataDir) {
|
|
83
|
+
try {
|
|
84
|
+
fs.rmSync(pidfilePath(userDataDir), { force: true });
|
|
85
|
+
} catch {
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function isPidAlive(pid) {
|
|
89
|
+
if (!pid || pid <= 0) return false;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return err.code === "EPERM";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function pidfileUsable(pf) {
|
|
98
|
+
if (!pf) return false;
|
|
99
|
+
if (pf.protocolVersion !== PROTOCOL_VERSION) return false;
|
|
100
|
+
if (!isPidAlive(pf.pid)) return false;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
function resolveHostScript(dirname) {
|
|
104
|
+
const candidates = [
|
|
105
|
+
// Packaged: node-pty must be unpacked, so run the host from the unpacked
|
|
106
|
+
// tree too (its require('node-pty') resolves to the unpacked .node).
|
|
107
|
+
dirname.includes(`app.asar${path.sep}`) || dirname.includes("app.asar/") ? dirname.replace(
|
|
108
|
+
/app\.asar([\\/])/,
|
|
109
|
+
`app.asar.unpacked$1`
|
|
110
|
+
) + path.sep + "pty-host.js" : "",
|
|
111
|
+
// Same dir as the compiled main bundle (dev: app/pty-host.js).
|
|
112
|
+
path.join(dirname, "pty-host.js"),
|
|
113
|
+
// Defensive: a sibling unpacked dir computed from the asar path.
|
|
114
|
+
path.join(dirname.replace("app.asar", "app.asar.unpacked"), "pty-host.js")
|
|
115
|
+
].filter(Boolean);
|
|
116
|
+
for (const c of candidates) {
|
|
117
|
+
try {
|
|
118
|
+
if (fs.existsSync(c)) return c;
|
|
119
|
+
} catch {
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export { FrameDecoder, PROTOCOL_VERSION, deletePidfile, encodeFrame, isPidAlive, pidfilePath, pidfileUsable, readPidfile, resolveHostScript, socketPathFor, userHash, writePidfile };
|
|
126
|
+
//# sourceMappingURL=chunk-2DQJKTG5.js.map
|
|
127
|
+
//# sourceMappingURL=chunk-2DQJKTG5.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/host-protocol.ts","../src/host-locate.ts"],"names":[],"mappings":";;;;;;AAuBO,IAAM,gBAAA,GAAmB;AAoDhC,IAAM,YAAA,GAAe,CAAA;AAGd,SAAS,YAAY,GAAA,EAAoB;AAC5C,EAAA,MAAM,OAAO,MAAA,CAAO,IAAA,CAAK,KAAK,SAAA,CAAU,GAAG,GAAG,MAAM,CAAA;AACpD,EAAA,MAAM,MAAA,GAAS,MAAA,CAAO,WAAA,CAAY,YAAY,CAAA;AAC9C,EAAA,MAAA,CAAO,aAAA,CAAc,IAAA,CAAK,MAAA,EAAQ,CAAC,CAAA;AACnC,EAAA,OAAO,MAAA,CAAO,MAAA,CAAO,CAAC,MAAA,EAAQ,IAAI,CAAC,CAAA;AACvC;AAYO,IAAM,aAAA,GAAN,MAAM,aAAA,CAAa;AAAA,EAAnB,WAAA,GAAA;AACH,IAAA,IAAA,CAAQ,MAAA,GAAiB,MAAA,CAAO,KAAA,CAAM,CAAC,CAAA;AASvC;AAAA;AAAA,IAAA,IAAA,CAAA,QAAA,GAAW,KAAA;AAAA,EAAA;AAAA,EAEX,KAAK,KAAA,EAAwB;AACzB,IAAA,IAAA,CAAK,MAAA,GAAS,IAAA,CAAK,MAAA,CAAO,MAAA,GAAS,MAAA,CAAO,MAAA,CAAO,CAAC,IAAA,CAAK,MAAA,EAAQ,KAAK,CAAC,CAAA,GAAI,KAAA;AACzE,IAAA,MAAM,MAAe,EAAC;AACtB,IAAA,WAAS;AACL,MAAA,IAAI,IAAA,CAAK,MAAA,CAAO,MAAA,GAAS,YAAA,EAAc;AACvC,MAAA,MAAM,GAAA,GAAM,IAAA,CAAK,MAAA,CAAO,YAAA,CAAa,CAAC,CAAA;AACtC,MAAA,IAAI,GAAA,GAAM,cAAa,SAAA,EAAW;AAG9B,QAAA,IAAA,CAAK,QAAA,GAAW,IAAA;AAChB,QAAA,IAAA,CAAK,MAAA,GAAS,MAAA,CAAO,KAAA,CAAM,CAAC,CAAA;AAC5B,QAAA;AAAA,MACJ;AACA,MAAA,IAAI,IAAA,CAAK,MAAA,CAAO,MAAA,GAAS,YAAA,GAAe,GAAA,EAAK;AAC7C,MAAA,MAAM,OAAO,IAAA,CAAK,MAAA,CAAO,QAAA,CAAS,YAAA,EAAc,eAAe,GAAG,CAAA;AAClE,MAAA,IAAA,CAAK,MAAA,GAAS,IAAA,CAAK,MAAA,CAAO,QAAA,CAAS,eAAe,GAAG,CAAA;AACrD,MAAA,IAAI;AACA,QAAA,GAAA,CAAI,KAAK,IAAA,CAAK,KAAA,CAAM,KAAK,QAAA,CAAS,MAAM,CAAC,CAAU,CAAA;AAAA,MACvD,CAAA,CAAA,MAAQ;AAAA,MAER;AAAA,IACJ;AACA,IAAA,OAAO,GAAA;AAAA,EACX;AACJ,CAAA;AAAA;AAAA;AAAA;AApCa,aAAA,CAMO,SAAA,GAAY,KAAK,IAAA,GAAO,IAAA;AANrC,IAAM,YAAA,GAAN;ACxEA,SAAS,QAAA,GAAmB;AAC/B,EAAA,MAAM,IAAA,GAAO,GAAG,EAAA,CAAG,QAAA,GAAW,QAAQ,CAAA,CAAA,EAAI,EAAA,CAAG,QAAA,EAAU,CAAA,CAAA;AACvD,EAAA,OAAO,MAAA,CAAO,UAAA,CAAW,MAAM,CAAA,CAAE,MAAA,CAAO,IAAI,CAAA,CAAE,MAAA,CAAO,KAAK,CAAA,CAAE,KAAA,CAAM,CAAA,EAAG,EAAE,CAAA;AAC3E;AAUO,SAAS,cAAc,WAAA,EAA6B;AACvD,EAAA,IAAI,OAAA,CAAQ,aAAa,OAAA,EAAS;AAC9B,IAAA,OAAO,CAAA,2BAAA,EAA8B,UAAU,CAAA,CAAA;AAAA,EACnD;AAIA,EAAA,MAAM,SAAA,GAAY,IAAA,CAAK,IAAA,CAAK,WAAA,EAAa,cAAc,CAAA;AACvD,EAAA,IAAI,SAAA,CAAU,MAAA,GAAS,GAAA,EAAK,OAAO,SAAA;AACnC,EAAA,OAAO,IAAA,CAAK,KAAK,EAAA,CAAG,MAAA,IAAU,CAAA,cAAA,EAAiB,QAAA,EAAU,CAAA,KAAA,CAAO,CAAA;AACpE;AAEO,SAAS,YAAY,WAAA,EAA6B;AACrD,EAAA,OAAO,IAAA,CAAK,IAAA,CAAK,WAAA,EAAa,cAAc,CAAA;AAChD;AAEO,SAAS,YAAA,CAAa,aAAqB,EAAA,EAAmB;AACjE,EAAA,MAAM,MAAA,GAAS,YAAY,WAAW,CAAA;AACtC,EAAA,MAAM,GAAA,GAAM,GAAG,MAAM,CAAA,IAAA,CAAA;AACrB,EAAA,EAAA,CAAG,aAAA,CAAc,GAAA,EAAK,IAAA,CAAK,SAAA,CAAU,EAAE,CAAC,CAAA;AACxC,EAAA,EAAA,CAAG,UAAA,CAAW,KAAK,MAAM,CAAA;AAC7B;AAEO,SAAS,YAAY,WAAA,EAAqC;AAC7D,EAAA,IAAI;AACA,IAAA,MAAM,MAAM,EAAA,CAAG,YAAA,CAAa,WAAA,CAAY,WAAW,GAAG,MAAM,CAAA;AAC5D,IAAA,MAAM,EAAA,GAAK,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA;AACzB,IAAA,IACI,OAAO,EAAA,CAAG,GAAA,KAAQ,QAAA,IAClB,OAAO,EAAA,CAAG,UAAA,KAAe,QAAA,IACzB,OAAO,EAAA,CAAG,eAAA,KAAoB,QAAA,EAChC;AACE,MAAA,OAAO,IAAA;AAAA,IACX;AACA,IAAA,OAAO,EAAA;AAAA,EACX,CAAA,CAAA,MAAQ;AACJ,IAAA,OAAO,IAAA;AAAA,EACX;AACJ;AAEO,SAAS,cAAc,WAAA,EAA2B;AACrD,EAAA,IAAI;AACA,IAAA,EAAA,CAAG,OAAO,WAAA,CAAY,WAAW,GAAG,EAAE,KAAA,EAAO,MAAM,CAAA;AAAA,EACvD,CAAA,CAAA,MAAQ;AAAA,EAER;AACJ;AAGO,SAAS,WAAW,GAAA,EAAsB;AAC7C,EAAA,IAAI,CAAC,GAAA,IAAO,GAAA,IAAO,CAAA,EAAG,OAAO,KAAA;AAC7B,EAAA,IAAI;AACA,IAAA,OAAA,CAAQ,IAAA,CAAK,KAAK,CAAC,CAAA;AACnB,IAAA,OAAO,IAAA;AAAA,EACX,SAAS,GAAA,EAAK;AAEV,IAAA,OAAQ,IAA8B,IAAA,KAAS,OAAA;AAAA,EACnD;AACJ;AAOO,SAAS,cAAc,EAAA,EAA6B;AACvD,EAAA,IAAI,CAAC,IAAI,OAAO,KAAA;AAChB,EAAA,IAAI,EAAA,CAAG,eAAA,KAAoB,gBAAA,EAAkB,OAAO,KAAA;AACpD,EAAA,IAAI,CAAC,UAAA,CAAW,EAAA,CAAG,GAAG,GAAG,OAAO,KAAA;AAChC,EAAA,OAAO,IAAA;AACX;AAaO,SAAS,kBAAkB,OAAA,EAAgC;AAC9D,EAAA,MAAM,UAAA,GAAa;AAAA;AAAA;AAAA,IAGf,OAAA,CAAQ,QAAA,CAAS,CAAA,QAAA,EAAW,IAAA,CAAK,GAAG,CAAA,CAAE,CAAA,IAAK,OAAA,CAAQ,QAAA,CAAS,WAAW,CAAA,GACjE,OAAA,CAAQ,OAAA;AAAA,MACJ,kBAAA;AAAA,MACA,CAAA,mBAAA;AAAA,KACJ,GAAI,IAAA,CAAK,GAAA,GAAM,aAAA,GACf,EAAA;AAAA;AAAA,IAEN,IAAA,CAAK,IAAA,CAAK,OAAA,EAAS,aAAa,CAAA;AAAA;AAAA,IAEhC,KAAK,IAAA,CAAK,OAAA,CAAQ,QAAQ,UAAA,EAAY,mBAAmB,GAAG,aAAa;AAAA,GAC7E,CAAE,OAAO,OAAO,CAAA;AAEhB,EAAA,KAAA,MAAW,KAAK,UAAA,EAAY;AACxB,IAAA,IAAI;AACA,MAAA,IAAI,EAAA,CAAG,UAAA,CAAW,CAAC,CAAA,EAAG,OAAO,CAAA;AAAA,IACjC,CAAA,CAAA,MAAQ;AAAA,IAER;AAAA,EACJ;AACA,EAAA,OAAO,IAAA;AACX","file":"chunk-2DQJKTG5.js","sourcesContent":["/**\n * Pty-host wire protocol (Tier 3).\n *\n * The detached pty-host (main/terminal/pty-host.ts) and the in-app HostClient\n * (main/terminal/host-client.ts) talk over a local IPC transport — a named pipe\n * on Windows, a unix domain socket on POSIX — using a tiny length-prefixed JSON\n * framing so there's no heavy dependency. This module is PURE (no electron, no\n * node-pty, no net): just the message shapes + the encode/decode for the framing,\n * so it can be imported by both ends AND unit-tested in isolation.\n *\n * Framing: each message is `[4-byte big-endian uint32 length][utf8 JSON body]`.\n * The length prefix is the byte length of the JSON body. A FrameDecoder buffers\n * partial reads and yields whole messages as they complete — TCP/pipe streams\n * don't preserve message boundaries, so we can't assume one `data` event == one\n * message.\n */\n\n/**\n * Protocol version. Bumped whenever the message shapes change in a way that\n * makes an old host incompatible with a new client (or vice-versa). The client\n * refuses to attach to a host whose pidfile reports a different version and\n * spawns a fresh host instead — see host-client.ts connect-or-spawn.\n */\nexport const PROTOCOL_VERSION = 1;\n\n/** Requests the client sends to the host. `seq` correlates a reply. */\nexport type ClientMessage =\n | { kind: 'hello'; seq: number; protocolVersion: number }\n | {\n kind: 'create';\n seq: number;\n 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 };\n }\n | { kind: 'write'; id: string; data: string }\n | { kind: 'resize'; id: string; cols: number; rows: number }\n | { kind: 'kill'; id: string }\n | { kind: 'list'; seq: number }\n | { kind: 'set-retained'; id: string; retained: boolean }\n | { kind: 'get-scrollback'; seq: number; id: string }\n | { kind: 'ping'; seq: number };\n\n/** Pushes + replies the host sends to the client. */\nexport type HostMessage =\n | { kind: 'hello-ok'; seq: number; protocolVersion: number; pid: number }\n | {\n kind: 'created';\n seq: number;\n result: {\n id: string;\n pid: number;\n shell: string;\n existing: boolean;\n scrollback: string;\n };\n }\n | {\n kind: 'list-result';\n seq: number;\n terminals: Array<{ id: string; pid: number; shell: string }>;\n }\n | { kind: 'scrollback-result'; seq: number; scrollback: string | null }\n | { kind: 'pong'; seq: number }\n | { kind: 'data'; id: string; data: string }\n | { kind: 'exit'; id: string; exitCode: number; signal?: number };\n\nexport type Frame = ClientMessage | HostMessage;\n\nconst LENGTH_BYTES = 4;\n\n/** Encode a message as a length-prefixed JSON frame ready for the socket. */\nexport function encodeFrame(msg: Frame): Buffer {\n const body = Buffer.from(JSON.stringify(msg), 'utf8');\n const header = Buffer.allocUnsafe(LENGTH_BYTES);\n header.writeUInt32BE(body.length, 0);\n return Buffer.concat([header, body]);\n}\n\n/**\n * Streaming frame decoder. Feed it raw socket chunks via `push`; it returns the\n * complete messages that became available (zero or more), buffering any partial\n * tail until the rest arrives. One decoder per socket.\n *\n * Resilient by design: a malformed JSON body is skipped (the frame is consumed\n * but yields nothing) rather than throwing — a corrupt frame must not wedge the\n * whole stream. An absurd length prefix (> MAX_FRAME) is treated as a desync and\n * the buffer is reset; the caller can decide whether to drop the connection.\n */\nexport class FrameDecoder {\n private buffer: Buffer = Buffer.alloc(0);\n\n /** Hard cap on a single frame (16 MB). Guards against a runaway/garbage\n * length prefix allocating unbounded memory. node-pty data chunks are tiny;\n * a serialized scrollback is bounded well under this. */\n static readonly MAX_FRAME = 16 * 1024 * 1024;\n\n /** True when the last push hit an oversized/desynced frame. The caller\n * should drop the connection — the stream can't be trusted to realign. */\n desynced = false;\n\n push(chunk: Buffer): Frame[] {\n this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;\n const out: Frame[] = [];\n for (;;) {\n if (this.buffer.length < LENGTH_BYTES) break;\n const len = this.buffer.readUInt32BE(0);\n if (len > FrameDecoder.MAX_FRAME) {\n // Desync / garbage. Reset and flag — realigning a length-prefixed\n // stream after a bad prefix isn't possible without a sentinel.\n this.desynced = true;\n this.buffer = Buffer.alloc(0);\n break;\n }\n if (this.buffer.length < LENGTH_BYTES + len) break; // wait for more\n const body = this.buffer.subarray(LENGTH_BYTES, LENGTH_BYTES + len);\n this.buffer = this.buffer.subarray(LENGTH_BYTES + len);\n try {\n out.push(JSON.parse(body.toString('utf8')) as Frame);\n } catch {\n /* skip a corrupt frame; the framing itself is still aligned */\n }\n }\n return out;\n }\n}\n","import path from 'node:path';\nimport os from 'node:os';\nimport fs from 'node:fs';\nimport crypto from 'node:crypto';\nimport { PROTOCOL_VERSION } from './host-protocol';\n\n/**\n * Path + pidfile resolution for the detached pty-host (Tier 3).\n *\n * Kept ELECTRON-FREE on the resolution side that the host itself uses (the host\n * is a plain node process — no `app`), so the userData path is passed IN. The\n * in-app side (host-client lifecycle) imports `app` separately and feeds it here.\n */\n\nexport interface Pidfile {\n pid: number;\n socketPath: string;\n protocolVersion: number;\n startedAt: number;\n}\n\n/** Short, stable per-user hash so two OS users don't collide on the Windows\n * pipe name (the pipe namespace is machine-global). */\nexport function userHash(): string {\n const seed = `${os.userInfo().username}|${os.hostname()}`;\n return crypto.createHash('sha1').update(seed).digest('hex').slice(0, 12);\n}\n\n/**\n * The local IPC transport address.\n * • Windows: a named pipe `\\\\.\\pipe\\genie-ptyhost-<userhash>`. The default\n * Windows pipe ACL is per-logon-session, so another user on the same machine\n * can't open it — that's our ACL. (Documented; we don't tighten further.)\n * • POSIX: a unix domain socket under userData (preferred — survives /tmp\n * cleaners and is per-user by directory perms) named `ptyhost.sock`.\n */\nexport function socketPathFor(userDataDir: string): string {\n if (process.platform === 'win32') {\n return `\\\\\\\\.\\\\pipe\\\\genie-ptyhost-${userHash()}`;\n }\n // Keep the path short — unix socket paths have a ~104-char limit. userData is\n // typically well under that; fall back to os.tmpdir() if it's pathologically\n // long.\n const candidate = path.join(userDataDir, 'ptyhost.sock');\n if (candidate.length < 100) return candidate;\n return path.join(os.tmpdir(), `genie-ptyhost-${userHash()}.sock`);\n}\n\nexport function pidfilePath(userDataDir: string): string {\n return path.join(userDataDir, 'ptyhost.json');\n}\n\nexport function writePidfile(userDataDir: string, pf: Pidfile): void {\n const target = pidfilePath(userDataDir);\n const tmp = `${target}.tmp`;\n fs.writeFileSync(tmp, JSON.stringify(pf));\n fs.renameSync(tmp, target);\n}\n\nexport function readPidfile(userDataDir: string): Pidfile | null {\n try {\n const raw = fs.readFileSync(pidfilePath(userDataDir), 'utf8');\n const pf = JSON.parse(raw) as Pidfile;\n if (\n typeof pf.pid !== 'number' ||\n typeof pf.socketPath !== 'string' ||\n typeof pf.protocolVersion !== 'number'\n ) {\n return null;\n }\n return pf;\n } catch {\n return null;\n }\n}\n\nexport function deletePidfile(userDataDir: string): void {\n try {\n fs.rmSync(pidfilePath(userDataDir), { force: true });\n } catch {\n /* ignore */\n }\n}\n\n/** True when a process with `pid` is alive (signal 0 probes without killing). */\nexport function isPidAlive(pid: number): boolean {\n if (!pid || pid <= 0) return false;\n try {\n process.kill(pid, 0);\n return true;\n } catch (err) {\n // EPERM = exists but not ours (still \"alive\"); ESRCH = gone.\n return (err as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\n/**\n * Decide whether an existing pidfile points at a usable host.\n * Usable = pid alive AND protocol versions match. A stale/dead/mismatched\n * pidfile means we must spawn a fresh host.\n */\nexport function pidfileUsable(pf: Pidfile | null): boolean {\n if (!pf) return false;\n if (pf.protocolVersion !== PROTOCOL_VERSION) return false;\n if (!isPidAlive(pf.pid)) return false;\n return true;\n}\n\n/**\n * Resolve the compiled pty-host script on disk, trying multiple candidate paths\n * so it works in BOTH `npm run dev` (script at app/pty-host.js next to\n * background.js) AND a packaged asar build. node-pty's native binding can't load\n * from inside an asar, so the host (which requires node-pty) must run UNPACKED —\n * `app.asar.unpacked/...`. We try the unpacked path first, then the in-asar path,\n * then a dev-relative path. Returns the first that exists, or null.\n *\n * `dirname` is main/background's __dirname (the directory the compiled main\n * bundle lives in). The host script is emitted alongside it as `pty-host.js`.\n */\nexport function resolveHostScript(dirname: string): string | null {\n const candidates = [\n // Packaged: node-pty must be unpacked, so run the host from the unpacked\n // tree too (its require('node-pty') resolves to the unpacked .node).\n dirname.includes(`app.asar${path.sep}`) || dirname.includes('app.asar/')\n ? dirname.replace(\n /app\\.asar([\\\\/])/,\n `app.asar.unpacked$1`,\n ) + path.sep + 'pty-host.js'\n : '',\n // Same dir as the compiled main bundle (dev: app/pty-host.js).\n path.join(dirname, 'pty-host.js'),\n // Defensive: a sibling unpacked dir computed from the asar path.\n path.join(dirname.replace('app.asar', 'app.asar.unpacked'), 'pty-host.js'),\n ].filter(Boolean);\n\n for (const c of candidates) {\n try {\n if (fs.existsSync(c)) return c;\n } catch {\n /* keep trying */\n }\n }\n return null;\n}\n"]}
|