@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,437 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { defineSurface, defineTool, z } from '@moxxy/sdk';
3
+ import { createTerminalProcess } from './pty.js';
4
+ /**
5
+ * Shared terminal processes, keyed by cwd. The surface and the `terminal` tool
6
+ * resolve the SAME process for a cwd, so a command the agent runs appears in the
7
+ * pane the user is watching (and vice-versa). A dead process is replaced on the
8
+ * next request.
9
+ */
10
+ const shared = new Map();
11
+ /**
12
+ * In-flight creates, keyed by cwd. Memoizing the PROMISE (not the resolved
13
+ * process) closes a create race: `createTerminalProcess` awaits, so two
14
+ * concurrent callers for the same cwd would both miss `shared`, both spawn a
15
+ * shell, and the second `set` would orphan the first PTY. By recording the
16
+ * promise synchronously before awaiting, the second caller reuses the first's
17
+ * in-flight create instead of spawning a duplicate.
18
+ */
19
+ const pending = new Map();
20
+ /**
21
+ * Monotonic shutdown generation, bumped by every `closeAllTerminals`. A create
22
+ * captures the epoch when it STARTS; if a shutdown happened since (the epoch
23
+ * advanced), its continuation self-disposes — the kill loop already ran and
24
+ * cleared `shared`, so storing that freshly-spawned shell would strand a live
25
+ * PTY/child past session teardown (the onShutdown hook already fired).
26
+ *
27
+ * A counter, not a boolean, on purpose: a new session reusing this module
28
+ * singleton calls `getSharedTerminal` again, which must re-arm so the NEW
29
+ * create is retained. A boolean reset on every call would ALSO resurrect a
30
+ * STALE pre-shutdown create that resolves after the re-arm, double-spawning and
31
+ * orphaning a shell. Comparing the create's captured epoch to the live one
32
+ * disposes only the creates that actually straddle a shutdown, regardless of
33
+ * later re-arms.
34
+ */
35
+ let epoch = 0;
36
+ export async function getSharedTerminal(cwd, create = createTerminalProcess) {
37
+ const existing = shared.get(cwd);
38
+ if (existing && existing.alive)
39
+ return existing;
40
+ const inFlight = pending.get(cwd);
41
+ if (inFlight)
42
+ return inFlight;
43
+ // Capture the epoch this create belongs to. If a shutdown bumps `epoch` while
44
+ // we await, the continuation sees the mismatch and disposes the orphan.
45
+ const startedEpoch = epoch;
46
+ const promise = (async () => {
47
+ const proc = await create(cwd);
48
+ // A shutdown landed while this create was in flight (epoch advanced) — the
49
+ // kill loop already ran and cleared `shared`, so dispose this orphan
50
+ // immediately instead of storing a shell nothing will ever kill.
51
+ if (epoch !== startedEpoch) {
52
+ proc.kill();
53
+ return proc;
54
+ }
55
+ shared.set(cwd, proc);
56
+ proc.onExit(() => {
57
+ if (shared.get(cwd) === proc)
58
+ shared.delete(cwd);
59
+ });
60
+ return proc;
61
+ })().finally(() => {
62
+ // Clear the in-flight slot only once this create settles, AND only if it's
63
+ // still ours — a shutdown + re-arm may have replaced the slot with a newer
64
+ // create for the same cwd, which we must not clobber.
65
+ if (pending.get(cwd) === promise)
66
+ pending.delete(cwd);
67
+ });
68
+ pending.set(cwd, promise);
69
+ return promise;
70
+ }
71
+ /** Dispose every shared terminal (plugin shutdown / session close). */
72
+ export function closeAllTerminals() {
73
+ // Bump the epoch FIRST so any create that resolves after this point sees the
74
+ // mismatch and self-kills (see getSharedTerminal). `pending.clear()` alone is
75
+ // insufficient — the promise body still runs to completion and would
76
+ // otherwise store the proc.
77
+ epoch += 1;
78
+ for (const proc of shared.values())
79
+ proc.kill();
80
+ shared.clear();
81
+ pending.clear();
82
+ }
83
+ /** A terminal dimension (cols/rows) is usable only if it's a finite, positive
84
+ * number. Rejects NaN/Infinity/0/negatives/floats-with-no-magnitude before
85
+ * they reach the PTY, where they'd throw or be silently coerced. */
86
+ function isValidDimension(n) {
87
+ return typeof n === 'number' && Number.isFinite(n) && n >= 1;
88
+ }
89
+ // --- Surface ----------------------------------------------------------------
90
+ /** The `terminal` surface: streams the shared PTY's output and feeds a viewer's
91
+ * keystrokes / resizes back into it. */
92
+ export function buildTerminalSurface() {
93
+ return defineSurface({
94
+ kind: 'terminal',
95
+ description: 'An embedded shell the user and the agent share.',
96
+ open: async (ctx) => {
97
+ const proc = await getSharedTerminal(ctx.cwd);
98
+ const dataSubs = new Set();
99
+ const emit = (payload) => {
100
+ for (const cb of dataSubs) {
101
+ try {
102
+ cb(payload);
103
+ }
104
+ catch {
105
+ /* a bad viewer must not break the stream (matches pty.ts emitData) */
106
+ }
107
+ }
108
+ };
109
+ const unsubData = proc.onData((data) => emit({ type: 'data', data }));
110
+ const unsubExit = proc.onExit((code) => emit({ type: 'exit', code }));
111
+ // Honest degraded state: without a real PTY the pane can't function as an
112
+ // interactive terminal (no echo, a viewer's Enter never reaches the shell).
113
+ // Tell the viewer rather than presenting a box that silently ignores input.
114
+ if (proc.backend === 'pipe') {
115
+ emit({
116
+ type: 'status',
117
+ text: proc.ptyError
118
+ ? `Terminal unavailable: the PTY backend failed to start (${proc.ptyError}).`
119
+ : 'Terminal unavailable: a real PTY backend (node-pty) is not available.',
120
+ });
121
+ }
122
+ return {
123
+ id: 'terminal',
124
+ kind: 'terminal',
125
+ onData: (cb) => {
126
+ dataSubs.add(cb);
127
+ return () => dataSubs.delete(cb);
128
+ },
129
+ snapshot: () => ({
130
+ type: 'snapshot',
131
+ data: proc.scrollback(),
132
+ backend: proc.backend,
133
+ ptyError: proc.ptyError,
134
+ }),
135
+ input: (msg) => {
136
+ if (msg.type === 'data' && typeof msg.data === 'string') {
137
+ proc.write(msg.data);
138
+ }
139
+ else {
140
+ // A viewer/relay sending a wrong-shaped message (field rename,
141
+ // numeric data, protocol skew) gets the keystroke silently dropped.
142
+ // Log it so the "terminal ignores my input" symptom is diagnosable.
143
+ ctx.logger?.debug?.('terminal surface dropped unrecognized input', { type: msg.type });
144
+ }
145
+ },
146
+ resize: (size) => {
147
+ // Validate before touching the PTY: a viewer/relay may send NaN,
148
+ // Infinity, floats, negatives, or 0 (SurfaceSize.cols/rows are bare
149
+ // `number`s — anything decoded off the wire). `resize` in pty.ts also
150
+ // clamps, but reject obviously-bad shapes here with a diagnostic so a
151
+ // dropped resize is traceable rather than a silent no-op.
152
+ if (isValidDimension(size.cols) && isValidDimension(size.rows)) {
153
+ proc.resize(size.cols, size.rows);
154
+ }
155
+ else {
156
+ ctx.logger?.debug?.('terminal surface dropped malformed resize', { size });
157
+ }
158
+ },
159
+ close: () => {
160
+ // Detach this viewer; the underlying shared process stays alive for
161
+ // the agent's tool and any other viewer (closed on session teardown).
162
+ unsubData();
163
+ unsubExit();
164
+ },
165
+ };
166
+ },
167
+ });
168
+ }
169
+ // --- Tool -------------------------------------------------------------------
170
+ const terminalInputSchema = z.object({
171
+ command: z
172
+ .string()
173
+ .min(1)
174
+ .max(10_000)
175
+ .describe('A shell command to run in the user-visible terminal.'),
176
+ timeoutMs: z
177
+ .number()
178
+ .int()
179
+ .positive()
180
+ .max(600_000)
181
+ .optional()
182
+ .describe('Max time to wait for the command to finish (default 30s).'),
183
+ });
184
+ /**
185
+ * The `terminal` tool runs a command in the SHARED terminal the user sees. It
186
+ * appends a unique sentinel that echoes the exit code, then reads output until
187
+ * the sentinel returns — reliable completion detection in an interactive,
188
+ * input-echoing shell. The command and its output stay visible to the user, who
189
+ * can take over at any time.
190
+ */
191
+ export function buildTerminalTool() {
192
+ return defineTool({
193
+ name: 'terminal',
194
+ description: 'Run a shell command in the user-visible terminal and return its output. ' +
195
+ 'The terminal is SHARED with the user — they see what you run and can take over. ' +
196
+ 'Use this to run applications or commands for the user (builds, scripts, CLIs). ' +
197
+ 'For long-running/interactive programs, expect partial output up to the timeout.',
198
+ inputSchema: terminalInputSchema,
199
+ // Bash-class honest caps: this drives a real shell (node-pty), so the
200
+ // command can touch anything the user could. The shared shell deliberately
201
+ // inherits the FULL runner env (see the SECURITY note in pty.ts); the env
202
+ // list below is the shell-relevant subset an enforcing isolator should
203
+ // provide when it re-spawns under a constrained env.
204
+ isolation: {
205
+ capabilities: {
206
+ subprocess: true,
207
+ fs: { read: ['$cwd/**', '/tmp/**'], write: ['$cwd/**', '/tmp/**'] },
208
+ net: { mode: 'any' },
209
+ env: ['PATH', 'HOME', 'USER', 'SHELL', 'COMSPEC', 'LANG', 'LC_ALL', 'TERM'],
210
+ timeMs: 600_000,
211
+ },
212
+ },
213
+ handler: async (input, ctx) => {
214
+ const proc = await getSharedTerminal(ctx.cwd ?? process.cwd());
215
+ const timeoutMs = input.timeoutMs ?? 30_000;
216
+ // Honor the turn's abort signal: when the loop/user cancels the turn, the
217
+ // command must stop waiting PROMPTLY rather than blocking the tool slot for
218
+ // up to the full timeout (600s) on a session that's already being torn down.
219
+ // We do NOT kill the shared shell (it's user-facing and may host other work)
220
+ // — we just stop awaiting its sentinel.
221
+ return runCommand(proc, input.command, makeMarker(), timeoutMs, ctx.signal);
222
+ },
223
+ });
224
+ }
225
+ /**
226
+ * A high-entropy, unpredictable completion marker. The exit code is detected by
227
+ * matching `<marker> <digits>` on its own line; a time+counter marker was
228
+ * predictable, so untrusted command output (a printed file, a remote response)
229
+ * could deliberately emit `<predicted-marker> <chosen-exit>` to spoof an
230
+ * attacker-chosen exit status and truncate the output. 64 bits of randomness
231
+ * makes that practically impossible to reproduce.
232
+ */
233
+ function makeMarker() {
234
+ return `__MOXXY_DONE_${randomBytes(8).toString('hex')}__`;
235
+ }
236
+ /**
237
+ * Cap the per-command accumulator. The shared scrollback in pty.ts is bounded,
238
+ * but THIS accumulator is per-call: a command that floods output before its
239
+ * sentinel arrives (`cat huge.log`, `yes`, a `tail -f` that never returns until
240
+ * the 600s timeout) would otherwise grow `acc` unbounded → OOM the runner. The
241
+ * sentinel is always at the END of the stream, so retaining only the tail keeps
242
+ * detection correct while making memory O(cap) instead of O(total output).
243
+ */
244
+ const MAX_ACC = 1_000_000;
245
+ /**
246
+ * Per-shared-process command serialization. The surface and the tool share ONE
247
+ * shell per cwd; runCommand writes `command` + a sentinel `printf` and reads the
248
+ * merged stream until ITS sentinel appears. With no serialization, two
249
+ * concurrent tool calls (parallel tool calls are normal) — or a tool call
250
+ * overlapping a user typing in the pane — would interleave their command +
251
+ * sentinel writes into one line-oriented shell, scrambling output and letting
252
+ * each `$?` capture the OTHER command's exit. We hold a per-proc tail promise: a
253
+ * command writes only once the previous one on the same shell has finished, so
254
+ * writes are single-file. The entry is deleted once the shell goes idle, so the
255
+ * NEXT command on an idle shell writes synchronously (no microtask hop) —
256
+ * preserving the single-command happy path exactly (listener + write in the same
257
+ * tick the caller invokes runCommand). Keyed weakly so a disposed process drops
258
+ * its tail.
259
+ */
260
+ const commandTails = new WeakMap();
261
+ /**
262
+ * Write `command` then a sentinel `printf` to a shared shell and collect output
263
+ * until the sentinel line appears. Returns the captured output (best-effort
264
+ * stripped of the echoed sentinel command) + the exit code parsed from it.
265
+ *
266
+ * Serialized per shared process: a command writes only once the previous one on
267
+ * the same shell finishes, so concurrent callers never interleave their writes
268
+ * on the single stdin and each `$?` reflects its OWN command.
269
+ */
270
+ export function runCommand(proc, command, marker, timeoutMs,
271
+ /** Optional turn-abort signal; firing it finishes the command immediately
272
+ * (timedOut=false) without waiting for the sentinel or the timeout. */
273
+ signal) {
274
+ // Idle shell (no pending tail) → write synchronously (undefined). Busy shell →
275
+ // defer this command's writes until the current tail settles.
276
+ const startWrites = commandTails.get(proc);
277
+ const run = runCommandSerialized(proc, command, marker, timeoutMs, startWrites, signal);
278
+ // This command becomes the new tail; later callers wait on it. Swallow
279
+ // rejections (it never rejects today) so one failure can't poison the queue.
280
+ const settled = run.then(() => { }, () => { });
281
+ commandTails.set(proc, settled);
282
+ // Once this command settles, if nothing newer queued behind it, the shell is
283
+ // idle again — drop the entry so the next command writes synchronously.
284
+ void settled.then(() => {
285
+ if (commandTails.get(proc) === settled)
286
+ commandTails.delete(proc);
287
+ });
288
+ return run;
289
+ }
290
+ function runCommandSerialized(proc, command, marker, timeoutMs,
291
+ /** Resolves when it's this command's turn to write; undefined = write now. */
292
+ startWrites,
293
+ /** Turn-abort signal; firing it finishes immediately (see runCommand). */
294
+ signal) {
295
+ return new Promise((resolve) => {
296
+ let acc = '';
297
+ let settled = false;
298
+ const finish = (exitCode, timedOut) => {
299
+ if (settled)
300
+ return;
301
+ settled = true;
302
+ unsubData();
303
+ unsubExit();
304
+ clearTimeout(timer);
305
+ if (onAbort)
306
+ signal?.removeEventListener('abort', onAbort);
307
+ resolve({ output: cleanOutput(acc, command, marker), exitCode, timedOut });
308
+ };
309
+ // Already aborted before we even start? Finish immediately — don't write to
310
+ // the shell, don't arm a timeout. The command never runs; null exit.
311
+ if (signal?.aborted) {
312
+ // `finish` below isn't fully wired yet (unsubData/unsubExit/timer are
313
+ // declared after this point), so resolve directly for the pre-armed case.
314
+ resolve({ output: '', exitCode: null, timedOut: false });
315
+ return;
316
+ }
317
+ // Abort while waiting/running → stop awaiting the sentinel and resolve with a
318
+ // null exit (the command may have partially run; we report what we captured).
319
+ // We deliberately do NOT kill the shared shell here — it is user-facing and
320
+ // may be hosting other work; we only detach this command's reader.
321
+ const onAbort = () => finish(null, false);
322
+ signal?.addEventListener('abort', onAbort, { once: true });
323
+ // Compile the sentinel matcher ONCE — `marker` is fixed for this call. The
324
+ // sentinel is `<marker> <digits>`; the marker is `__MOXXY_DONE_<hex>__`, so
325
+ // nothing in it is a regex metacharacter and it needs no escaping. Anchor the
326
+ // match to a REAL newline — `\n` only, NOT the regex `^` — so command OUTPUT
327
+ // that merely CONTAINS the literal string (echoing a captured transcript,
328
+ // grepping a file) cannot false-trigger completion. (`^` is deliberately
329
+ // excluded: we scan a SLICE of the buffer for O(n) cost, and `^` would match
330
+ // the slice's first char even when that char is mid-line — a hostile line
331
+ // like `prefix<marker> 137` whose `<marker>` happens to land at the slice
332
+ // start would otherwise spoof an attacker-chosen exit code. By requiring a
333
+ // literal `\n`, a sentinel is only ever recognized when a genuine newline
334
+ // precedes it in the buffer.) printf always appends a trailing `\n`.
335
+ const sentinel = new RegExp(`\\n${marker} (\\d+)\\r?\\n`);
336
+ // Re-scanning the whole accumulated buffer per chunk is O(n^2). The sentinel
337
+ // appears exactly once (unique marker) and we finish on first match, so it
338
+ // suffices to scan only the new chunk plus a carry-over of the previous
339
+ // tail long enough to catch a sentinel split across the chunk boundary:
340
+ // a leading newline + marker + space + a generous run of digits + trailing
341
+ // newline.
342
+ const carry = marker.length + 64;
343
+ // The shell ALWAYS echoes the command (and our printf) on their own lines, so
344
+ // a real sentinel is always preceded by a newline IN the buffer — we never
345
+ // need to treat the very first byte of the stream as a line start. To keep
346
+ // the `\n`-anchored match correct across the slice boundary, the scan window
347
+ // is widened by one extra leading char so the newline that precedes a
348
+ // sentinel can never be sliced away (see `scanStart` below).
349
+ let scanFrom = 0;
350
+ const unsubData = proc.onData((d) => {
351
+ // Begin the scan window just before the bytes that could only now have
352
+ // completed a sentinel that started in an earlier chunk.
353
+ let from = Math.max(scanFrom, acc.length - carry, 0);
354
+ acc += d;
355
+ // Bound the accumulator: keep only the tail. The sentinel lives at the
356
+ // end of the stream, so trimming the head never drops it. Shift the scan
357
+ // index down by the trimmed amount so the carry-window math stays correct.
358
+ if (acc.length > MAX_ACC) {
359
+ const removed = acc.length - MAX_ACC;
360
+ acc = acc.slice(removed);
361
+ from = Math.max(0, from - removed);
362
+ }
363
+ // Scan from one char BEFORE `from` so the newline that precedes a sentinel
364
+ // straddling the carry boundary is inside the window (the match needs the
365
+ // leading `\n`). Slicing exactly at `from` could otherwise drop that
366
+ // newline and miss — or, with a bare `^` anchor, falsely match — the
367
+ // sentinel. Backing up one char keeps the real preceding newline in scope.
368
+ const scanStart = Math.max(0, from - 1);
369
+ // At the TRUE stream start (scanStart === 0) a sentinel may legitimately be
370
+ // the very first line, with no real newline before it. Prepend a synthetic
371
+ // `\n` ONLY there so the `\n`-anchored match recognizes it — this never
372
+ // creates a false mid-stream line start, because it's applied solely when
373
+ // the slice begins at byte 0 of the whole stream.
374
+ const hay = scanStart > 0 ? acc.slice(scanStart) : `\n${acc}`;
375
+ sentinel.lastIndex = 0;
376
+ const m = sentinel.exec(hay);
377
+ if (m)
378
+ finish(Number(m[1]), false);
379
+ // Everything before this point can never start a future sentinel match.
380
+ scanFrom = Math.max(0, acc.length - carry);
381
+ });
382
+ // If the shared shell dies while this command runs (user types `exit`, the
383
+ // shell crashes, the process is killed), the sentinel never arrives — finish
384
+ // immediately with the shell's exit code rather than hanging to the full
385
+ // timeout (up to 600s) on a known-dead process.
386
+ const unsubExit = proc.onExit((code) => finish(code, false));
387
+ let timer;
388
+ // Two writes to the interactive shell: the command, then the sentinel whose
389
+ // $? reflects the command's exit. A leading newline before the printf
390
+ // terminates any dangling/unterminated command line (trailing backslash,
391
+ // unbalanced quote) so the sentinel is always emitted rather than consumed
392
+ // as a continuation — otherwise such a command would always time out. On a
393
+ // non-PTY pipe the shell still runs both sequentially.
394
+ const writeAndArm = () => {
395
+ if (settled)
396
+ return; // shell died (or we were torn down) before our turn
397
+ // The shell is ALREADY dead at write time (it exited before our turn, or
398
+ // in the TOCTOU window between getSharedTerminal returning it and now).
399
+ // `onExit` won't fire again — it already fired and is single-shot — so a
400
+ // bare write would hang to the full timeout (up to 600s) on a known-dead
401
+ // process. Finish immediately instead. Reported as timedOut=false / null
402
+ // exit: the command never actually ran, so there is no real exit status.
403
+ if (!proc.alive) {
404
+ finish(null, false);
405
+ return;
406
+ }
407
+ timer = setTimeout(() => finish(null, true), timeoutMs);
408
+ proc.write(`${command}\n`);
409
+ proc.write(`\nprintf '%s %s\\n' "${marker}" "$?"\n`);
410
+ };
411
+ // Idle shell → write now (keeps the single-command path fully synchronous).
412
+ // Busy shell → wait our turn; the timeout clock only starts when we write, so
413
+ // a command queued behind a slow predecessor doesn't burn its budget waiting.
414
+ if (startWrites)
415
+ void startWrites.then(writeAndArm);
416
+ else
417
+ writeAndArm();
418
+ });
419
+ }
420
+ /** Strip the echoed command + sentinel lines so the model sees just the output. */
421
+ function cleanOutput(acc, command, marker) {
422
+ // A multi-line command is echoed by an echo-on PTY one line at a time; none of
423
+ // those echoed lines equals the full `command`, so match each command line
424
+ // independently. (Single-line commands are the trivial one-element case.)
425
+ const commandLines = new Set(command
426
+ .split('\n')
427
+ .map((l) => l.trim())
428
+ .filter((l) => l.length > 0));
429
+ return acc
430
+ .split('\n')
431
+ .filter((line) => !line.includes(marker) &&
432
+ !commandLines.has(line.trim()) &&
433
+ !line.includes(`printf '%s %s\\n' "${marker}"`))
434
+ .join('\n')
435
+ .trim();
436
+ }
437
+ //# sourceMappingURL=terminal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terminal.js","sourceRoot":"","sources":["../src/terminal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,CAAC,EAAwB,MAAM,YAAY,CAAC;AAChF,OAAO,EAAE,qBAAqB,EAAwB,MAAM,UAAU,CAAC;AAEvE;;;;;GAKG;AACH,MAAM,MAAM,GAAG,IAAI,GAAG,EAA2B,CAAC;AAClD;;;;;;;GAOG;AACH,MAAM,OAAO,GAAG,IAAI,GAAG,EAAoC,CAAC;AAC5D;;;;;;;;;;;;;;GAcG;AACH,IAAI,KAAK,GAAG,CAAC,CAAC;AAEd,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,GAAW,EACX,SAAoD,qBAAqB;IAEzE,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACjC,IAAI,QAAQ,IAAI,QAAQ,CAAC,KAAK;QAAE,OAAO,QAAQ,CAAC;IAChD,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAClC,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC9B,8EAA8E;IAC9E,wEAAwE;IACxE,MAAM,YAAY,GAAG,KAAK,CAAC;IAC3B,MAAM,OAAO,GAAG,CAAC,KAAK,IAAI,EAAE;QAC1B,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,GAAG,CAAC,CAAC;QAC/B,2EAA2E;QAC3E,qEAAqE;QACrE,iEAAiE;QACjE,IAAI,KAAK,KAAK,YAAY,EAAE,CAAC;YAC3B,IAAI,CAAC,IAAI,EAAE,CAAC;YACZ,OAAO,IAAI,CAAC;QACd,CAAC;QACD,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACtB,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE;YACf,IAAI,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,IAAI;gBAAE,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACnD,CAAC,CAAC,CAAC;QACH,OAAO,IAAI,CAAC;IACd,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE;QAChB,2EAA2E;QAC3E,2EAA2E;QAC3E,sDAAsD;QACtD,IAAI,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,OAAO;YAAE,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;IACH,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IAC1B,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,iBAAiB;IAC/B,6EAA6E;IAC7E,8EAA8E;IAC9E,qEAAqE;IACrE,4BAA4B;IAC5B,KAAK,IAAI,CAAC,CAAC;IACX,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,CAAC,IAAI,EAAE,CAAC;IAChD,MAAM,CAAC,KAAK,EAAE,CAAC;IACf,OAAO,CAAC,KAAK,EAAE,CAAC;AAClB,CAAC;AAED;;qEAEqE;AACrE,SAAS,gBAAgB,CAAC,CAAqB;IAC7C,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC/D,CAAC;AAED,+EAA+E;AAE/E;yCACyC;AACzC,MAAM,UAAU,oBAAoB;IAClC,OAAO,aAAa,CAAC;QACnB,IAAI,EAAE,UAAU;QAChB,WAAW,EAAE,iDAAiD;QAC9D,IAAI,EAAE,KAAK,EAAE,GAAG,EAA4B,EAAE;YAC5C,MAAM,IAAI,GAAG,MAAM,iBAAiB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAC9C,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA8B,CAAC;YACvD,MAAM,IAAI,GAAG,CAAC,OAAgB,EAAQ,EAAE;gBACtC,KAAK,MAAM,EAAE,IAAI,QAAQ,EAAE,CAAC;oBAC1B,IAAI,CAAC;wBACH,EAAE,CAAC,OAAO,CAAC,CAAC;oBACd,CAAC;oBAAC,MAAM,CAAC;wBACP,sEAAsE;oBACxE,CAAC;gBACH,CAAC;YACH,CAAC,CAAC;YACF,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;YACtE,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;YACtE,0EAA0E;YAC1E,4EAA4E;YAC5E,4EAA4E;YAC5E,IAAI,IAAI,CAAC,OAAO,KAAK,MAAM,EAAE,CAAC;gBAC5B,IAAI,CAAC;oBACH,IAAI,EAAE,QAAQ;oBACd,IAAI,EAAE,IAAI,CAAC,QAAQ;wBACjB,CAAC,CAAC,0DAA0D,IAAI,CAAC,QAAQ,IAAI;wBAC7E,CAAC,CAAC,uEAAuE;iBAC5E,CAAC,CAAC;YACL,CAAC;YACD,OAAO;gBACL,EAAE,EAAE,UAAU;gBACd,IAAI,EAAE,UAAU;gBAChB,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE;oBACb,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;oBACjB,OAAO,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;gBACnC,CAAC;gBACD,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC;oBACf,IAAI,EAAE,UAAU;oBAChB,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE;oBACvB,OAAO,EAAE,IAAI,CAAC,OAAO;oBACrB,QAAQ,EAAE,IAAI,CAAC,QAAQ;iBACxB,CAAC;gBACF,KAAK,EAAE,CAAC,GAAG,EAAE,EAAE;oBACb,IAAI,GAAG,CAAC,IAAI,KAAK,MAAM,IAAI,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;wBACxD,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;oBACvB,CAAC;yBAAM,CAAC;wBACN,+DAA+D;wBAC/D,oEAAoE;wBACpE,oEAAoE;wBACpE,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,6CAA6C,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;oBACzF,CAAC;gBACH,CAAC;gBACD,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;oBACf,iEAAiE;oBACjE,oEAAoE;oBACpE,sEAAsE;oBACtE,sEAAsE;oBACtE,0DAA0D;oBAC1D,IAAI,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;wBAC/D,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAc,EAAE,IAAI,CAAC,IAAc,CAAC,CAAC;oBACxD,CAAC;yBAAM,CAAC;wBACN,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,2CAA2C,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;oBAC7E,CAAC;gBACH,CAAC;gBACD,KAAK,EAAE,GAAG,EAAE;oBACV,oEAAoE;oBACpE,sEAAsE;oBACtE,SAAS,EAAE,CAAC;oBACZ,SAAS,EAAE,CAAC;gBACd,CAAC;aACF,CAAC;QACJ,CAAC;KACF,CAAC,CAAC;AACL,CAAC;AAED,+EAA+E;AAE/E,MAAM,mBAAmB,GAAG,CAAC,CAAC,MAAM,CAAC;IACnC,OAAO,EAAE,CAAC;SACP,MAAM,EAAE;SACR,GAAG,CAAC,CAAC,CAAC;SACN,GAAG,CAAC,MAAM,CAAC;SACX,QAAQ,CAAC,sDAAsD,CAAC;IACnE,SAAS,EAAE,CAAC;SACT,MAAM,EAAE;SACR,GAAG,EAAE;SACL,QAAQ,EAAE;SACV,GAAG,CAAC,OAAO,CAAC;SACZ,QAAQ,EAAE;SACV,QAAQ,CAAC,2DAA2D,CAAC;CACzE,CAAC,CAAC;AAEH;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB;IAC/B,OAAO,UAAU,CAAC;QAChB,IAAI,EAAE,UAAU;QAChB,WAAW,EACT,0EAA0E;YAC1E,kFAAkF;YAClF,iFAAiF;YACjF,iFAAiF;QACnF,WAAW,EAAE,mBAAmB;QAChC,sEAAsE;QACtE,2EAA2E;QAC3E,0EAA0E;QAC1E,uEAAuE;QACvE,qDAAqD;QACrD,SAAS,EAAE;YACT,YAAY,EAAE;gBACZ,UAAU,EAAE,IAAI;gBAChB,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,SAAS,EAAE,SAAS,CAAC,EAAE,KAAK,EAAE,CAAC,SAAS,EAAE,SAAS,CAAC,EAAE;gBACnE,GAAG,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE;gBACpB,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC;gBAC3E,MAAM,EAAE,OAAO;aAChB;SACF;QACD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE;YAC5B,MAAM,IAAI,GAAG,MAAM,iBAAiB,CAAC,GAAG,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;YAC/D,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,IAAI,MAAM,CAAC;YAC5C,0EAA0E;YAC1E,4EAA4E;YAC5E,6EAA6E;YAC7E,6EAA6E;YAC7E,wCAAwC;YACxC,OAAO,UAAU,CAAC,IAAI,EAAE,KAAK,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;QAC9E,CAAC;KACF,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,UAAU;IACjB,OAAO,gBAAgB,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC;AAC5D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,OAAO,GAAG,SAAS,CAAC;AAE1B;;;;;;;;;;;;;;GAcG;AACH,MAAM,YAAY,GAAG,IAAI,OAAO,EAAkC,CAAC;AAEnE;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CACxB,IAAqB,EACrB,OAAe,EACf,MAAc,EACd,SAAiB;AACjB;wEACwE;AACxE,MAAoB;IAEpB,+EAA+E;IAC/E,8DAA8D;IAC9D,MAAM,WAAW,GAAG,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC3C,MAAM,GAAG,GAAG,oBAAoB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,CAAC,CAAC;IACxF,uEAAuE;IACvE,6EAA6E;IAC7E,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,CACtB,GAAG,EAAE,GAAE,CAAC,EACR,GAAG,EAAE,GAAE,CAAC,CACT,CAAC;IACF,YAAY,CAAC,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAChC,6EAA6E;IAC7E,wEAAwE;IACxE,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE;QACrB,IAAI,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,OAAO;YAAE,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACpE,CAAC,CAAC,CAAC;IACH,OAAO,GAAG,CAAC;AACb,CAAC;AAED,SAAS,oBAAoB,CAC3B,IAAqB,EACrB,OAAe,EACf,MAAc,EACd,SAAiB;AACjB,8EAA8E;AAC9E,WAAsC;AACtC,0EAA0E;AAC1E,MAA+B;IAE/B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,IAAI,GAAG,GAAG,EAAE,CAAC;QACb,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,MAAM,MAAM,GAAG,CAAC,QAAuB,EAAE,QAAiB,EAAQ,EAAE;YAClE,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,SAAS,EAAE,CAAC;YACZ,SAAS,EAAE,CAAC;YACZ,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,IAAI,OAAO;gBAAE,MAAM,EAAE,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC3D,OAAO,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC7E,CAAC,CAAC;QACF,4EAA4E;QAC5E,qEAAqE;QACrE,IAAI,MAAM,EAAE,OAAO,EAAE,CAAC;YACpB,sEAAsE;YACtE,0EAA0E;YAC1E,OAAO,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC;YACzD,OAAO;QACT,CAAC;QACD,8EAA8E;QAC9E,8EAA8E;QAC9E,4EAA4E;QAC5E,mEAAmE;QACnE,MAAM,OAAO,GAAG,GAAS,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAChD,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;QAC3D,2EAA2E;QAC3E,4EAA4E;QAC5E,8EAA8E;QAC9E,6EAA6E;QAC7E,0EAA0E;QAC1E,yEAAyE;QACzE,6EAA6E;QAC7E,0EAA0E;QAC1E,0EAA0E;QAC1E,2EAA2E;QAC3E,0EAA0E;QAC1E,qEAAqE;QACrE,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAC,MAAM,MAAM,gBAAgB,CAAC,CAAC;QAC1D,6EAA6E;QAC7E,2EAA2E;QAC3E,wEAAwE;QACxE,wEAAwE;QACxE,2EAA2E;QAC3E,WAAW;QACX,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,GAAG,EAAE,CAAC;QACjC,8EAA8E;QAC9E,2EAA2E;QAC3E,2EAA2E;QAC3E,6EAA6E;QAC7E,sEAAsE;QACtE,6DAA6D;QAC7D,IAAI,QAAQ,GAAG,CAAC,CAAC;QACjB,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE;YAClC,uEAAuE;YACvE,yDAAyD;YACzD,IAAI,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,CAAC,MAAM,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC;YACrD,GAAG,IAAI,CAAC,CAAC;YACT,uEAAuE;YACvE,yEAAyE;YACzE,2EAA2E;YAC3E,IAAI,GAAG,CAAC,MAAM,GAAG,OAAO,EAAE,CAAC;gBACzB,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,GAAG,OAAO,CAAC;gBACrC,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;gBACzB,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC,CAAC;YACrC,CAAC;YACD,2EAA2E;YAC3E,0EAA0E;YAC1E,qEAAqE;YACrE,qEAAqE;YACrE,2EAA2E;YAC3E,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,GAAG,CAAC,CAAC,CAAC;YACxC,4EAA4E;YAC5E,2EAA2E;YAC3E,wEAAwE;YACxE,0EAA0E;YAC1E,kDAAkD;YAClD,MAAM,GAAG,GAAG,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC;YAC9D,QAAQ,CAAC,SAAS,GAAG,CAAC,CAAC;YACvB,MAAM,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAC7B,IAAI,CAAC;gBAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;YACnC,wEAAwE;YACxE,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,CAAC,MAAM,GAAG,KAAK,CAAC,CAAC;QAC7C,CAAC,CAAC,CAAC;QACH,2EAA2E;QAC3E,6EAA6E;QAC7E,yEAAyE;QACzE,gDAAgD;QAChD,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC;QAC7D,IAAI,KAAgD,CAAC;QACrD,4EAA4E;QAC5E,sEAAsE;QACtE,yEAAyE;QACzE,2EAA2E;QAC3E,2EAA2E;QAC3E,uDAAuD;QACvD,MAAM,WAAW,GAAG,GAAS,EAAE;YAC7B,IAAI,OAAO;gBAAE,OAAO,CAAC,oDAAoD;YACzE,yEAAyE;YACzE,wEAAwE;YACxE,yEAAyE;YACzE,yEAAyE;YACzE,yEAAyE;YACzE,yEAAyE;YACzE,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;gBAChB,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;gBACpB,OAAO;YACT,CAAC;YACD,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,SAAS,CAAC,CAAC;YACxD,IAAI,CAAC,KAAK,CAAC,GAAG,OAAO,IAAI,CAAC,CAAC;YAC3B,IAAI,CAAC,KAAK,CAAC,wBAAwB,MAAM,UAAU,CAAC,CAAC;QACvD,CAAC,CAAC;QACF,4EAA4E;QAC5E,8EAA8E;QAC9E,8EAA8E;QAC9E,IAAI,WAAW;YAAE,KAAK,WAAW,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;;YAC/C,WAAW,EAAE,CAAC;IACrB,CAAC,CAAC,CAAC;AACL,CAAC;AAED,mFAAmF;AACnF,SAAS,WAAW,CAAC,GAAW,EAAE,OAAe,EAAE,MAAc;IAC/D,+EAA+E;IAC/E,2EAA2E;IAC3E,0EAA0E;IAC1E,MAAM,YAAY,GAAG,IAAI,GAAG,CAC1B,OAAO;SACJ,KAAK,CAAC,IAAI,CAAC;SACX,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAC/B,CAAC;IACF,OAAO,GAAG;SACP,KAAK,CAAC,IAAI,CAAC;SACX,MAAM,CACL,CAAC,IAAI,EAAE,EAAE,CACP,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC;QACtB,CAAC,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QAC9B,CAAC,IAAI,CAAC,QAAQ,CAAC,sBAAsB,MAAM,GAAG,CAAC,CAClD;SACA,IAAI,CAAC,IAAI,CAAC;SACV,IAAI,EAAE,CAAC;AACZ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@moxxy/plugin-terminal",
3
+ "version": "0.27.0",
4
+ "description": "A shared terminal surface for moxxy: an embedded PTY the user and the agent drive together (the `terminal` tool runs commands; the desktop renders the live session).",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "terminal",
9
+ "pty",
10
+ "tools"
11
+ ],
12
+ "homepage": "https://moxxy.ai",
13
+ "bugs": {
14
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
19
+ "directory": "packages/plugin-terminal"
20
+ },
21
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src"
38
+ ],
39
+ "moxxy": {
40
+ "plugin": {
41
+ "entry": "./dist/index.js"
42
+ }
43
+ },
44
+ "dependencies": {
45
+ "@moxxy/sdk": "0.27.0"
46
+ },
47
+ "peerDependencies": {
48
+ "node-pty": "^1.0.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "node-pty": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.10.0",
57
+ "typescript": "^5.7.3",
58
+ "vitest": "^2.1.8",
59
+ "@moxxy/vitest-preset": "0.0.0",
60
+ "@moxxy/tsconfig": "0.0.0"
61
+ },
62
+ "optionalDependencies": {
63
+ "node-pty": "^1.0.0"
64
+ },
65
+ "scripts": {
66
+ "build": "tsc -p tsconfig.json",
67
+ "typecheck": "tsc -p tsconfig.json --noEmit",
68
+ "test": "vitest run",
69
+ "clean": "rm -rf dist .turbo"
70
+ }
71
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { terminalPlugin } from './index.js';
3
+ import { buildTerminalSurface, closeAllTerminals, getSharedTerminal } from './terminal.js';
4
+ import type { TerminalProcess } from './pty.js';
5
+
6
+ describe('plugin-terminal', () => {
7
+ it('contributes a terminal surface and a terminal tool', () => {
8
+ expect(terminalPlugin.surfaces?.map((s) => s.kind)).toContain('terminal');
9
+ expect(terminalPlugin.tools?.map((t) => t.name)).toContain('terminal');
10
+ });
11
+
12
+ it('terminal tool validates its input schema', () => {
13
+ const tool = terminalPlugin.tools?.find((t) => t.name === 'terminal');
14
+ expect(tool).toBeDefined();
15
+ // A command is required; an empty object is rejected.
16
+ expect(tool!.inputSchema.safeParse({ command: 'ls -la' }).success).toBe(true);
17
+ expect(tool!.inputSchema.safeParse({}).success).toBe(false);
18
+ // timeoutMs is bounded.
19
+ expect(tool!.inputSchema.safeParse({ command: 'x', timeoutMs: 5000 }).success).toBe(true);
20
+ expect(tool!.inputSchema.safeParse({ command: 'x', timeoutMs: -1 }).success).toBe(false);
21
+ });
22
+
23
+ it('getSharedTerminal does not spawn a duplicate PTY under a concurrent create', async () => {
24
+ let spawns = 0;
25
+ // A factory that yields the event loop before resolving — reproducing the
26
+ // create race where two callers both miss the map before either set()s.
27
+ const create = vi.fn(async (): Promise<TerminalProcess> => {
28
+ spawns++;
29
+ await Promise.resolve();
30
+ const proc: TerminalProcess = {
31
+ backend: 'pipe',
32
+ onData: () => () => {},
33
+ onExit: () => () => {},
34
+ scrollback: () => '',
35
+ write: () => {},
36
+ resize: () => {},
37
+ kill: () => {},
38
+ alive: true,
39
+ };
40
+ return proc;
41
+ });
42
+
43
+ const cwd = `/tmp/race-${Math.random()}`;
44
+ const [a, b] = await Promise.all([
45
+ getSharedTerminal(cwd, create),
46
+ getSharedTerminal(cwd, create),
47
+ ]);
48
+
49
+ expect(a).toBe(b);
50
+ expect(spawns).toBe(1);
51
+ expect(create).toHaveBeenCalledTimes(1);
52
+ });
53
+
54
+ // MEDIUM: a create in flight when closeAllTerminals runs must NOT strand a
55
+ // live shell — the create continuation kills it instead of storing an orphan.
56
+ it('kills a shell whose create resolves after closeAllTerminals', async () => {
57
+ let release!: () => void;
58
+ const gate = new Promise<void>((r) => {
59
+ release = r;
60
+ });
61
+ let killed = false;
62
+ const create = async (): Promise<TerminalProcess> => {
63
+ await gate; // hold the create open across the shutdown
64
+ return {
65
+ backend: 'pipe',
66
+ ptyError: null,
67
+ onData: () => () => {},
68
+ onExit: () => () => {},
69
+ scrollback: () => '',
70
+ write: () => {},
71
+ resize: () => {},
72
+ kill: () => {
73
+ killed = true;
74
+ },
75
+ alive: true,
76
+ };
77
+ };
78
+
79
+ const cwd = `/tmp/shutdown-${Math.random()}`;
80
+ const p = getSharedTerminal(cwd, create); // create is now in flight
81
+ closeAllTerminals(); // shutdown lands before the create resolves
82
+ release(); // create finally resolves
83
+ const proc = await p;
84
+
85
+ expect(killed).toBe(true);
86
+ expect(proc.kill).toBeDefined();
87
+ });
88
+
89
+ // u112-5: the surface's viewer fan-out must isolate a throwing viewer so it
90
+ // does not abort delivery to the other viewers (matches pty.ts emitData).
91
+ it('surface emit() isolates a throwing viewer from the others', async () => {
92
+ // Capture the data callback the surface registers on the shared process so
93
+ // the test can drive a frame through the real emit() loop.
94
+ let pushData: ((d: string) => void) | undefined;
95
+ const create = async (): Promise<TerminalProcess> => ({
96
+ backend: 'pipe',
97
+ onData: (cb) => {
98
+ pushData = cb;
99
+ return () => {};
100
+ },
101
+ onExit: () => () => {},
102
+ scrollback: () => '',
103
+ write: () => {},
104
+ resize: () => {},
105
+ kill: () => {},
106
+ alive: true,
107
+ });
108
+
109
+ const cwd = `/tmp/emit-${Math.random()}`;
110
+ // Pre-seed the shared map so the surface's getSharedTerminal(ctx.cwd) reuses
111
+ // this fake (no real PTY spawn).
112
+ await getSharedTerminal(cwd, create);
113
+
114
+ const surface = buildTerminalSurface();
115
+ const instance = await surface.open({ cwd });
116
+
117
+ const second = vi.fn();
118
+ instance.onData(() => {
119
+ throw new Error('bad viewer');
120
+ });
121
+ instance.onData(second);
122
+
123
+ // Drive a frame; the shared process relays it through the surface's emit().
124
+ expect(() => pushData?.('hello')).not.toThrow();
125
+ expect(second).toHaveBeenCalledWith({ type: 'data', data: 'hello' });
126
+ });
127
+ });