@co0ontty/wand 4.38.0 → 4.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+ import pty from "node-pty";
6
+ import { ensureNodePtyHelperExecutable } from "./ensure-node-pty-helper.js";
7
+ import { TERMINAL_DAEMON_PROTOCOL_VERSION, terminalDaemonPaths, } from "./terminal-daemon-protocol.js";
8
+ import { appendTerminalChunkWindow, } from "./terminal-host.js";
9
+ import { PtyTerminalState } from "./pty-terminal-state.js";
10
+ import { PtyCliExitMarker } from "./pty-shell-launch.js";
11
+ import { appendWindow, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
12
+ const MAX_REQUEST_BUFFER = 4 * 1024 * 1024;
13
+ export async function runTerminalDaemon(configPath) {
14
+ const paths = terminalDaemonPaths(configPath);
15
+ mkdirSync(path.dirname(paths.tokenPath), { recursive: true, mode: 0o700 });
16
+ const previousPid = readTerminalDaemonPid(configPath);
17
+ if (previousPid && previousPid !== process.pid && isProcessAlive(previousPid)) {
18
+ // A service install/update must never evict the daemon that owns live PTYs.
19
+ // Stay as the service-manager-owned successor and take over only after the
20
+ // previous standalone/versioned daemon naturally exits (for example reboot).
21
+ await waitForProcessExit(previousPid);
22
+ }
23
+ if (process.platform !== "win32" && existsSync(paths.socketPath)) {
24
+ if (await socketAcceptsConnections(paths.socketPath)) {
25
+ // Covers the narrow startup window before the winning daemon publishes
26
+ // its pid. Never unlink a socket that another live daemon already bound.
27
+ await waitForSocketRelease(paths.socketPath);
28
+ }
29
+ try {
30
+ unlinkSync(paths.socketPath);
31
+ }
32
+ catch { /* listen will report the real error */ }
33
+ }
34
+ const token = randomBytes(32).toString("hex");
35
+ const sessions = new Map();
36
+ const clients = new Set();
37
+ const send = (socket, message) => {
38
+ if (!socket.destroyed && socket.writable)
39
+ socket.write(`${JSON.stringify(message)}\n`);
40
+ };
41
+ const broadcast = (event) => {
42
+ for (const client of clients) {
43
+ if (client.authenticated)
44
+ send(client.socket, event);
45
+ }
46
+ };
47
+ const serialize = (session) => ({
48
+ sessionId: session.sessionId,
49
+ incarnationId: session.incarnationId,
50
+ pid: session.pid,
51
+ status: session.status,
52
+ exitCode: session.exitCode,
53
+ cols: session.cols,
54
+ rows: session.rows,
55
+ seq: session.seq,
56
+ output: session.output,
57
+ chunks: session.chunks.map((chunk) => ({ ...chunk })),
58
+ terminalSnapshot: session.terminalState.snapshot(),
59
+ launchMarkerToken: session.launchMarkerToken,
60
+ });
61
+ const createOrAttach = (params) => {
62
+ const existing = sessions.get(params.sessionId);
63
+ if (existing)
64
+ return { state: serialize(existing), isNew: false };
65
+ ensureNodePtyHelperExecutable();
66
+ const child = pty.spawn(params.file, params.args, {
67
+ cwd: params.cwd,
68
+ env: params.env,
69
+ name: params.name,
70
+ cols: params.cols,
71
+ rows: params.rows,
72
+ });
73
+ const session = {
74
+ sessionId: params.sessionId,
75
+ incarnationId: randomUUID(),
76
+ process: child,
77
+ pid: child.pid,
78
+ status: "running",
79
+ exitCode: null,
80
+ cols: params.cols,
81
+ rows: params.rows,
82
+ seq: 0,
83
+ output: "",
84
+ chunks: [],
85
+ terminalState: new PtyTerminalState(params.cols, params.rows),
86
+ launchMarkerToken: params.launchMarkerToken ?? null,
87
+ displayMarker: params.launchMarkerToken ? new PtyCliExitMarker(params.launchMarkerToken) : null,
88
+ };
89
+ sessions.set(params.sessionId, session);
90
+ child.onData((data) => {
91
+ if (sessions.get(session.sessionId) !== session || session.process !== child)
92
+ return;
93
+ const event = { data, seq: ++session.seq };
94
+ const visibleData = session.displayMarker?.consume(data).data ?? data;
95
+ session.output = appendWindow(session.output, visibleData, PTY_OUTPUT_MAX_SIZE);
96
+ session.chunks = appendTerminalChunkWindow(session.chunks, event);
97
+ if (visibleData)
98
+ session.terminalState.write(visibleData);
99
+ broadcast({
100
+ kind: "event",
101
+ event: "data",
102
+ sessionId: session.sessionId,
103
+ incarnationId: session.incarnationId,
104
+ data,
105
+ seq: event.seq,
106
+ });
107
+ });
108
+ child.onExit(({ exitCode, signal }) => {
109
+ if (sessions.get(session.sessionId) !== session || session.process !== child)
110
+ return;
111
+ session.process = null;
112
+ session.status = "exited";
113
+ session.exitCode = exitCode;
114
+ broadcast({
115
+ kind: "event",
116
+ event: "exit",
117
+ sessionId: session.sessionId,
118
+ incarnationId: session.incarnationId,
119
+ exitCode,
120
+ signal,
121
+ });
122
+ });
123
+ return { state: serialize(session), isNew: true };
124
+ };
125
+ const forget = (sessionId) => {
126
+ const session = sessions.get(sessionId);
127
+ if (!session)
128
+ return;
129
+ sessions.delete(sessionId);
130
+ const child = session.process;
131
+ session.process = null;
132
+ if (child) {
133
+ try {
134
+ child.kill();
135
+ }
136
+ catch { /* best-effort explicit deletion */ }
137
+ }
138
+ session.terminalState.dispose();
139
+ };
140
+ const handleRequest = (client, request) => {
141
+ if (request.token !== token) {
142
+ send(client.socket, { kind: "response", id: request.id, ok: false, error: "Unauthorized" });
143
+ client.socket.destroy();
144
+ return;
145
+ }
146
+ if (request.protocolVersion !== TERMINAL_DAEMON_PROTOCOL_VERSION) {
147
+ send(client.socket, {
148
+ kind: "response",
149
+ id: request.id,
150
+ ok: false,
151
+ error: `Unsupported terminal daemon protocol ${request.protocolVersion}`,
152
+ });
153
+ return;
154
+ }
155
+ client.authenticated = true;
156
+ try {
157
+ let result;
158
+ const params = request.params ?? {};
159
+ switch (request.method) {
160
+ case "hello":
161
+ result = { protocolVersion: TERMINAL_DAEMON_PROTOCOL_VERSION, pid: process.pid };
162
+ break;
163
+ case "list":
164
+ result = Array.from(sessions.values(), serialize);
165
+ break;
166
+ case "createOrAttach":
167
+ result = createOrAttach(params);
168
+ break;
169
+ case "write": {
170
+ const session = sessions.get(String(params.sessionId ?? ""));
171
+ if (!session?.process || session.status !== "running")
172
+ throw new Error("Terminal is not running");
173
+ session.process.write(String(params.data ?? ""));
174
+ result = { ok: true };
175
+ break;
176
+ }
177
+ case "resize": {
178
+ const session = sessions.get(String(params.sessionId ?? ""));
179
+ if (!session?.process || session.status !== "running")
180
+ throw new Error("Terminal is not running");
181
+ const cols = Number(params.cols);
182
+ const rows = Number(params.rows);
183
+ if (!Number.isSafeInteger(cols) || !Number.isSafeInteger(rows))
184
+ throw new Error("Invalid terminal size");
185
+ session.process.resize(cols, rows);
186
+ session.terminalState.resize(cols, rows);
187
+ session.cols = cols;
188
+ session.rows = rows;
189
+ result = { ok: true };
190
+ break;
191
+ }
192
+ case "kill": {
193
+ const session = sessions.get(String(params.sessionId ?? ""));
194
+ if (session?.process)
195
+ session.process.kill(typeof params.signal === "string" ? params.signal : undefined);
196
+ result = { ok: true };
197
+ break;
198
+ }
199
+ case "forget":
200
+ forget(String(params.sessionId ?? ""));
201
+ result = { ok: true };
202
+ break;
203
+ }
204
+ send(client.socket, { kind: "response", id: request.id, ok: true, result });
205
+ }
206
+ catch (error) {
207
+ send(client.socket, {
208
+ kind: "response",
209
+ id: request.id,
210
+ ok: false,
211
+ error: error instanceof Error ? error.message : String(error),
212
+ });
213
+ }
214
+ };
215
+ const server = net.createServer((socket) => {
216
+ const client = { socket, authenticated: false, buffer: "" };
217
+ clients.add(client);
218
+ socket.setNoDelay(true);
219
+ socket.on("data", (data) => {
220
+ client.buffer += data.toString("utf8");
221
+ if (client.buffer.length > MAX_REQUEST_BUFFER) {
222
+ socket.destroy();
223
+ return;
224
+ }
225
+ while (true) {
226
+ const newline = client.buffer.indexOf("\n");
227
+ if (newline < 0)
228
+ break;
229
+ const line = client.buffer.slice(0, newline);
230
+ client.buffer = client.buffer.slice(newline + 1);
231
+ if (!line)
232
+ continue;
233
+ try {
234
+ handleRequest(client, JSON.parse(line));
235
+ }
236
+ catch {
237
+ socket.destroy();
238
+ return;
239
+ }
240
+ }
241
+ });
242
+ socket.on("close", () => clients.delete(client));
243
+ socket.on("error", () => clients.delete(client));
244
+ });
245
+ await new Promise((resolve, reject) => {
246
+ server.once("error", reject);
247
+ server.listen(paths.socketPath, () => {
248
+ server.off("error", reject);
249
+ if (process.platform !== "win32")
250
+ chmodSync(paths.socketPath, 0o600);
251
+ // Publish credentials only after this process has atomically won the
252
+ // socket bind. Concurrent starters can no longer overwrite the live
253
+ // daemon's token/pid before discovering EADDRINUSE.
254
+ writeFileSync(paths.tokenPath, `${token}\n`, { encoding: "utf8", mode: 0o600 });
255
+ writeFileSync(paths.pidPath, `${process.pid}\n`, { encoding: "utf8", mode: 0o600 });
256
+ resolve();
257
+ });
258
+ });
259
+ const shutdown = () => {
260
+ for (const sessionId of Array.from(sessions.keys()))
261
+ forget(sessionId);
262
+ for (const client of clients)
263
+ client.socket.destroy();
264
+ server.close(() => process.exit(0));
265
+ cleanupDaemonFiles(paths.socketPath, paths.tokenPath, paths.pidPath);
266
+ };
267
+ process.once("SIGINT", shutdown);
268
+ process.once("SIGTERM", shutdown);
269
+ }
270
+ function cleanupDaemonFiles(socketPath, tokenPath, pidPath) {
271
+ for (const file of [socketPath, tokenPath, pidPath]) {
272
+ if (process.platform === "win32" && file === socketPath)
273
+ continue;
274
+ try {
275
+ unlinkSync(file);
276
+ }
277
+ catch { /* already absent */ }
278
+ }
279
+ }
280
+ export function readTerminalDaemonPid(configPath) {
281
+ try {
282
+ const value = Number(readFileSync(terminalDaemonPaths(configPath).pidPath, "utf8").trim());
283
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ }
289
+ function isProcessAlive(pid) {
290
+ try {
291
+ process.kill(pid, 0);
292
+ return true;
293
+ }
294
+ catch {
295
+ return false;
296
+ }
297
+ }
298
+ function waitForProcessExit(pid) {
299
+ return new Promise((resolve) => {
300
+ const timer = setInterval(() => {
301
+ if (isProcessAlive(pid))
302
+ return;
303
+ clearInterval(timer);
304
+ process.off("SIGINT", stopWaiting);
305
+ process.off("SIGTERM", stopWaiting);
306
+ resolve();
307
+ }, 500);
308
+ const stopWaiting = () => {
309
+ clearInterval(timer);
310
+ process.exit(0);
311
+ };
312
+ process.once("SIGINT", stopWaiting);
313
+ process.once("SIGTERM", stopWaiting);
314
+ });
315
+ }
316
+ function socketAcceptsConnections(socketPath) {
317
+ return new Promise((resolve) => {
318
+ const socket = net.createConnection(socketPath);
319
+ let settled = false;
320
+ const finish = (alive) => {
321
+ if (settled)
322
+ return;
323
+ settled = true;
324
+ clearTimeout(timer);
325
+ socket.removeAllListeners();
326
+ socket.destroy();
327
+ resolve(alive);
328
+ };
329
+ socket.once("connect", () => finish(true));
330
+ socket.once("error", () => finish(false));
331
+ const timer = setTimeout(() => finish(false), 300);
332
+ timer.unref?.();
333
+ });
334
+ }
335
+ async function waitForSocketRelease(socketPath) {
336
+ let stopped = false;
337
+ const stopWaiting = () => {
338
+ stopped = true;
339
+ };
340
+ process.once("SIGINT", stopWaiting);
341
+ process.once("SIGTERM", stopWaiting);
342
+ while (!stopped && await socketAcceptsConnections(socketPath)) {
343
+ await new Promise((resolve) => setTimeout(resolve, 500));
344
+ }
345
+ process.off("SIGINT", stopWaiting);
346
+ process.off("SIGTERM", stopWaiting);
347
+ if (stopped)
348
+ process.exit(0);
349
+ }
@@ -0,0 +1,79 @@
1
+ import type { PtyTerminalSnapshot } from "./pty-terminal-state.js";
2
+ export interface TerminalDataEvent {
3
+ data: string;
4
+ /** Monotonic per-incarnation chunk sequence used for gap-free reattach. */
5
+ seq: number;
6
+ }
7
+ export interface TerminalExitEvent {
8
+ exitCode: number;
9
+ signal?: number;
10
+ }
11
+ export interface TerminalProcess {
12
+ readonly sessionId: string;
13
+ readonly incarnationId: string;
14
+ readonly pid: number;
15
+ write(data: string): void;
16
+ resize(cols: number, rows: number): void;
17
+ kill(signal?: string): void;
18
+ pause(): void;
19
+ resume(): void;
20
+ onData(listener: (event: TerminalDataEvent) => void): {
21
+ dispose(): void;
22
+ };
23
+ onExit(listener: (event: TerminalExitEvent) => void): {
24
+ dispose(): void;
25
+ };
26
+ }
27
+ export interface TerminalSessionState {
28
+ sessionId: string;
29
+ incarnationId: string;
30
+ pid: number;
31
+ status: "running" | "exited";
32
+ exitCode: number | null;
33
+ cols: number;
34
+ rows: number;
35
+ seq: number;
36
+ output: string;
37
+ chunks: TerminalDataEvent[];
38
+ terminalSnapshot: PtyTerminalSnapshot | null;
39
+ launchMarkerToken: string | null;
40
+ }
41
+ export interface TerminalAttachResult {
42
+ process: TerminalProcess | null;
43
+ state: TerminalSessionState;
44
+ replay: TerminalDataEvent[];
45
+ isNew: boolean;
46
+ }
47
+ export interface TerminalSpawnRequest {
48
+ sessionId: string;
49
+ file: string;
50
+ args: string[];
51
+ cwd: string;
52
+ env: NodeJS.ProcessEnv;
53
+ name: string;
54
+ cols: number;
55
+ rows: number;
56
+ launchMarkerToken?: string;
57
+ }
58
+ /**
59
+ * Owns terminal processes behind one small seam. Persistent adapters may outlive
60
+ * their caller; in-process adapters deliberately retain the legacy lifecycle.
61
+ */
62
+ export interface TerminalHost {
63
+ readonly persistent: boolean;
64
+ attach(sessionId: string, afterSeq?: number): TerminalAttachResult | null;
65
+ createOrAttach(request: TerminalSpawnRequest, afterSeq?: number): Promise<TerminalAttachResult>;
66
+ forget(sessionId: string): void;
67
+ disconnect(): void;
68
+ }
69
+ /** Legacy/local adapter and deterministic test adapter for the TerminalHost seam. */
70
+ export declare class InProcessTerminalHost implements TerminalHost {
71
+ readonly persistent = false;
72
+ private readonly processes;
73
+ attach(): TerminalAttachResult | null;
74
+ createOrAttach(request: TerminalSpawnRequest): Promise<TerminalAttachResult>;
75
+ forget(sessionId: string): void;
76
+ disconnect(): void;
77
+ }
78
+ /** Keep a bounded replay log while preserving chunk sequence numbers. */
79
+ export declare function appendTerminalChunkWindow(chunks: readonly TerminalDataEvent[], event: TerminalDataEvent): TerminalDataEvent[];
@@ -0,0 +1,123 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import pty from "node-pty";
3
+ import { ensureNodePtyHelperExecutable } from "./ensure-node-pty-helper.js";
4
+ import { PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
5
+ class InProcessTerminalProcess {
6
+ sessionId;
7
+ child;
8
+ incarnationId = randomUUID();
9
+ seq = 0;
10
+ constructor(sessionId, child) {
11
+ this.sessionId = sessionId;
12
+ this.child = child;
13
+ }
14
+ get pid() {
15
+ return this.child.pid;
16
+ }
17
+ write(data) {
18
+ this.child.write(data);
19
+ }
20
+ resize(cols, rows) {
21
+ this.child.resize(cols, rows);
22
+ }
23
+ kill(signal) {
24
+ this.child.kill(signal);
25
+ }
26
+ pause() {
27
+ this.child.pause();
28
+ }
29
+ resume() {
30
+ this.child.resume();
31
+ }
32
+ onData(listener) {
33
+ return this.child.onData((data) => listener({ data, seq: ++this.seq }));
34
+ }
35
+ onExit(listener) {
36
+ return this.child.onExit(({ exitCode, signal }) => listener({ exitCode, signal }));
37
+ }
38
+ }
39
+ /** Legacy/local adapter and deterministic test adapter for the TerminalHost seam. */
40
+ export class InProcessTerminalHost {
41
+ persistent = false;
42
+ processes = new Map();
43
+ attach() {
44
+ return null;
45
+ }
46
+ async createOrAttach(request) {
47
+ const existing = this.processes.get(request.sessionId);
48
+ if (existing) {
49
+ return {
50
+ process: existing,
51
+ state: emptyRunningState(existing, request.cols, request.rows, request.launchMarkerToken),
52
+ replay: [],
53
+ isNew: false,
54
+ };
55
+ }
56
+ ensureNodePtyHelperExecutable();
57
+ const child = pty.spawn(request.file, request.args, {
58
+ cwd: request.cwd,
59
+ env: request.env,
60
+ name: request.name,
61
+ cols: request.cols,
62
+ rows: request.rows,
63
+ });
64
+ const process = new InProcessTerminalProcess(request.sessionId, child);
65
+ this.processes.set(request.sessionId, process);
66
+ process.onExit(() => {
67
+ if (this.processes.get(request.sessionId) === process) {
68
+ this.processes.delete(request.sessionId);
69
+ }
70
+ });
71
+ return {
72
+ process,
73
+ state: emptyRunningState(process, request.cols, request.rows, request.launchMarkerToken),
74
+ replay: [],
75
+ isNew: true,
76
+ };
77
+ }
78
+ forget(sessionId) {
79
+ this.processes.delete(sessionId);
80
+ }
81
+ disconnect() {
82
+ for (const process of this.processes.values()) {
83
+ try {
84
+ process.kill();
85
+ }
86
+ catch { /* best-effort legacy shutdown */ }
87
+ }
88
+ this.processes.clear();
89
+ }
90
+ }
91
+ function emptyRunningState(process, cols, rows, launchMarkerToken) {
92
+ return {
93
+ sessionId: process.sessionId,
94
+ incarnationId: process.incarnationId,
95
+ pid: process.pid,
96
+ status: "running",
97
+ exitCode: null,
98
+ cols,
99
+ rows,
100
+ seq: 0,
101
+ output: "",
102
+ chunks: [],
103
+ terminalSnapshot: null,
104
+ launchMarkerToken: launchMarkerToken ?? null,
105
+ };
106
+ }
107
+ /** Keep a bounded replay log while preserving chunk sequence numbers. */
108
+ export function appendTerminalChunkWindow(chunks, event) {
109
+ const boundedEvent = event.data.length > PTY_OUTPUT_MAX_SIZE
110
+ ? { ...event, data: event.data.slice(-PTY_OUTPUT_MAX_SIZE) }
111
+ : event;
112
+ const next = [...chunks, boundedEvent];
113
+ let size = 0;
114
+ let first = next.length;
115
+ while (first > 0) {
116
+ const candidate = next[first - 1].data.length;
117
+ if (size > 0 && size + candidate > PTY_OUTPUT_MAX_SIZE)
118
+ break;
119
+ size += candidate;
120
+ first -= 1;
121
+ }
122
+ return next.slice(first);
123
+ }