@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/LICENSE +21 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/pty.d.ts +108 -0
- package/dist/pty.d.ts.map +1 -0
- package/dist/pty.js +415 -0
- package/dist/pty.js.map +1 -0
- package/dist/terminal.d.ts +33 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +437 -0
- package/dist/terminal.js.map +1 -0
- package/package.json +71 -0
- package/src/index.test.ts +127 -0
- package/src/index.ts +28 -0
- package/src/pty.test.ts +285 -0
- package/src/pty.ts +446 -0
- package/src/terminal.test.ts +368 -0
- package/src/terminal.ts +458 -0
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
|
+
}
|