@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.
@@ -0,0 +1,458 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { defineSurface, defineTool, z, type SurfaceInstance } from '@moxxy/sdk';
3
+ import { createTerminalProcess, type TerminalProcess } from './pty.js';
4
+
5
+ /**
6
+ * Shared terminal processes, keyed by cwd. The surface and the `terminal` tool
7
+ * resolve the SAME process for a cwd, so a command the agent runs appears in the
8
+ * pane the user is watching (and vice-versa). A dead process is replaced on the
9
+ * next request.
10
+ */
11
+ const shared = new Map<string, TerminalProcess>();
12
+ /**
13
+ * In-flight creates, keyed by cwd. Memoizing the PROMISE (not the resolved
14
+ * process) closes a create race: `createTerminalProcess` awaits, so two
15
+ * concurrent callers for the same cwd would both miss `shared`, both spawn a
16
+ * shell, and the second `set` would orphan the first PTY. By recording the
17
+ * promise synchronously before awaiting, the second caller reuses the first's
18
+ * in-flight create instead of spawning a duplicate.
19
+ */
20
+ const pending = new Map<string, Promise<TerminalProcess>>();
21
+ /**
22
+ * Monotonic shutdown generation, bumped by every `closeAllTerminals`. A create
23
+ * captures the epoch when it STARTS; if a shutdown happened since (the epoch
24
+ * advanced), its continuation self-disposes — the kill loop already ran and
25
+ * cleared `shared`, so storing that freshly-spawned shell would strand a live
26
+ * PTY/child past session teardown (the onShutdown hook already fired).
27
+ *
28
+ * A counter, not a boolean, on purpose: a new session reusing this module
29
+ * singleton calls `getSharedTerminal` again, which must re-arm so the NEW
30
+ * create is retained. A boolean reset on every call would ALSO resurrect a
31
+ * STALE pre-shutdown create that resolves after the re-arm, double-spawning and
32
+ * orphaning a shell. Comparing the create's captured epoch to the live one
33
+ * disposes only the creates that actually straddle a shutdown, regardless of
34
+ * later re-arms.
35
+ */
36
+ let epoch = 0;
37
+
38
+ export async function getSharedTerminal(
39
+ cwd: string,
40
+ create: (cwd: string) => Promise<TerminalProcess> = createTerminalProcess,
41
+ ): Promise<TerminalProcess> {
42
+ const existing = shared.get(cwd);
43
+ if (existing && existing.alive) return existing;
44
+ const inFlight = pending.get(cwd);
45
+ if (inFlight) return inFlight;
46
+ // Capture the epoch this create belongs to. If a shutdown bumps `epoch` while
47
+ // we await, the continuation sees the mismatch and disposes the orphan.
48
+ const startedEpoch = epoch;
49
+ const promise = (async () => {
50
+ const proc = await create(cwd);
51
+ // A shutdown landed while this create was in flight (epoch advanced) — the
52
+ // kill loop already ran and cleared `shared`, so dispose this orphan
53
+ // immediately instead of storing a shell nothing will ever kill.
54
+ if (epoch !== startedEpoch) {
55
+ proc.kill();
56
+ return proc;
57
+ }
58
+ shared.set(cwd, proc);
59
+ proc.onExit(() => {
60
+ if (shared.get(cwd) === proc) shared.delete(cwd);
61
+ });
62
+ return proc;
63
+ })().finally(() => {
64
+ // Clear the in-flight slot only once this create settles, AND only if it's
65
+ // still ours — a shutdown + re-arm may have replaced the slot with a newer
66
+ // create for the same cwd, which we must not clobber.
67
+ if (pending.get(cwd) === promise) pending.delete(cwd);
68
+ });
69
+ pending.set(cwd, promise);
70
+ return promise;
71
+ }
72
+
73
+ /** Dispose every shared terminal (plugin shutdown / session close). */
74
+ export function closeAllTerminals(): void {
75
+ // Bump the epoch FIRST so any create that resolves after this point sees the
76
+ // mismatch and self-kills (see getSharedTerminal). `pending.clear()` alone is
77
+ // insufficient — the promise body still runs to completion and would
78
+ // otherwise store the proc.
79
+ epoch += 1;
80
+ for (const proc of shared.values()) proc.kill();
81
+ shared.clear();
82
+ pending.clear();
83
+ }
84
+
85
+ /** A terminal dimension (cols/rows) is usable only if it's a finite, positive
86
+ * number. Rejects NaN/Infinity/0/negatives/floats-with-no-magnitude before
87
+ * they reach the PTY, where they'd throw or be silently coerced. */
88
+ function isValidDimension(n: number | undefined): boolean {
89
+ return typeof n === 'number' && Number.isFinite(n) && n >= 1;
90
+ }
91
+
92
+ // --- Surface ----------------------------------------------------------------
93
+
94
+ /** The `terminal` surface: streams the shared PTY's output and feeds a viewer's
95
+ * keystrokes / resizes back into it. */
96
+ export function buildTerminalSurface() {
97
+ return defineSurface({
98
+ kind: 'terminal',
99
+ description: 'An embedded shell the user and the agent share.',
100
+ open: async (ctx): Promise<SurfaceInstance> => {
101
+ const proc = await getSharedTerminal(ctx.cwd);
102
+ const dataSubs = new Set<(payload: unknown) => void>();
103
+ const emit = (payload: unknown): void => {
104
+ for (const cb of dataSubs) {
105
+ try {
106
+ cb(payload);
107
+ } catch {
108
+ /* a bad viewer must not break the stream (matches pty.ts emitData) */
109
+ }
110
+ }
111
+ };
112
+ const unsubData = proc.onData((data) => emit({ type: 'data', data }));
113
+ const unsubExit = proc.onExit((code) => emit({ type: 'exit', code }));
114
+ // Honest degraded state: without a real PTY the pane can't function as an
115
+ // interactive terminal (no echo, a viewer's Enter never reaches the shell).
116
+ // Tell the viewer rather than presenting a box that silently ignores input.
117
+ if (proc.backend === 'pipe') {
118
+ emit({
119
+ type: 'status',
120
+ text: proc.ptyError
121
+ ? `Terminal unavailable: the PTY backend failed to start (${proc.ptyError}).`
122
+ : 'Terminal unavailable: a real PTY backend (node-pty) is not available.',
123
+ });
124
+ }
125
+ return {
126
+ id: 'terminal',
127
+ kind: 'terminal',
128
+ onData: (cb) => {
129
+ dataSubs.add(cb);
130
+ return () => dataSubs.delete(cb);
131
+ },
132
+ snapshot: () => ({
133
+ type: 'snapshot',
134
+ data: proc.scrollback(),
135
+ backend: proc.backend,
136
+ ptyError: proc.ptyError,
137
+ }),
138
+ input: (msg) => {
139
+ if (msg.type === 'data' && typeof msg.data === 'string') {
140
+ proc.write(msg.data);
141
+ } else {
142
+ // A viewer/relay sending a wrong-shaped message (field rename,
143
+ // numeric data, protocol skew) gets the keystroke silently dropped.
144
+ // Log it so the "terminal ignores my input" symptom is diagnosable.
145
+ ctx.logger?.debug?.('terminal surface dropped unrecognized input', { type: msg.type });
146
+ }
147
+ },
148
+ resize: (size) => {
149
+ // Validate before touching the PTY: a viewer/relay may send NaN,
150
+ // Infinity, floats, negatives, or 0 (SurfaceSize.cols/rows are bare
151
+ // `number`s — anything decoded off the wire). `resize` in pty.ts also
152
+ // clamps, but reject obviously-bad shapes here with a diagnostic so a
153
+ // dropped resize is traceable rather than a silent no-op.
154
+ if (isValidDimension(size.cols) && isValidDimension(size.rows)) {
155
+ proc.resize(size.cols as number, size.rows as number);
156
+ } else {
157
+ ctx.logger?.debug?.('terminal surface dropped malformed resize', { size });
158
+ }
159
+ },
160
+ close: () => {
161
+ // Detach this viewer; the underlying shared process stays alive for
162
+ // the agent's tool and any other viewer (closed on session teardown).
163
+ unsubData();
164
+ unsubExit();
165
+ },
166
+ };
167
+ },
168
+ });
169
+ }
170
+
171
+ // --- Tool -------------------------------------------------------------------
172
+
173
+ const terminalInputSchema = z.object({
174
+ command: z
175
+ .string()
176
+ .min(1)
177
+ .max(10_000)
178
+ .describe('A shell command to run in the user-visible terminal.'),
179
+ timeoutMs: z
180
+ .number()
181
+ .int()
182
+ .positive()
183
+ .max(600_000)
184
+ .optional()
185
+ .describe('Max time to wait for the command to finish (default 30s).'),
186
+ });
187
+
188
+ /**
189
+ * The `terminal` tool runs a command in the SHARED terminal the user sees. It
190
+ * appends a unique sentinel that echoes the exit code, then reads output until
191
+ * the sentinel returns — reliable completion detection in an interactive,
192
+ * input-echoing shell. The command and its output stay visible to the user, who
193
+ * can take over at any time.
194
+ */
195
+ export function buildTerminalTool() {
196
+ return defineTool({
197
+ name: 'terminal',
198
+ description:
199
+ 'Run a shell command in the user-visible terminal and return its output. ' +
200
+ 'The terminal is SHARED with the user — they see what you run and can take over. ' +
201
+ 'Use this to run applications or commands for the user (builds, scripts, CLIs). ' +
202
+ 'For long-running/interactive programs, expect partial output up to the timeout.',
203
+ inputSchema: terminalInputSchema,
204
+ // Bash-class honest caps: this drives a real shell (node-pty), so the
205
+ // command can touch anything the user could. The shared shell deliberately
206
+ // inherits the FULL runner env (see the SECURITY note in pty.ts); the env
207
+ // list below is the shell-relevant subset an enforcing isolator should
208
+ // provide when it re-spawns under a constrained env.
209
+ isolation: {
210
+ capabilities: {
211
+ subprocess: true,
212
+ fs: { read: ['$cwd/**', '/tmp/**'], write: ['$cwd/**', '/tmp/**'] },
213
+ net: { mode: 'any' },
214
+ env: ['PATH', 'HOME', 'USER', 'SHELL', 'COMSPEC', 'LANG', 'LC_ALL', 'TERM'],
215
+ timeMs: 600_000,
216
+ },
217
+ },
218
+ handler: async (input, ctx) => {
219
+ const proc = await getSharedTerminal(ctx.cwd ?? process.cwd());
220
+ const timeoutMs = input.timeoutMs ?? 30_000;
221
+ // Honor the turn's abort signal: when the loop/user cancels the turn, the
222
+ // command must stop waiting PROMPTLY rather than blocking the tool slot for
223
+ // up to the full timeout (600s) on a session that's already being torn down.
224
+ // We do NOT kill the shared shell (it's user-facing and may host other work)
225
+ // — we just stop awaiting its sentinel.
226
+ return runCommand(proc, input.command, makeMarker(), timeoutMs, ctx.signal);
227
+ },
228
+ });
229
+ }
230
+
231
+ /**
232
+ * A high-entropy, unpredictable completion marker. The exit code is detected by
233
+ * matching `<marker> <digits>` on its own line; a time+counter marker was
234
+ * predictable, so untrusted command output (a printed file, a remote response)
235
+ * could deliberately emit `<predicted-marker> <chosen-exit>` to spoof an
236
+ * attacker-chosen exit status and truncate the output. 64 bits of randomness
237
+ * makes that practically impossible to reproduce.
238
+ */
239
+ function makeMarker(): string {
240
+ return `__MOXXY_DONE_${randomBytes(8).toString('hex')}__`;
241
+ }
242
+
243
+ /**
244
+ * Cap the per-command accumulator. The shared scrollback in pty.ts is bounded,
245
+ * but THIS accumulator is per-call: a command that floods output before its
246
+ * sentinel arrives (`cat huge.log`, `yes`, a `tail -f` that never returns until
247
+ * the 600s timeout) would otherwise grow `acc` unbounded → OOM the runner. The
248
+ * sentinel is always at the END of the stream, so retaining only the tail keeps
249
+ * detection correct while making memory O(cap) instead of O(total output).
250
+ */
251
+ const MAX_ACC = 1_000_000;
252
+
253
+ /**
254
+ * Per-shared-process command serialization. The surface and the tool share ONE
255
+ * shell per cwd; runCommand writes `command` + a sentinel `printf` and reads the
256
+ * merged stream until ITS sentinel appears. With no serialization, two
257
+ * concurrent tool calls (parallel tool calls are normal) — or a tool call
258
+ * overlapping a user typing in the pane — would interleave their command +
259
+ * sentinel writes into one line-oriented shell, scrambling output and letting
260
+ * each `$?` capture the OTHER command's exit. We hold a per-proc tail promise: a
261
+ * command writes only once the previous one on the same shell has finished, so
262
+ * writes are single-file. The entry is deleted once the shell goes idle, so the
263
+ * NEXT command on an idle shell writes synchronously (no microtask hop) —
264
+ * preserving the single-command happy path exactly (listener + write in the same
265
+ * tick the caller invokes runCommand). Keyed weakly so a disposed process drops
266
+ * its tail.
267
+ */
268
+ const commandTails = new WeakMap<TerminalProcess, Promise<void>>();
269
+
270
+ /**
271
+ * Write `command` then a sentinel `printf` to a shared shell and collect output
272
+ * until the sentinel line appears. Returns the captured output (best-effort
273
+ * stripped of the echoed sentinel command) + the exit code parsed from it.
274
+ *
275
+ * Serialized per shared process: a command writes only once the previous one on
276
+ * the same shell finishes, so concurrent callers never interleave their writes
277
+ * on the single stdin and each `$?` reflects its OWN command.
278
+ */
279
+ export function runCommand(
280
+ proc: TerminalProcess,
281
+ command: string,
282
+ marker: string,
283
+ timeoutMs: number,
284
+ /** Optional turn-abort signal; firing it finishes the command immediately
285
+ * (timedOut=false) without waiting for the sentinel or the timeout. */
286
+ signal?: AbortSignal,
287
+ ): Promise<{ output: string; exitCode: number | null; timedOut: boolean }> {
288
+ // Idle shell (no pending tail) → write synchronously (undefined). Busy shell →
289
+ // defer this command's writes until the current tail settles.
290
+ const startWrites = commandTails.get(proc);
291
+ const run = runCommandSerialized(proc, command, marker, timeoutMs, startWrites, signal);
292
+ // This command becomes the new tail; later callers wait on it. Swallow
293
+ // rejections (it never rejects today) so one failure can't poison the queue.
294
+ const settled = run.then(
295
+ () => {},
296
+ () => {},
297
+ );
298
+ commandTails.set(proc, settled);
299
+ // Once this command settles, if nothing newer queued behind it, the shell is
300
+ // idle again — drop the entry so the next command writes synchronously.
301
+ void settled.then(() => {
302
+ if (commandTails.get(proc) === settled) commandTails.delete(proc);
303
+ });
304
+ return run;
305
+ }
306
+
307
+ function runCommandSerialized(
308
+ proc: TerminalProcess,
309
+ command: string,
310
+ marker: string,
311
+ timeoutMs: number,
312
+ /** Resolves when it's this command's turn to write; undefined = write now. */
313
+ startWrites: Promise<void> | undefined,
314
+ /** Turn-abort signal; firing it finishes immediately (see runCommand). */
315
+ signal: AbortSignal | undefined,
316
+ ): Promise<{ output: string; exitCode: number | null; timedOut: boolean }> {
317
+ return new Promise((resolve) => {
318
+ let acc = '';
319
+ let settled = false;
320
+ const finish = (exitCode: number | null, timedOut: boolean): void => {
321
+ if (settled) return;
322
+ settled = true;
323
+ unsubData();
324
+ unsubExit();
325
+ clearTimeout(timer);
326
+ if (onAbort) signal?.removeEventListener('abort', onAbort);
327
+ resolve({ output: cleanOutput(acc, command, marker), exitCode, timedOut });
328
+ };
329
+ // Already aborted before we even start? Finish immediately — don't write to
330
+ // the shell, don't arm a timeout. The command never runs; null exit.
331
+ if (signal?.aborted) {
332
+ // `finish` below isn't fully wired yet (unsubData/unsubExit/timer are
333
+ // declared after this point), so resolve directly for the pre-armed case.
334
+ resolve({ output: '', exitCode: null, timedOut: false });
335
+ return;
336
+ }
337
+ // Abort while waiting/running → stop awaiting the sentinel and resolve with a
338
+ // null exit (the command may have partially run; we report what we captured).
339
+ // We deliberately do NOT kill the shared shell here — it is user-facing and
340
+ // may be hosting other work; we only detach this command's reader.
341
+ const onAbort = (): void => finish(null, false);
342
+ signal?.addEventListener('abort', onAbort, { once: true });
343
+ // Compile the sentinel matcher ONCE — `marker` is fixed for this call. The
344
+ // sentinel is `<marker> <digits>`; the marker is `__MOXXY_DONE_<hex>__`, so
345
+ // nothing in it is a regex metacharacter and it needs no escaping. Anchor the
346
+ // match to a REAL newline — `\n` only, NOT the regex `^` — so command OUTPUT
347
+ // that merely CONTAINS the literal string (echoing a captured transcript,
348
+ // grepping a file) cannot false-trigger completion. (`^` is deliberately
349
+ // excluded: we scan a SLICE of the buffer for O(n) cost, and `^` would match
350
+ // the slice's first char even when that char is mid-line — a hostile line
351
+ // like `prefix<marker> 137` whose `<marker>` happens to land at the slice
352
+ // start would otherwise spoof an attacker-chosen exit code. By requiring a
353
+ // literal `\n`, a sentinel is only ever recognized when a genuine newline
354
+ // precedes it in the buffer.) printf always appends a trailing `\n`.
355
+ const sentinel = new RegExp(`\\n${marker} (\\d+)\\r?\\n`);
356
+ // Re-scanning the whole accumulated buffer per chunk is O(n^2). The sentinel
357
+ // appears exactly once (unique marker) and we finish on first match, so it
358
+ // suffices to scan only the new chunk plus a carry-over of the previous
359
+ // tail long enough to catch a sentinel split across the chunk boundary:
360
+ // a leading newline + marker + space + a generous run of digits + trailing
361
+ // newline.
362
+ const carry = marker.length + 64;
363
+ // The shell ALWAYS echoes the command (and our printf) on their own lines, so
364
+ // a real sentinel is always preceded by a newline IN the buffer — we never
365
+ // need to treat the very first byte of the stream as a line start. To keep
366
+ // the `\n`-anchored match correct across the slice boundary, the scan window
367
+ // is widened by one extra leading char so the newline that precedes a
368
+ // sentinel can never be sliced away (see `scanStart` below).
369
+ let scanFrom = 0;
370
+ const unsubData = proc.onData((d) => {
371
+ // Begin the scan window just before the bytes that could only now have
372
+ // completed a sentinel that started in an earlier chunk.
373
+ let from = Math.max(scanFrom, acc.length - carry, 0);
374
+ acc += d;
375
+ // Bound the accumulator: keep only the tail. The sentinel lives at the
376
+ // end of the stream, so trimming the head never drops it. Shift the scan
377
+ // index down by the trimmed amount so the carry-window math stays correct.
378
+ if (acc.length > MAX_ACC) {
379
+ const removed = acc.length - MAX_ACC;
380
+ acc = acc.slice(removed);
381
+ from = Math.max(0, from - removed);
382
+ }
383
+ // Scan from one char BEFORE `from` so the newline that precedes a sentinel
384
+ // straddling the carry boundary is inside the window (the match needs the
385
+ // leading `\n`). Slicing exactly at `from` could otherwise drop that
386
+ // newline and miss — or, with a bare `^` anchor, falsely match — the
387
+ // sentinel. Backing up one char keeps the real preceding newline in scope.
388
+ const scanStart = Math.max(0, from - 1);
389
+ // At the TRUE stream start (scanStart === 0) a sentinel may legitimately be
390
+ // the very first line, with no real newline before it. Prepend a synthetic
391
+ // `\n` ONLY there so the `\n`-anchored match recognizes it — this never
392
+ // creates a false mid-stream line start, because it's applied solely when
393
+ // the slice begins at byte 0 of the whole stream.
394
+ const hay = scanStart > 0 ? acc.slice(scanStart) : `\n${acc}`;
395
+ sentinel.lastIndex = 0;
396
+ const m = sentinel.exec(hay);
397
+ if (m) finish(Number(m[1]), false);
398
+ // Everything before this point can never start a future sentinel match.
399
+ scanFrom = Math.max(0, acc.length - carry);
400
+ });
401
+ // If the shared shell dies while this command runs (user types `exit`, the
402
+ // shell crashes, the process is killed), the sentinel never arrives — finish
403
+ // immediately with the shell's exit code rather than hanging to the full
404
+ // timeout (up to 600s) on a known-dead process.
405
+ const unsubExit = proc.onExit((code) => finish(code, false));
406
+ let timer: ReturnType<typeof setTimeout> | undefined;
407
+ // Two writes to the interactive shell: the command, then the sentinel whose
408
+ // $? reflects the command's exit. A leading newline before the printf
409
+ // terminates any dangling/unterminated command line (trailing backslash,
410
+ // unbalanced quote) so the sentinel is always emitted rather than consumed
411
+ // as a continuation — otherwise such a command would always time out. On a
412
+ // non-PTY pipe the shell still runs both sequentially.
413
+ const writeAndArm = (): void => {
414
+ if (settled) return; // shell died (or we were torn down) before our turn
415
+ // The shell is ALREADY dead at write time (it exited before our turn, or
416
+ // in the TOCTOU window between getSharedTerminal returning it and now).
417
+ // `onExit` won't fire again — it already fired and is single-shot — so a
418
+ // bare write would hang to the full timeout (up to 600s) on a known-dead
419
+ // process. Finish immediately instead. Reported as timedOut=false / null
420
+ // exit: the command never actually ran, so there is no real exit status.
421
+ if (!proc.alive) {
422
+ finish(null, false);
423
+ return;
424
+ }
425
+ timer = setTimeout(() => finish(null, true), timeoutMs);
426
+ proc.write(`${command}\n`);
427
+ proc.write(`\nprintf '%s %s\\n' "${marker}" "$?"\n`);
428
+ };
429
+ // Idle shell → write now (keeps the single-command path fully synchronous).
430
+ // Busy shell → wait our turn; the timeout clock only starts when we write, so
431
+ // a command queued behind a slow predecessor doesn't burn its budget waiting.
432
+ if (startWrites) void startWrites.then(writeAndArm);
433
+ else writeAndArm();
434
+ });
435
+ }
436
+
437
+ /** Strip the echoed command + sentinel lines so the model sees just the output. */
438
+ function cleanOutput(acc: string, command: string, marker: string): string {
439
+ // A multi-line command is echoed by an echo-on PTY one line at a time; none of
440
+ // those echoed lines equals the full `command`, so match each command line
441
+ // independently. (Single-line commands are the trivial one-element case.)
442
+ const commandLines = new Set(
443
+ command
444
+ .split('\n')
445
+ .map((l) => l.trim())
446
+ .filter((l) => l.length > 0), // never strip blank output lines
447
+ );
448
+ return acc
449
+ .split('\n')
450
+ .filter(
451
+ (line) =>
452
+ !line.includes(marker) &&
453
+ !commandLines.has(line.trim()) &&
454
+ !line.includes(`printf '%s %s\\n' "${marker}"`),
455
+ )
456
+ .join('\n')
457
+ .trim();
458
+ }