@moxxy/plugin-terminal 0.27.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/src/pty.ts ADDED
@@ -0,0 +1,446 @@
1
+ /**
2
+ * The shared terminal process behind the `terminal` surface + tool.
3
+ *
4
+ * Two backends, picked at open time:
5
+ * 1. **node-pty** (preferred) — a real PTY, so interactive programs (vim, top,
6
+ * a REPL) and prompts render correctly. Lazy-loaded; absent in a default
7
+ * install (it is an OPTIONAL peer dep — native, so CI never has to build
8
+ * it).
9
+ * 2. **piped child shell** (fallback, dependency-free) — spawns the user's
10
+ * shell with piped stdio. Commands run and output streams live, which is
11
+ * enough for "run a command for the user"; full TTY apps are degraded.
12
+ *
13
+ * One process is shared per cwd (a module singleton map), so the agent's
14
+ * `terminal` tool and the desktop pane drive the SAME session — the user sees
15
+ * the agent's commands appear live and can take over typing.
16
+ */
17
+
18
+ import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
19
+ import { chmodSync, existsSync, statSync } from 'node:fs';
20
+ import { createRequire } from 'node:module';
21
+ import * as nodePath from 'node:path';
22
+
23
+ /**
24
+ * Upper bound on output/exit listeners on a single shared process. A surface
25
+ * whose `close()` never runs (the viewer disconnects abnormally, the desktop
26
+ * crashes mid-stream) leaks its subscription for the life of the shared shell.
27
+ * Past this many live listeners we warn once — a runaway count is a leak, not a
28
+ * legitimate fan-out (only a handful of viewers + the per-command reader are
29
+ * ever expected). The set is not hard-capped (dropping a real viewer's stream
30
+ * is worse than the warning), but the diagnostic makes the leak visible.
31
+ */
32
+ const LISTENER_WARN_THRESHOLD = 64;
33
+
34
+ /**
35
+ * Grace period before escalating a kill() from SIGTERM to SIGKILL. An
36
+ * interactive shell that ignores/handles SIGTERM (or is wedged) would otherwise
37
+ * never die; after this we force it.
38
+ */
39
+ const KILL_ESCALATION_MS = 2_000;
40
+
41
+ /** Minimal slice of node-pty we use — declared locally so typecheck never needs
42
+ * `@types/node-pty` (the dep is optional). */
43
+ interface NodePtyModule {
44
+ spawn(
45
+ file: string,
46
+ args: string[] | string,
47
+ opts: { name?: string; cols?: number; rows?: number; cwd?: string; env?: NodeJS.ProcessEnv },
48
+ ): NodePtyProcess;
49
+ }
50
+ interface NodePtyProcess {
51
+ onData(cb: (data: string) => void): void;
52
+ onExit(cb: (e: { exitCode: number }) => void): void;
53
+ write(data: string): void;
54
+ resize(cols: number, rows: number): void;
55
+ kill(signal?: string): void;
56
+ }
57
+
58
+ /**
59
+ * Resolve a usable NodePtyModule from whatever `import('node-pty')` yields, or
60
+ * null. Handles ESM/CJS interop (`.spawn` on the namespace OR on `.default`) and
61
+ * — critically — REQUIRES a callable `spawn` on whichever we pick. A malformed /
62
+ * partially-shimmed module (a `default` that lacks `spawn`, a non-function
63
+ * `spawn`) must degrade to the piped fallback HERE rather than be returned and
64
+ * blow up later as `pty.spawn is not a function` inside `createTerminalProcess`.
65
+ * Exported for tests — the optional dep is never present in CI, so the
66
+ * shape-resolution logic is unit-tested against hand-built module objects.
67
+ */
68
+ export function resolveNodePtyModule(m: unknown): NodePtyModule | null {
69
+ const hasSpawn = (v: unknown): v is NodePtyModule =>
70
+ typeof v === 'object' && v !== null && typeof (v as { spawn?: unknown }).spawn === 'function';
71
+ if (hasSpawn(m)) return m;
72
+ const def = (m as { default?: unknown } | null | undefined)?.default;
73
+ if (hasSpawn(def)) return def;
74
+ return null;
75
+ }
76
+
77
+ let nodePtyPromise: Promise<NodePtyModule | null> | undefined;
78
+ /** Lazy-load node-pty; resolves to null when it isn't installed/usable. */
79
+ function loadNodePty(): Promise<NodePtyModule | null> {
80
+ if (!nodePtyPromise) {
81
+ nodePtyPromise = import('node-pty' as string)
82
+ .then((m) => resolveNodePtyModule(m))
83
+ .catch(() => null);
84
+ }
85
+ return nodePtyPromise;
86
+ }
87
+
88
+ /** Add the executable bit (u+x,g+x,o+x) to a file if it lacks it. Returns true
89
+ * when the file now has it (was already +x, or we just set it). Exported for
90
+ * tests — the chmod logic is the load-bearing fix, so it's unit-tested directly
91
+ * without needing node-pty present. */
92
+ export function makeExecutable(filePath: string): boolean {
93
+ try {
94
+ const st = statSync(filePath);
95
+ if (st.mode & 0o111) return true; // already executable
96
+ chmodSync(filePath, st.mode | 0o111);
97
+ return true;
98
+ } catch {
99
+ return false; // not present / not permitted — caller treats as best-effort
100
+ }
101
+ }
102
+
103
+ /**
104
+ * node-pty ships a prebuilt `spawn-helper` binary on macOS that it exec()s to
105
+ * launch the shell inside the PTY. Several install/repack paths (notably
106
+ * `npm install` into the desktop's writable CLI prefix, and pnpm's content store)
107
+ * drop the executable bit on it — node-pty then loads fine but `pty.spawn`
108
+ * throws `posix_spawnp failed`, which used to be swallowed into the (effectively
109
+ * dead) piped fallback. So before we spawn, ensure the helper is executable.
110
+ * Best-effort: never throws; the spawn itself is the real test.
111
+ */
112
+ function ensureSpawnHelperExecutable(): void {
113
+ if (process.platform === 'win32') return; // Windows uses conpty/winpty, no spawn-helper
114
+ try {
115
+ const root = nodePtyPackageRoot();
116
+ if (!root) return;
117
+ const candidates = [
118
+ // prebuildify layout (the published npm package)
119
+ nodePath.join(root, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
120
+ // build-from-source layout (no prebuild for this platform)
121
+ nodePath.join(root, 'build', 'Release', 'spawn-helper'),
122
+ ];
123
+ for (const c of candidates) makeExecutable(c);
124
+ } catch {
125
+ /* best-effort */
126
+ }
127
+ }
128
+
129
+ /** Resolve node-pty's package directory (the one containing `prebuilds/`),
130
+ * resolving from THIS module so it works whether node-pty sits in a hoisted
131
+ * root, a nested, or a pnpm store layout. */
132
+ function nodePtyPackageRoot(): string | null {
133
+ try {
134
+ const require = createRequire(import.meta.url);
135
+ try {
136
+ return nodePath.dirname(require.resolve('node-pty/package.json'));
137
+ } catch {
138
+ // `exports` may hide package.json; resolve the entry and walk up to the
139
+ // first ancestor that has a `prebuilds/` (or `build/`) dir.
140
+ let dir = nodePath.dirname(require.resolve('node-pty'));
141
+ for (let i = 0; i < 6; i += 1) {
142
+ if (existsSync(nodePath.join(dir, 'prebuilds')) || existsSync(nodePath.join(dir, 'build'))) {
143
+ return dir;
144
+ }
145
+ const parent = nodePath.dirname(dir);
146
+ if (parent === dir) break;
147
+ dir = parent;
148
+ }
149
+ return null;
150
+ }
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /** Pick the user's interactive shell per platform. */
157
+ function defaultShell(): string {
158
+ if (process.platform === 'win32') return process.env['COMSPEC'] ?? 'powershell.exe';
159
+ return process.env['SHELL'] ?? '/bin/bash';
160
+ }
161
+
162
+ /** Cap retained scrollback so a chatty process can't grow the buffer forever. */
163
+ const MAX_SCROLLBACK = 200_000;
164
+ /**
165
+ * Hysteresis margin: let the live buffer grow this far past the cap before
166
+ * trimming back down to it, so we amortize the (expensive) slice over many
167
+ * chunks instead of copying ~MAX_SCROLLBACK bytes on every chunk once
168
+ * saturated. `scrollback()` masks the slack by always returning the last
169
+ * MAX_SCROLLBACK chars.
170
+ */
171
+ const SCROLLBACK_SLACK = 100_000;
172
+
173
+ /** Upper bound on a PTY dimension. A viewer/relay could request an absurd
174
+ * cols/rows (off the wire, unvalidated upstream); node-pty/conpty allocates
175
+ * per-cell and can throw or wedge on extreme values, so cap to a generous but
176
+ * finite ceiling. Far larger than any real terminal. */
177
+ const MAX_DIMENSION = 10_000;
178
+
179
+ /** Floor to an integer and clamp into [1, MAX_DIMENSION]; non-finite → 1. */
180
+ function clampDimension(n: number): number {
181
+ if (!Number.isFinite(n)) return 1;
182
+ return Math.min(MAX_DIMENSION, Math.max(1, Math.floor(n)));
183
+ }
184
+
185
+ export type TerminalBackend = 'pty' | 'pipe';
186
+
187
+ export interface TerminalProcess {
188
+ readonly backend: TerminalBackend;
189
+ /** When `backend === 'pipe'`, the reason the real PTY couldn't start (so the
190
+ * surface can show an honest "degraded" status instead of a silently-dead
191
+ * box). Null when a real PTY is in use. */
192
+ readonly ptyError: string | null;
193
+ /** Subscribe to output (utf8). Returns an unsubscribe fn. */
194
+ onData(cb: (data: string) => void): () => void;
195
+ /** Subscribe to process exit. */
196
+ onExit(cb: (code: number) => void): () => void;
197
+ /** Recent output for a late-joining viewer. */
198
+ scrollback(): string;
199
+ write(data: string): void;
200
+ resize(cols: number, rows: number): void;
201
+ kill(): void;
202
+ readonly alive: boolean;
203
+ }
204
+
205
+ /** Exported for tests: lets a suite drive `emitData`/`scrollback` directly
206
+ * without spawning a real shell. Not part of the plugin's public surface. */
207
+ export class TerminalProcessImpl implements TerminalProcess {
208
+ private readonly dataListeners = new Set<(d: string) => void>();
209
+ private readonly exitListeners = new Set<(c: number) => void>();
210
+ private buffer = '';
211
+ private warnedListenerLeak = false;
212
+ alive = true;
213
+
214
+ constructor(
215
+ readonly backend: TerminalBackend,
216
+ private readonly pty: NodePtyProcess | null,
217
+ private readonly child: ChildProcessWithoutNullStreams | null,
218
+ readonly ptyError: string | null = null,
219
+ ) {
220
+ if (pty) {
221
+ pty.onData((d) => this.emitData(d));
222
+ pty.onExit((e) => this.emitExit(e.exitCode));
223
+ } else if (child) {
224
+ // The shared child is long-lived and may be (re)subscribed by several
225
+ // viewers over its lifetime; make the intent explicit so Node never emits
226
+ // a false-positive "possible EventEmitter memory leak detected" warning on
227
+ // its streams. Our own fan-out Sets are the real bound (see addListener).
228
+ child.stdout.setMaxListeners(0);
229
+ child.stderr.setMaxListeners(0);
230
+ child.stdin.setMaxListeners(0);
231
+ child.setMaxListeners(0);
232
+ child.stdout.on('data', (b: Buffer) => this.emitData(b.toString('utf8')));
233
+ child.stderr.on('data', (b: Buffer) => this.emitData(b.toString('utf8')));
234
+ child.on('exit', (code) => this.emitExit(code ?? 0));
235
+ child.on('error', () => this.emitExit(1));
236
+ // The child can exit (closing stdin) before the async 'exit' event flips
237
+ // `alive` — a write in that window can emit a broken-pipe 'error' on
238
+ // stdin. Swallow it here so it never goes unhandled and crashes the host.
239
+ child.stdin.on('error', () => {
240
+ /* broken pipe after shell exit — ignored */
241
+ });
242
+ }
243
+ }
244
+
245
+ /** Warn once when a listener Set grows past the leak threshold (a viewer whose
246
+ * close() never ran keeps its subscription for the shell's whole life). */
247
+ private checkListenerLeak(): void {
248
+ if (this.warnedListenerLeak) return;
249
+ if (this.dataListeners.size + this.exitListeners.size > LISTENER_WARN_THRESHOLD) {
250
+ this.warnedListenerLeak = true;
251
+
252
+ console.warn(
253
+ `[plugin-terminal] shared terminal has ${this.dataListeners.size} data + ` +
254
+ `${this.exitListeners.size} exit listeners — likely a viewer that never closed.`,
255
+ );
256
+ }
257
+ }
258
+
259
+ private emitData(d: string): void {
260
+ // Append, and only trim when we exceed the cap by a hysteresis margin —
261
+ // trimming all the way back to the cap. The previous code sliced the full
262
+ // 200KB buffer on EVERY chunk once saturated (O(total_output * cap) churn);
263
+ // amortizing the trim makes appends ~O(1). `scrollback()` always returns the
264
+ // last MAX_SCROLLBACK chars, so the observable tail is unchanged.
265
+ this.buffer += d;
266
+ if (this.buffer.length > MAX_SCROLLBACK + SCROLLBACK_SLACK) {
267
+ this.buffer = this.buffer.slice(-MAX_SCROLLBACK);
268
+ }
269
+ for (const cb of this.dataListeners) {
270
+ try {
271
+ cb(d);
272
+ } catch {
273
+ /* a bad viewer must not break the stream */
274
+ }
275
+ }
276
+ }
277
+
278
+ private emitExit(code: number): void {
279
+ if (!this.alive) return;
280
+ this.alive = false;
281
+ for (const cb of this.exitListeners) {
282
+ try {
283
+ cb(code);
284
+ } catch {
285
+ /* ignore */
286
+ }
287
+ }
288
+ }
289
+
290
+ onData(cb: (d: string) => void): () => void {
291
+ this.dataListeners.add(cb);
292
+ this.checkListenerLeak();
293
+ return () => this.dataListeners.delete(cb);
294
+ }
295
+
296
+ onExit(cb: (c: number) => void): () => void {
297
+ this.exitListeners.add(cb);
298
+ this.checkListenerLeak();
299
+ return () => this.exitListeners.delete(cb);
300
+ }
301
+
302
+ scrollback(): string {
303
+ // The buffer may hold up to MAX_SCROLLBACK + SCROLLBACK_SLACK chars between
304
+ // trims (see emitData); always hand back exactly the last MAX_SCROLLBACK so
305
+ // a late-joining viewer sees the same tail as before the hysteresis change.
306
+ return this.buffer.length > MAX_SCROLLBACK
307
+ ? this.buffer.slice(-MAX_SCROLLBACK)
308
+ : this.buffer;
309
+ }
310
+
311
+ write(data: string): void {
312
+ if (!this.alive) return;
313
+ try {
314
+ if (this.pty) this.pty.write(data);
315
+ else this.child?.stdin.write(data);
316
+ } catch {
317
+ // The child may have exited between `alive` flipping and now, leaving a
318
+ // closed stdin pipe — a synchronous EPIPE here must not crash the host.
319
+ }
320
+ }
321
+
322
+ resize(cols: number, rows: number): void {
323
+ if (this.pty && this.alive) {
324
+ // Coerce to a sane integer in [1, MAX_DIMENSION]. Callers SHOULD pre-validate
325
+ // (terminal.ts isValidDimension), but a float/huge value reaching node-pty
326
+ // can throw or wedge conpty, so clamp defensively here too.
327
+ try {
328
+ this.pty.resize(clampDimension(cols), clampDimension(rows));
329
+ } catch {
330
+ /* resize on a dead pty — ignore */
331
+ }
332
+ }
333
+ // The piped fallback has no TTY to resize.
334
+ }
335
+
336
+ kill(): void {
337
+ if (!this.alive) return;
338
+ try {
339
+ if (this.pty) {
340
+ // node-pty kills the conpty/pty session; on POSIX it signals the shell.
341
+ // Send SIGTERM, then escalate to SIGKILL after a grace period if the
342
+ // shell ignores/handles it (a wedged shell would otherwise never die).
343
+ this.pty.kill();
344
+ const pty = this.pty;
345
+ setTimeout(() => {
346
+ try {
347
+ pty.kill('SIGKILL');
348
+ } catch {
349
+ /* already gone */
350
+ }
351
+ }, KILL_ESCALATION_MS).unref?.();
352
+ }
353
+ if (this.child) this.killChildTree(this.child);
354
+ } catch {
355
+ /* already gone */
356
+ }
357
+ this.emitExit(0);
358
+ }
359
+
360
+ /**
361
+ * Terminate the piped shell AND its descendants. The child is spawned
362
+ * `detached` (its own process group), so a negative-pid signal reaches the
363
+ * whole tree — otherwise a running grandchild (a `sleep`, a dev server, a
364
+ * `tail -f`, a build) is reparented to init and leaks past session teardown,
365
+ * holding ports/files. Escalate SIGTERM → SIGKILL after a grace period.
366
+ */
367
+ private killChildTree(child: ChildProcessWithoutNullStreams): void {
368
+ const pid = child.pid;
369
+ const signalGroup = (signal: NodeJS.Signals): void => {
370
+ try {
371
+ if (pid !== undefined && process.platform !== 'win32') {
372
+ // Negative pid = the whole process group (requires detached spawn).
373
+ process.kill(-pid, signal);
374
+ } else {
375
+ // Windows / unknown pid: node handles its own tree (taskkill /T-like).
376
+ child.kill(signal);
377
+ }
378
+ } catch {
379
+ // No such process group (already dead) or not permitted — fall back to
380
+ // signaling just the child so we never leave it running.
381
+ try {
382
+ child.kill(signal);
383
+ } catch {
384
+ /* already gone */
385
+ }
386
+ }
387
+ };
388
+ signalGroup('SIGTERM');
389
+ setTimeout(() => signalGroup('SIGKILL'), KILL_ESCALATION_MS).unref?.();
390
+ }
391
+ }
392
+
393
+ /** Spawn a fresh shared terminal in `cwd`, preferring a real PTY. */
394
+ export async function createTerminalProcess(cwd: string): Promise<TerminalProcess> {
395
+ const shell = defaultShell();
396
+ const cols = 80;
397
+ const rows = 24;
398
+ // SECURITY: the shared shell deliberately inherits the runner's full
399
+ // environment (API keys, tokens, MOXXY_* signing keys). This mirrors a real
400
+ // user shell so the agent's commands behave as the user expects (a script
401
+ // that needs $GITHUB_TOKEN works). The trade-off is that `env`/`printenv` can
402
+ // surface secrets into captured output and scrollback — acceptable because the
403
+ // terminal is a deliberately user-facing, user-controllable surface. Do NOT
404
+ // strip vars here (it would silently break legitimate commands); gate any
405
+ // scrubbing behind an explicit opt-in if ever needed.
406
+ const env: NodeJS.ProcessEnv = { ...process.env, TERM: 'xterm-256color' };
407
+ const pty = await loadNodePty();
408
+ let ptyError: string | null = pty ? null : 'node-pty is not installed';
409
+ if (pty) {
410
+ const trySpawn = (): NodePtyProcess =>
411
+ pty.spawn(shell, [], { name: 'xterm-256color', cols, rows, cwd, env });
412
+ // Make sure the prebuilt spawn-helper is executable, then spawn. If the first
413
+ // spawn still fails (e.g. the bit was only just lost), repair + retry ONCE
414
+ // before giving up — most "posix_spawnp failed" cases clear on the retry.
415
+ ensureSpawnHelperExecutable();
416
+ try {
417
+ return new TerminalProcessImpl('pty', trySpawn(), null);
418
+ } catch {
419
+ ensureSpawnHelperExecutable();
420
+ try {
421
+ return new TerminalProcessImpl('pty', trySpawn(), null);
422
+ } catch (err2) {
423
+ // Don't swallow it: record WHY so the surface can show an honest status
424
+ // instead of a silently-dead piped terminal.
425
+ ptyError = err2 instanceof Error ? err2.message : String(err2);
426
+ }
427
+ }
428
+ }
429
+ // Dependency-free fallback: an interactive shell with piped stdio. `-i` keeps
430
+ // it from exiting immediately; non-Windows only — cmd/powershell are already
431
+ // interactive when stdin is piped. NOTE: this has no TTY line discipline (a
432
+ // viewer's `\r` is never turned into `\n`, nothing echoes), so it is NOT a
433
+ // usable interactive terminal — `ptyError` is surfaced to the user so the pane
434
+ // reports the degraded state rather than appearing to ignore every keystroke.
435
+ const args = process.platform === 'win32' ? [] : ['-i'];
436
+ const child = spawn(shell, args, {
437
+ cwd,
438
+ env: { ...env, PS1: '$ ' },
439
+ stdio: ['pipe', 'pipe', 'pipe'],
440
+ // Own process group (POSIX) so kill() can signal the WHOLE tree by negative
441
+ // pid — otherwise grandchildren (a dev server, a `tail -f`, a build) outlive
442
+ // the session. No-op semantics on Windows; node-pty handles its own tree.
443
+ detached: process.platform !== 'win32',
444
+ });
445
+ return new TerminalProcessImpl('pipe', null, child, ptyError);
446
+ }