@termfleet/terminal 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/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # `@termfleet/terminal`
2
+
3
+ Framework-neutral terminal transport and durable tmux session primitives shared
4
+ by Termfleet and embedders such as Supercode.
5
+
6
+ The package owns terminal attachment lifetimes. Closing an attachment releases
7
+ its WebSocket, PTY client, listeners, timers, and file descriptors; it does not
8
+ terminate the durable tmux session. Session termination remains a separate,
9
+ explicit operation controlled by the embedding host.
10
+
11
+ Exports are split by environment without splitting the package:
12
+
13
+ - `@termfleet/terminal/client.js` — browser-safe terminal WebSocket client and
14
+ wire contract.
15
+ - `@termfleet/terminal/attach.js` — Node CLI attachment loop with deterministic
16
+ TTY restoration and `Ctrl-b d` detach.
17
+ - `@termfleet/terminal/tmux.js` — Node tmux session, capture, input, and ownership
18
+ primitives.
19
+ - `@termfleet/terminal/tmux-stream.js` — Node PTY-to-WebSocket attachment with
20
+ idempotent handle cleanup.
21
+
22
+ `@homebridge/node-pty-prebuilt-multiarch` and `ws` are optional peers because
23
+ browser-only consumers need neither. A host importing `tmux-stream.js` must
24
+ provide both.
25
+
26
+ The tmux helpers accept an optional `socket` everywhere, so applications can
27
+ use an isolated tmux server instead of touching the user's default server.
28
+ Created sessions can carry an app-namespaced ownership mark; discovery does not
29
+ claim or terminate unmarked sessions. Terminal attachment is intentionally
30
+ separate from session termination: disposing a viewer only detaches its
31
+ ephemeral PTY client and releases all handles.
@@ -0,0 +1,29 @@
1
+ import { Buffer } from "node:buffer";
2
+ export declare const detachPrefixByte = 2;
3
+ export declare const detachKeyByte = 100;
4
+ export type DetachFilter = {
5
+ feed: (chunk: Buffer | string) => void;
6
+ };
7
+ export declare function createDetachFilter(opts: {
8
+ key?: number;
9
+ onDetach: () => void;
10
+ onInput: (data: string) => void;
11
+ prefix?: number;
12
+ }): DetachFilter;
13
+ export type AttachResult = {
14
+ exitCode?: number;
15
+ reason: "detached" | "exited";
16
+ };
17
+ export type AttachStreams = {
18
+ stdin?: NodeJS.ReadStream;
19
+ stdout?: NodeJS.WritableStream & {
20
+ columns?: number;
21
+ rows?: number;
22
+ };
23
+ };
24
+ export declare function attachTerminal(opts: {
25
+ authToken?: () => string | undefined;
26
+ baseUrl: string;
27
+ streams?: AttachStreams;
28
+ terminalId: string;
29
+ }): Promise<AttachResult>;
package/dist/attach.js ADDED
@@ -0,0 +1,141 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { TerminalClient } from "./client.js";
3
+ // Connect the calling terminal directly to a live provider terminal — the raw
4
+ // PTY pipe behind `termfleet attach` / `control` / `chat --attach`. It reuses
5
+ // the same duplex socket the browser mirror speaks (TerminalClient: an `output`
6
+ // stream in, `input` writes out), so there is no second transport: stdin →
7
+ // input, output → stdout, and a tmux-style prefix key detaches without killing
8
+ // the session (it stays live on the provider, durable on disk, reapable).
9
+ // Ctrl-b, then `d` — the tmux detach chord. A lone prefix is held until the next
10
+ // key so a real Ctrl-b still reaches the inner program (delayed one keystroke);
11
+ // Ctrl-b Ctrl-b sends a literal Ctrl-b. This is the ONE place the chord is
12
+ // decoded, kept pure (no socket/tty) so it is unit-testable.
13
+ export const detachPrefixByte = 0x02; // Ctrl-b
14
+ export const detachKeyByte = 0x64; // d
15
+ export function createDetachFilter(opts) {
16
+ const prefix = opts.prefix ?? detachPrefixByte;
17
+ const key = opts.key ?? detachKeyByte;
18
+ let armed = false;
19
+ return {
20
+ feed(chunk) {
21
+ const bytes = typeof chunk === "string" ? [...Buffer.from(chunk, "utf8")] : [...chunk];
22
+ const out = [];
23
+ const flush = () => {
24
+ if (out.length === 0)
25
+ return;
26
+ opts.onInput(Buffer.from(out).toString("utf8"));
27
+ out.length = 0;
28
+ };
29
+ for (const byte of bytes) {
30
+ if (armed) {
31
+ armed = false;
32
+ if (byte === key) {
33
+ flush();
34
+ opts.onDetach();
35
+ return;
36
+ }
37
+ // Ctrl-b Ctrl-b → a literal Ctrl-b reaches the inner program; any
38
+ // other key after the prefix forwards both bytes unchanged.
39
+ out.push(prefix);
40
+ if (byte !== prefix)
41
+ out.push(byte);
42
+ continue;
43
+ }
44
+ if (byte === prefix) {
45
+ armed = true;
46
+ continue;
47
+ }
48
+ out.push(byte);
49
+ }
50
+ flush();
51
+ }
52
+ };
53
+ }
54
+ // Raw-pipe the calling terminal to `terminalId` on the provider at `baseUrl`.
55
+ // Resolves when the remote pane exits or the user detaches (Ctrl-b d). Restores
56
+ // the local tty on every exit path. The pane size is fixed at connect from the
57
+ // current terminal — the socket protocol carries no live resize yet.
58
+ export async function attachTerminal(opts) {
59
+ const stdin = opts.streams?.stdin ?? process.stdin;
60
+ const stdout = opts.streams?.stdout ?? process.stdout;
61
+ const cols = stdout.columns ?? 80;
62
+ const rows = stdout.rows ?? 24;
63
+ const client = new TerminalClient({
64
+ ...(opts.authToken ? { authToken: opts.authToken } : {}),
65
+ baseUrl: opts.baseUrl,
66
+ cols,
67
+ rows,
68
+ terminalId: opts.terminalId
69
+ });
70
+ const socket = client.connect();
71
+ return await new Promise((resolve, reject) => {
72
+ let settled = false;
73
+ const onStdin = (chunk) => filter.feed(chunk);
74
+ // A real Ctrl-C is forwarded to the remote pane as a raw byte (raw mode), so
75
+ // it never lands here — these fire only on an EXTERNAL kill (or a parent
76
+ // termination). Without restoring the tty first, the process would die with
77
+ // raw mode still on, leaving the user's shell with no echo (needs `reset`).
78
+ const onSignal = () => finish({ reason: "detached" });
79
+ const cleanup = () => {
80
+ stdin.off("data", onStdin);
81
+ process.off("SIGINT", onSignal);
82
+ process.off("SIGTERM", onSignal);
83
+ if (stdin.isTTY)
84
+ stdin.setRawMode(false);
85
+ stdin.pause();
86
+ try {
87
+ client.close();
88
+ }
89
+ catch {
90
+ /* socket already closing */
91
+ }
92
+ };
93
+ const finish = (result) => {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ cleanup();
98
+ resolve(result);
99
+ };
100
+ process.once("SIGINT", onSignal);
101
+ process.once("SIGTERM", onSignal);
102
+ const filter = createDetachFilter({
103
+ onDetach: () => finish({ reason: "detached" }),
104
+ onInput: (data) => {
105
+ if (socket.readyState === socket.OPEN)
106
+ client.write(data);
107
+ }
108
+ });
109
+ socket.onopen = () => {
110
+ if (stdin.isTTY)
111
+ stdin.setRawMode(true);
112
+ stdin.resume();
113
+ stdin.on("data", onStdin);
114
+ };
115
+ socket.onmessage = (event) => {
116
+ // After detach/exit, a still-buffered frame must not write stray bytes to
117
+ // the now-foreground shell.
118
+ if (settled)
119
+ return;
120
+ const raw = typeof event.data === "string" ? event.data : String(event.data);
121
+ const message = client.parse(raw);
122
+ if (message.type === "output") {
123
+ stdout.write(message.data);
124
+ }
125
+ else if (message.type === "exit") {
126
+ finish({ exitCode: message.exitCode, reason: "exited" });
127
+ }
128
+ else if (message.type === "error" || message.type === "input-rejected") {
129
+ stdout.write(`\r\n[attach] ${message.message}\r\n`);
130
+ }
131
+ };
132
+ socket.onclose = () => finish({ reason: "exited" });
133
+ socket.onerror = () => {
134
+ if (settled)
135
+ return;
136
+ settled = true;
137
+ cleanup();
138
+ reject(new Error(`Could not attach to terminal ${opts.terminalId} at ${opts.baseUrl}.`));
139
+ };
140
+ });
141
+ }
@@ -0,0 +1,53 @@
1
+ export type TerminalUrlResolver = {
2
+ wsAuthQuery(): Record<string, string>;
3
+ wsBase(baseUrl: string): string;
4
+ };
5
+ export declare const directTerminalUrlResolver: TerminalUrlResolver;
6
+ export type TerminalHelloMessage = {
7
+ type: "hello";
8
+ cols: number;
9
+ rows: number;
10
+ };
11
+ export type TerminalOutputMessage = {
12
+ type: "output";
13
+ data: string;
14
+ };
15
+ export type TerminalExitMessage = {
16
+ type: "exit";
17
+ exitCode: number;
18
+ signal?: number;
19
+ };
20
+ export type TerminalErrorMessage = {
21
+ type: "error";
22
+ message: string;
23
+ };
24
+ export type TerminalInputRejectedMessage = {
25
+ type: "input-rejected";
26
+ message: string;
27
+ };
28
+ export type TerminalMessage = TerminalHelloMessage | TerminalOutputMessage | TerminalExitMessage | TerminalErrorMessage | TerminalInputRejectedMessage;
29
+ export type TerminalClientOptions = {
30
+ authToken?: () => string | undefined;
31
+ baseUrl: string;
32
+ cols?: number;
33
+ rows?: number;
34
+ terminalId: string;
35
+ urlResolver?: TerminalUrlResolver;
36
+ };
37
+ export declare class TerminalClient {
38
+ readonly cols: number | undefined;
39
+ readonly rows: number | undefined;
40
+ readonly terminalId: string;
41
+ private readonly authToken?;
42
+ private socket?;
43
+ private readonly baseUrl;
44
+ private readonly urls;
45
+ constructor({ authToken, baseUrl, cols, rows, terminalId, urlResolver }: TerminalClientOptions);
46
+ connect(): WebSocket;
47
+ write(data: string): void;
48
+ close(): void;
49
+ parse(raw: string): TerminalMessage;
50
+ private requireOpenSocket;
51
+ private url;
52
+ }
53
+ export declare function webSocketBaseUrl(baseUrl: string): string;
package/dist/client.js ADDED
@@ -0,0 +1,100 @@
1
+ export const directTerminalUrlResolver = {
2
+ wsAuthQuery: () => ({}),
3
+ wsBase: (baseUrl) => webSocketBaseUrl(baseUrl)
4
+ };
5
+ export class TerminalClient {
6
+ cols;
7
+ rows;
8
+ terminalId;
9
+ authToken;
10
+ socket;
11
+ baseUrl;
12
+ urls;
13
+ constructor({ authToken, baseUrl, cols, rows, terminalId, urlResolver }) {
14
+ this.authToken = authToken;
15
+ this.baseUrl = baseUrl;
16
+ this.cols = cols;
17
+ this.rows = rows;
18
+ this.terminalId = terminalId;
19
+ this.urls = urlResolver ?? directTerminalUrlResolver;
20
+ }
21
+ connect() {
22
+ if (this.socket && this.socket.readyState !== WebSocket.CLOSED && this.socket.readyState !== WebSocket.CLOSING) {
23
+ return this.socket;
24
+ }
25
+ this.socket = new WebSocket(this.url());
26
+ return this.socket;
27
+ }
28
+ write(data) {
29
+ const socket = this.requireOpenSocket();
30
+ socket.send(JSON.stringify({ data, type: "input" }));
31
+ }
32
+ close() {
33
+ this.socket?.close();
34
+ }
35
+ parse(raw) {
36
+ const message = JSON.parse(raw);
37
+ if (message.type === "hello") {
38
+ const cols = message.cols;
39
+ const rows = message.rows;
40
+ if (cols === undefined || rows === undefined || !Number.isInteger(cols) || !Number.isInteger(rows)) {
41
+ throw new Error(`Hello message for ${this.terminalId} did not include cols and rows.`);
42
+ }
43
+ return { cols, rows, type: "hello" };
44
+ }
45
+ if (message.type === "output") {
46
+ if (typeof message.data !== "string") {
47
+ throw new Error(`Output message for ${this.terminalId} did not include data.`);
48
+ }
49
+ return { data: message.data, type: "output" };
50
+ }
51
+ if (message.type === "exit") {
52
+ const exitCode = message.exitCode;
53
+ if (exitCode === undefined || !Number.isInteger(exitCode)) {
54
+ throw new Error(`Exit message for ${this.terminalId} did not include an exit code.`);
55
+ }
56
+ return { exitCode, signal: message.signal, type: "exit" };
57
+ }
58
+ if (message.type === "error") {
59
+ return { message: message.message ?? `WebSocket error for ${this.terminalId}.`, type: "error" };
60
+ }
61
+ if (message.type === "input-rejected") {
62
+ return { message: message.message ?? `Input rejected for ${this.terminalId}.`, type: "input-rejected" };
63
+ }
64
+ throw new Error(`Unsupported message type: ${String(message.type)}`);
65
+ }
66
+ requireOpenSocket() {
67
+ const socket = this.socket;
68
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
69
+ throw new Error(`Terminal socket for ${this.terminalId} is not open.`);
70
+ }
71
+ return socket;
72
+ }
73
+ url() {
74
+ const url = new URL(`${this.urls.wsBase(this.baseUrl)}/ws`);
75
+ url.searchParams.set("terminalId", this.terminalId);
76
+ // Only send a size when the caller actually specified one (see
77
+ // TerminalClientOptions.cols/rows) — an omitted cols/rows must reach the
78
+ // gateway as an ABSENT query param, never "undefined" as a string, so its
79
+ // own `value === null` fallback to the pane's live size still fires.
80
+ if (this.cols !== undefined) {
81
+ url.searchParams.set("cols", String(this.cols));
82
+ }
83
+ if (this.rows !== undefined) {
84
+ url.searchParams.set("rows", String(this.rows));
85
+ }
86
+ const token = this.authToken?.();
87
+ if (token) {
88
+ url.searchParams.set("token", token);
89
+ }
90
+ for (const [name, value] of Object.entries(this.urls.wsAuthQuery())) {
91
+ url.searchParams.set(name, value);
92
+ }
93
+ return url.href;
94
+ }
95
+ }
96
+ export function webSocketBaseUrl(baseUrl) {
97
+ const url = new URL(baseUrl);
98
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
99
+ return url.origin;
100
+ }
@@ -0,0 +1,15 @@
1
+ export type RunOptions = {
2
+ cwd?: string;
3
+ env?: Record<string, string | undefined>;
4
+ inheritEnv?: boolean;
5
+ timeoutMs?: number;
6
+ };
7
+ export declare function commandExists(command: string): boolean;
8
+ export declare function requireCommand(command: string, installHint?: string): void;
9
+ export declare function ensureSystemToolsOnPath(): void;
10
+ export declare function run(command: string, args: string[], options?: RunOptions): string;
11
+ export declare function runBuffer(command: string, args: string[], options?: RunOptions): Buffer;
12
+ export declare function runWithInput(command: string, args: string[], input: Buffer, options?: RunOptions): Buffer;
13
+ export declare function runAsync(command: string, args: string[], options?: RunOptions): Promise<string>;
14
+ export declare function runWithInputAsync(command: string, args: string[], input: Buffer | string, options?: RunOptions): Promise<string>;
15
+ export declare function spawnInherited(command: string, args: string[]): void;
@@ -0,0 +1,192 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ function runEnvironment(options) {
3
+ if (options.inheritEnv === false) {
4
+ return options.env ?? {};
5
+ }
6
+ return options.env ? { ...process.env, ...options.env } : process.env;
7
+ }
8
+ // Memoized by command name: PATH is finalized once at process startup
9
+ // (ensureSystemToolsOnPath, called before any subprocess runs) and never changes
10
+ // for the rest of the process's life in this codebase's usage, so re-spawning
11
+ // `command -v <name>` on every call is pure repeated cost. This matters because
12
+ // requireCommand/assertTmux-style guards run on hot per-request paths (e.g.
13
+ // captureTerminal calling assertTmux on every capture) — a synchronous spawnSync
14
+ // there would otherwise reintroduce a small blocking cost on every single call,
15
+ // undermining the whole point of an async capture path (see CLAUDE.md's
16
+ // event-loop invariant).
17
+ const commandExistsCache = new Map();
18
+ export function commandExists(command) {
19
+ const cached = commandExistsCache.get(command);
20
+ if (cached !== undefined) {
21
+ return cached;
22
+ }
23
+ const result = spawnSync("command", ["-v", command], {
24
+ encoding: "utf8",
25
+ shell: true
26
+ });
27
+ const exists = result.status === 0;
28
+ commandExistsCache.set(command, exists);
29
+ return exists;
30
+ }
31
+ export function requireCommand(command, installHint) {
32
+ if (!commandExists(command)) {
33
+ const hint = installHint ? ` ${installHint}` : "";
34
+ throw new Error(`${command} is required but was not found on PATH.${hint}`);
35
+ }
36
+ }
37
+ // The standard system tool directories. Appended (never reordered) to PATH at
38
+ // process startup so `run`/`execFile` of ps, tmux, and coreutils resolve even when
39
+ // the process was launched from a stripped-PATH context — cron, launchd, a GUI
40
+ // app, or a nested agent shell whose PATH was degraded. A provider booted that way
41
+ // otherwise silently loses its lifecycle observation (`spawn ps ENOENT`) while
42
+ // healthz still answers. Non-existent dirs (e.g. /opt/homebrew on Intel/Linux) are
43
+ // harmless — the OS skips them during lookup.
44
+ const systemToolDirs = ["/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/local/bin", "/opt/homebrew/bin"];
45
+ // Guarantee the system tool dirs are on PATH without disturbing the caller's own
46
+ // order or entries: only the missing ones are appended. Call once at entrypoint
47
+ // startup, before any subprocess runs. Idempotent.
48
+ export function ensureSystemToolsOnPath() {
49
+ const current = (process.env.PATH ?? "").split(":").filter(Boolean);
50
+ const have = new Set(current);
51
+ const missing = systemToolDirs.filter((dir) => !have.has(dir));
52
+ if (missing.length > 0) {
53
+ process.env.PATH = [...current, ...missing].join(":");
54
+ }
55
+ }
56
+ export function run(command, args, options = {}) {
57
+ const result = spawnSync(command, args, {
58
+ cwd: options.cwd,
59
+ encoding: "utf8",
60
+ env: runEnvironment(options),
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ timeout: options.timeoutMs
63
+ });
64
+ if (result.error) {
65
+ const message = result.error.name === "Error" && "code" in result.error && result.error.code === "ETIMEDOUT"
66
+ ? `${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms.`
67
+ : result.error.message;
68
+ const detail = runOutputDetail(result.stdout, result.stderr);
69
+ if (detail) {
70
+ throw new Error(`${message}\n${detail}`);
71
+ }
72
+ throw result.error;
73
+ }
74
+ if (result.status !== 0) {
75
+ const output = runOutputDetail(result.stdout, result.stderr);
76
+ const detail = output ? `\n${output}` : "";
77
+ throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status}.${detail}`);
78
+ }
79
+ return result.stdout;
80
+ }
81
+ function runOutputDetail(stdout, stderr) {
82
+ const stderrText = outputText(stderr);
83
+ const stdoutText = outputText(stdout);
84
+ if (stderrText && stdoutText) {
85
+ return `stderr:\n${stderrText}\nstdout:\n${stdoutText}`;
86
+ }
87
+ if (stderrText) {
88
+ return `stderr:\n${stderrText}`;
89
+ }
90
+ if (stdoutText) {
91
+ return `stdout:\n${stdoutText}`;
92
+ }
93
+ return "";
94
+ }
95
+ function outputText(value) {
96
+ if (Buffer.isBuffer(value)) {
97
+ return value.toString("utf8").trim();
98
+ }
99
+ return String(value ?? "").trim();
100
+ }
101
+ export function runBuffer(command, args, options = {}) {
102
+ const result = spawnSync(command, args, {
103
+ cwd: options.cwd,
104
+ env: runEnvironment(options),
105
+ stdio: ["ignore", "pipe", "pipe"]
106
+ });
107
+ if (result.error) {
108
+ throw result.error;
109
+ }
110
+ if (result.status !== 0) {
111
+ const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8").trim() : String(result.stderr ?? "").trim();
112
+ const detail = stderr ? `\n${stderr}` : "";
113
+ throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status}.${detail}`);
114
+ }
115
+ return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "");
116
+ }
117
+ export function runWithInput(command, args, input, options = {}) {
118
+ const result = spawnSync(command, args, {
119
+ cwd: options.cwd,
120
+ env: runEnvironment(options),
121
+ input,
122
+ stdio: ["pipe", "pipe", "pipe"]
123
+ });
124
+ if (result.error) {
125
+ throw result.error;
126
+ }
127
+ if (result.status !== 0) {
128
+ const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8").trim() : String(result.stderr ?? "").trim();
129
+ const detail = stderr ? `\n${stderr}` : "";
130
+ throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status}.${detail}`);
131
+ }
132
+ return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "");
133
+ }
134
+ export async function runAsync(command, args, options = {}) {
135
+ return await spawnAsync(command, args, options);
136
+ }
137
+ export async function runWithInputAsync(command, args, input, options = {}) {
138
+ return await spawnAsync(command, args, options, input);
139
+ }
140
+ async function spawnAsync(command, args, options, input) {
141
+ return await new Promise((resolve, reject) => {
142
+ const child = spawn(command, args, {
143
+ cwd: options.cwd,
144
+ env: runEnvironment(options),
145
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
146
+ });
147
+ const stdout = [];
148
+ const stderr = [];
149
+ // Honor timeoutMs without blocking the loop (the sync `run` relied on
150
+ // spawnSync's timeout): SIGKILL the child and reject if it overruns.
151
+ const timer = options.timeoutMs === undefined
152
+ ? undefined
153
+ : setTimeout(() => {
154
+ child.kill("SIGKILL");
155
+ reject(new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms.`));
156
+ }, options.timeoutMs);
157
+ timer?.unref?.();
158
+ const clear = () => {
159
+ if (timer)
160
+ clearTimeout(timer);
161
+ };
162
+ child.stdout?.on("data", (chunk) => stdout.push(chunk));
163
+ child.stderr?.on("data", (chunk) => stderr.push(chunk));
164
+ child.on("error", (error) => {
165
+ clear();
166
+ reject(error);
167
+ });
168
+ child.on("close", (code) => {
169
+ clear();
170
+ if (code !== 0) {
171
+ const detail = Buffer.concat(stderr).toString("utf8").trim();
172
+ reject(new Error(`${command} ${args.join(" ")} failed with exit ${code}.${detail ? `\n${detail}` : ""}`));
173
+ return;
174
+ }
175
+ resolve(Buffer.concat(stdout).toString("utf8"));
176
+ });
177
+ if (input !== undefined && child.stdin) {
178
+ child.stdin.on("error", reject);
179
+ child.stdin.end(input);
180
+ }
181
+ });
182
+ }
183
+ export function spawnInherited(command, args) {
184
+ const child = spawn(command, args, { stdio: "inherit" });
185
+ child.on("exit", (code, signal) => {
186
+ if (signal) {
187
+ process.kill(process.pid, signal);
188
+ return;
189
+ }
190
+ process.exit(code ?? 1);
191
+ });
192
+ }
@@ -0,0 +1 @@
1
+ export declare function snapshotDescendantPids(rootPids: number[]): number[];
@@ -0,0 +1,41 @@
1
+ import { execFileSync } from "node:child_process";
2
+ const PROCESS_SNAPSHOT_MAX_BUFFER = 16 * 1024 * 1024;
3
+ // Return only each requested root and its descendants. This is the safe input
4
+ // to the tmux session cleanup sweep: the tmux server is a parent and therefore
5
+ // can never be selected, while helpers detached below a pane remain reachable.
6
+ export function snapshotDescendantPids(rootPids) {
7
+ if (rootPids.length === 0)
8
+ return [];
9
+ const stdout = execFileSync("ps", ["-axo", "pid=,ppid="], {
10
+ encoding: "utf8",
11
+ maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER,
12
+ timeout: 5000
13
+ });
14
+ const children = new Map();
15
+ const processIds = new Set();
16
+ for (const line of stdout.split("\n")) {
17
+ const match = line.trim().match(/^(\d+)\s+(\d+)$/);
18
+ if (!match)
19
+ continue;
20
+ const pid = Number(match[1]);
21
+ const parent = Number(match[2]);
22
+ if (!Number.isInteger(pid) || pid < 1 || !Number.isInteger(parent) || parent < 0)
23
+ continue;
24
+ processIds.add(pid);
25
+ const siblings = children.get(parent) ?? [];
26
+ siblings.push(pid);
27
+ children.set(parent, siblings);
28
+ }
29
+ const result = [];
30
+ const pending = [...new Set(rootPids.filter((pid) => processIds.has(pid)))];
31
+ const seen = new Set();
32
+ while (pending.length > 0) {
33
+ const pid = pending.pop();
34
+ if (pid === undefined || seen.has(pid))
35
+ continue;
36
+ seen.add(pid);
37
+ result.push(pid);
38
+ pending.push(...(children.get(pid) ?? []));
39
+ }
40
+ return result.sort((a, b) => a - b);
41
+ }
@@ -0,0 +1,31 @@
1
+ import type { WebSocket } from "ws";
2
+ import pty from "@homebridge/node-pty-prebuilt-multiarch";
3
+ export type TerminalAttachment = {
4
+ dispose(): void;
5
+ };
6
+ export declare class TmuxTerminalAttachError extends Error {
7
+ readonly phase: "resolve" | "spawn";
8
+ constructor(phase: "resolve" | "spawn", cause: unknown);
9
+ }
10
+ export type TmuxTerminalSocketOptions<TAuthorization = void> = {
11
+ authorizeInput?: () => Promise<TAuthorization> | TAuthorization;
12
+ cols?: number;
13
+ cwd?: string;
14
+ errorLabel?: string;
15
+ onInput?: (data: string, authorization: TAuthorization | undefined) => Promise<void> | void;
16
+ rows?: number;
17
+ socket: WebSocket;
18
+ terminalId: string;
19
+ tmuxSocket?: string;
20
+ window?: number;
21
+ };
22
+ export declare function attachTmuxTerminalSocket<TAuthorization = void>(options: TmuxTerminalSocketOptions<TAuthorization>): TerminalAttachment;
23
+ type ReconciledPty = {
24
+ child: ReturnType<typeof pty.spawn>;
25
+ orphanPtmxFds: number[];
26
+ };
27
+ export declare function spawnReconciledPty(file: string, args: string[], options: Parameters<typeof pty.spawn>[2]): ReconciledPty;
28
+ export declare function releasePtyMaster(child: {
29
+ destroy(): void;
30
+ }, orphanPtmxFds?: number[]): void;
31
+ export {};