@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/dist/terminal.js
ADDED
|
@@ -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":"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"}
|
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
|
+
});
|