@volter/supercode-terminal 0.2.30 → 0.2.31
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/index.d.ts +36 -0
- package/index.mjs +622 -0
- package/package.json +13 -3
package/index.d.ts
CHANGED
|
@@ -72,6 +72,42 @@ export class TmuxTerminalHost {
|
|
|
72
72
|
dispose(): void;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Terminal panes without tmux (Windows): ConPTY children of this process, each with a headless screen for
|
|
77
|
+
* capture and attach. Same surface as TmuxTerminalHost; panes end with the host.
|
|
78
|
+
*/
|
|
79
|
+
export class PtyTerminalHost {
|
|
80
|
+
constructor(options?: { attachmentTtlMs?: number });
|
|
81
|
+
listSessions(): Promise<TerminalSession[]>;
|
|
82
|
+
createSession(input: {
|
|
83
|
+
command: TmuxCommand;
|
|
84
|
+
contextKey?: string;
|
|
85
|
+
cwd?: string;
|
|
86
|
+
env?: Record<string, string>;
|
|
87
|
+
name?: string;
|
|
88
|
+
}): Promise<TerminalSession>;
|
|
89
|
+
capture(sessionId: string, options?: { lines?: number; preserveEscapes?: boolean }): string;
|
|
90
|
+
sendInput(
|
|
91
|
+
sessionId: string,
|
|
92
|
+
data: string,
|
|
93
|
+
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' },
|
|
94
|
+
): Promise<TerminalInputDelivery>;
|
|
95
|
+
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
96
|
+
/** A ConPTY pane has no window to rename; returns its label. */
|
|
97
|
+
renameWindow(sessionId: string, title?: string | null): string;
|
|
98
|
+
issueAttachment(sessionId: string, options?: { mode?: 'observe' | 'control' }): TerminalAttachmentGrant;
|
|
99
|
+
attachSocket(
|
|
100
|
+
attachmentId: string,
|
|
101
|
+
socket: unknown,
|
|
102
|
+
size?: { cols?: number; rows?: number },
|
|
103
|
+
): Promise<TerminalAttachment>;
|
|
104
|
+
/** Unsupported: throws `unsupported`. */
|
|
105
|
+
localAttachLaunch(sessionId: string): StructuredLaunch;
|
|
106
|
+
openLocalTerminal(sessionId: string): Promise<void>;
|
|
107
|
+
closeSession(sessionId: string): void;
|
|
108
|
+
dispose(): void;
|
|
109
|
+
}
|
|
110
|
+
|
|
75
111
|
export class TerminalWebSocketBridge {
|
|
76
112
|
constructor(options: {
|
|
77
113
|
host: Pick<TmuxTerminalHost, 'issueAttachment' | 'attachSocket'>;
|
package/index.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { execFile, spawn, spawnSync } from 'node:child_process';
|
|
1
2
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
2
4
|
import { createServer as defaultCreateServer } from 'node:http';
|
|
3
5
|
|
|
4
6
|
import { NativeTerminalSessionHost } from './substrate/native-host.js';
|
|
@@ -431,6 +433,626 @@ export class TmuxTerminalHost {
|
|
|
431
433
|
}
|
|
432
434
|
}
|
|
433
435
|
|
|
436
|
+
// The submit cadence tmux.js keeps for a submitted line: one Enter after the paste settles, or three spaced ones.
|
|
437
|
+
const SINGLE_SUBMIT_DELAY_MS = 500;
|
|
438
|
+
const SUBMITTED_LINE_DELAYS_MS = [500, 1500, 3500];
|
|
439
|
+
const PTY_DEFAULT_SIZE = Object.freeze({ cols: 80, rows: 24 });
|
|
440
|
+
const PTY_SCROLLBACK = 5_000;
|
|
441
|
+
// How long one read of the Windows process table answers every pane's activeCommand: the read costs a
|
|
442
|
+
// PowerShell start, and panes are listed on every attention and pane call.
|
|
443
|
+
const PROCESS_TABLE_TTL_MS = 3_000;
|
|
444
|
+
// The shells a pane's root may be: its activeCommand is the first process beneath them that is not one.
|
|
445
|
+
const PTY_SHELLS = new Set(['cmd', 'powershell', 'pwsh', 'bash', 'sh', 'wsl', 'nu']);
|
|
446
|
+
// Console hosts appear as children of a pane's processes and are never its command.
|
|
447
|
+
const CONSOLE_HOSTS = new Set(['conhost.exe', 'openconsole.exe']);
|
|
448
|
+
/**
|
|
449
|
+
* Ends the process trees of the roots in SUPERCODE_SWEEP_ROOTS (`pid:ticks:exact;…`, ticks being CIM CreationDate
|
|
450
|
+
* ticks). A root is taken while it still runs as itself: by its exact start when that was read, else only if it
|
|
451
|
+
* started no earlier than a moment before its pane was spawned. Beneath a root, a process counts only if it started
|
|
452
|
+
* no earlier than its parent (ParentProcessId). A root that has ended is left, with whatever it detached, unless
|
|
453
|
+
* SUPERCODE_SWEEP_BEFORE names the moment the host died: the host's death closes every pane's console, ending the
|
|
454
|
+
* roots with it, so then a dead root's descendants are taken too, but only those started before that moment, which
|
|
455
|
+
* a stranger reusing the root's pid afterwards cannot have.
|
|
456
|
+
*/
|
|
457
|
+
const SWEEP_SCRIPT = [
|
|
458
|
+
'$all = @(Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ Id = [int]$_.ProcessId; Parent = [int]$_.ParentProcessId; T = $_.CreationDate.Ticks } })',
|
|
459
|
+
'$kill = New-Object System.Collections.Generic.List[int]',
|
|
460
|
+
'foreach ($root in ($env:SUPERCODE_SWEEP_ROOTS -split ";" | Where-Object { $_ })) {',
|
|
461
|
+
' $pid0, $t0, $exact = $root -split ":"',
|
|
462
|
+
' $self = $all | Where-Object { $_.Id -eq [int]$pid0 -and (($exact -eq "1" -and $_.T -eq [long]$t0) -or ($exact -ne "1" -and $_.T -ge [long]$t0)) } | Select-Object -First 1',
|
|
463
|
+
' $before = if ($env:SUPERCODE_SWEEP_BEFORE) { [long]$env:SUPERCODE_SWEEP_BEFORE } else { [long]::MaxValue }',
|
|
464
|
+
' if ($self) { $kill.Add($self.Id) } elseif ($env:SUPERCODE_SWEEP_BEFORE) { $self = [pscustomobject]@{ Id = [int]$pid0; T = [long]$t0 } } else { continue }',
|
|
465
|
+
' $queue = New-Object System.Collections.Generic.Queue[object]; $queue.Enqueue($self)',
|
|
466
|
+
' while ($queue.Count) { $p = $queue.Dequeue(); foreach ($c in ($all | Where-Object { $_.Parent -eq $p.Id -and $_.T -ge $p.T -and $_.T -le $before })) { if (-not $kill.Contains($c.Id)) { $kill.Add($c.Id); $queue.Enqueue($c) } } }',
|
|
467
|
+
'}',
|
|
468
|
+
'foreach ($id in $kill) { Stop-Process -Id $id -Force -ErrorAction SilentlyContinue }',
|
|
469
|
+
].join('\n');
|
|
470
|
+
/**
|
|
471
|
+
* The pane watchdog, a detached node process per host. A process cannot hold a Windows Job Object without a native
|
|
472
|
+
* addon, so nothing makes the OS end a pane's tree when the daemon dies; this does instead. Its stdin is a pipe from
|
|
473
|
+
* the host, which sends each change to the live panes as a line of sweep roots and `bye` when it disposes cleanly.
|
|
474
|
+
* When the pipe ends without a `bye`, the host is gone (the OS closes the pipe whatever killed it), and the watchdog
|
|
475
|
+
* sweeps the last panes it was told of, up to that moment. No file and no pid: neither can be lost or reused under it.
|
|
476
|
+
*/
|
|
477
|
+
const WATCHDOG_SOURCE = `
|
|
478
|
+
const { execFileSync } = require('node:child_process');
|
|
479
|
+
const script = process.argv[1];
|
|
480
|
+
let roots = '', pending = '', finished = false;
|
|
481
|
+
// Now, as CIM gives a process's CreationDate (local-time .NET ticks), with a second's slack for a root still dying.
|
|
482
|
+
const cutoff = () => { const ms = Date.now() + 1000; return String(BigInt(ms - new Date(ms).getTimezoneOffset() * 60000) * 10000n + 621355968000000000n); };
|
|
483
|
+
const sweep = () => {
|
|
484
|
+
if (finished) return;
|
|
485
|
+
finished = true;
|
|
486
|
+
try { if (roots) execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { env: { ...process.env, SUPERCODE_SWEEP_ROOTS: roots, SUPERCODE_SWEEP_BEFORE: cutoff() }, stdio: 'ignore', windowsHide: true, timeout: 60000 }); }
|
|
487
|
+
finally { process.exit(0); }
|
|
488
|
+
};
|
|
489
|
+
process.stdin.setEncoding('utf8');
|
|
490
|
+
process.stdin.on('data', (chunk) => {
|
|
491
|
+
pending += chunk;
|
|
492
|
+
for (let end; (end = pending.indexOf('\\n')) >= 0; pending = pending.slice(end + 1)) {
|
|
493
|
+
const line = pending.slice(0, end);
|
|
494
|
+
if (line === 'bye') { finished = true; process.exit(0); }
|
|
495
|
+
roots = line;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
for (const event of ['end', 'close', 'error']) process.stdin.on(event, sweep);
|
|
499
|
+
`;
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Terminal panes where there is no tmux (Windows): each pane is a ConPTY child of this process, and a headless
|
|
503
|
+
* xterm keeps its screen for `capture` and for a viewer who attaches mid-session. The surface is
|
|
504
|
+
* TmuxTerminalHost's, so a daemon drives either the same way. Panes live as long as this host: tmux panes
|
|
505
|
+
* outlive the daemon that launched them, these end with it (a watchdog ends them if the host dies without
|
|
506
|
+
* disposing), so every pane here is this host's own. As with tmux's `remain-on-exit failed`, a pane whose program
|
|
507
|
+
* fails stays listed and capturable, marked dead, until it is closed; one that exits cleanly leaves at once.
|
|
508
|
+
*/
|
|
509
|
+
export class PtyTerminalHost {
|
|
510
|
+
#attachments = new Map();
|
|
511
|
+
#liveAttachments = new Set();
|
|
512
|
+
#now;
|
|
513
|
+
#processTable = null;
|
|
514
|
+
#processTableRead = null;
|
|
515
|
+
#randomToken;
|
|
516
|
+
#sessions = new Map();
|
|
517
|
+
#ttlMs;
|
|
518
|
+
#watchdog = null;
|
|
519
|
+
|
|
520
|
+
constructor(options = {}) {
|
|
521
|
+
this.#now = options.now ?? Date.now;
|
|
522
|
+
this.#randomToken = options.randomToken ?? randomToken;
|
|
523
|
+
this.#ttlMs = positiveInteger(options.attachmentTtlMs ?? DEFAULT_ATTACHMENT_TTL_MS, 'attachmentTtlMs');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async listSessions() {
|
|
527
|
+
this.#pruneAttachments();
|
|
528
|
+
const table = this.#sessions.size ? await this.#readProcessTable() : null;
|
|
529
|
+
return [...this.#sessions.values()].map((entry) => this.#describe(entry, table));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async createSession(input) {
|
|
533
|
+
const name = input.name ?? `supercode-${this.#randomToken().slice(0, 16)}`;
|
|
534
|
+
if (!SESSION_NAME.test(name)) {
|
|
535
|
+
throw new TerminalHostError('invalid_session_name', 'A pane name must contain 1–64 letters, numbers, underscores, or hyphens.');
|
|
536
|
+
}
|
|
537
|
+
if (!input.command?.program) {
|
|
538
|
+
throw new TerminalHostError('invalid_command', 'A structured command program is required.');
|
|
539
|
+
}
|
|
540
|
+
if (input.contextKey !== undefined) assertContextKey(input.contextKey);
|
|
541
|
+
if ([...this.#sessions.values()].some((entry) => entry.name === name)) {
|
|
542
|
+
throw new TerminalHostError('session_exists', `A pane named ${name} already exists.`);
|
|
543
|
+
}
|
|
544
|
+
const [{ default: pty }, { default: headless }, { default: serialize }] = await Promise.all([
|
|
545
|
+
import('@homebridge/node-pty-prebuilt-multiarch'),
|
|
546
|
+
import('@xterm/headless'),
|
|
547
|
+
import('@xterm/addon-serialize'),
|
|
548
|
+
]);
|
|
549
|
+
const env = { ...process.env, ...(input.env ?? {}) };
|
|
550
|
+
const { file, args } = windowsLaunch(input.command.program, input.command.arguments ?? [], env);
|
|
551
|
+
// The watchdog is running before the pane is, and the pane is on its list the moment it exists.
|
|
552
|
+
this.#startWatchdog();
|
|
553
|
+
let child;
|
|
554
|
+
const spawnedAt = Date.now();
|
|
555
|
+
try {
|
|
556
|
+
child = pty.spawn(file, args, { cols: PTY_DEFAULT_SIZE.cols, rows: PTY_DEFAULT_SIZE.rows, cwd: input.cwd ?? process.cwd(), env, name: 'xterm-256color' });
|
|
557
|
+
} catch (error) {
|
|
558
|
+
throw new TerminalHostError('session_not_found', `The pane could not start ${input.command.program}: ${errorMessage(error)}`);
|
|
559
|
+
}
|
|
560
|
+
const screen = new headless.Terminal({ allowProposedApi: true, cols: PTY_DEFAULT_SIZE.cols, rows: PTY_DEFAULT_SIZE.rows, scrollback: PTY_SCROLLBACK });
|
|
561
|
+
const serializer = new serialize.SerializeAddon();
|
|
562
|
+
screen.loadAddon(serializer);
|
|
563
|
+
const id = `scts_${this.#randomToken()}`;
|
|
564
|
+
const entry = {
|
|
565
|
+
// Replaced by the root's own start once the process table has it; a program gone by then keeps this floor.
|
|
566
|
+
child, contextKey: input.contextKey ?? null, created: cimTicks(spawnedAt - 2_000), exact: false, cwd: input.cwd ?? process.cwd(), exit: null, gone: new Set(),
|
|
567
|
+
id, listeners: new Set(), name, program: input.command.program, screen, serializer, settled: null,
|
|
568
|
+
};
|
|
569
|
+
const settled = new Promise((resolve) => { entry.settled = resolve; });
|
|
570
|
+
// Output reaches viewers once the screen has taken it, so a viewer's snapshot and its live output never
|
|
571
|
+
// overlap or leave a gap (attachSocket reads the snapshot in the same order).
|
|
572
|
+
child.onData((data) => {
|
|
573
|
+
if (!this.#sessions.has(id)) return;
|
|
574
|
+
screen.write(data, () => { for (const listener of entry.listeners) listener.output(data); });
|
|
575
|
+
});
|
|
576
|
+
child.onExit(({ exitCode, signal }) => {
|
|
577
|
+
if (!this.#sessions.has(id) || entry.exit) return releasePane(child);
|
|
578
|
+
const settle = () => {
|
|
579
|
+
if (!this.#sessions.has(id) || entry.exit) return;
|
|
580
|
+
entry.exit = { exitCode, signal };
|
|
581
|
+
// Nothing is swept when a pane's program ends by itself: what it deliberately detached (a browser, a
|
|
582
|
+
// server) outlives it, as it outlives a tmux pane. What stayed in the pane's console ends with the console.
|
|
583
|
+
// Only closing a pane, or its daemon dying, ends a whole tree.
|
|
584
|
+
releasePane(child);
|
|
585
|
+
this.#tellExit(entry);
|
|
586
|
+
this.#writeWatchList();
|
|
587
|
+
if (exitCode === 0) this.#forget(entry);
|
|
588
|
+
entry.settled();
|
|
589
|
+
};
|
|
590
|
+
screen.write('', settle);
|
|
591
|
+
});
|
|
592
|
+
this.#sessions.set(id, entry);
|
|
593
|
+
this.#writeWatchList();
|
|
594
|
+
// A table read that began before the spawn cannot hold the new root, so this one starts now.
|
|
595
|
+
const table = await this.#readProcessTable({ fresh: true });
|
|
596
|
+
const root = table?.find((row) => row.pid === child.pid);
|
|
597
|
+
if (root) Object.assign(entry, { created: root.ticks, exact: true });
|
|
598
|
+
// A program that already ended cleanly left no pane, as with tmux. node-pty reports an exit about a second
|
|
599
|
+
// after it happens (it flushes the output first), so a root missing from the table is waited on.
|
|
600
|
+
else if (table) await Promise.race([settled, sleep(3_000)]);
|
|
601
|
+
if (!this.#sessions.has(id)) {
|
|
602
|
+
throw new TerminalHostError('session_not_found', `${input.command.program} exited before its pane could be attached.`);
|
|
603
|
+
}
|
|
604
|
+
this.#writeWatchList();
|
|
605
|
+
return this.#describe(entry, table);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
capture(sessionId, options = {}) {
|
|
609
|
+
const { screen } = this.#entry(sessionId);
|
|
610
|
+
if (options.lines !== undefined && (!Number.isInteger(options.lines) || options.lines < 1)) {
|
|
611
|
+
throw new Error('--lines must be a positive integer.');
|
|
612
|
+
}
|
|
613
|
+
// capture-pane's range: the visible screen of the active buffer (the alternate one while a full-screen
|
|
614
|
+
// program runs), plus `lines` of history above it when asked. Rows stay rows, wrapped or not.
|
|
615
|
+
const buffer = screen.buffer.active;
|
|
616
|
+
const start = Math.max(0, buffer.baseY - (options.lines ?? 0));
|
|
617
|
+
const end = buffer.baseY + screen.rows - 1;
|
|
618
|
+
const lines = [];
|
|
619
|
+
const cell = buffer.getNullCell();
|
|
620
|
+
for (let row = start; row <= end; row += 1) {
|
|
621
|
+
const line = buffer.getLine(row);
|
|
622
|
+
lines.push(!line ? '' : options.preserveEscapes ? styledRow(line, cell) : line.translateToString(true));
|
|
623
|
+
}
|
|
624
|
+
return `${lines.join('\n')}\n`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async sendInput(sessionId, data, options = {}) {
|
|
628
|
+
const entry = this.#live(sessionId);
|
|
629
|
+
if (typeof data !== 'string') throw new Error('Input data must be a string.');
|
|
630
|
+
const deadline = options.deadlineMs;
|
|
631
|
+
const write = (bytes) => {
|
|
632
|
+
if (deadline !== undefined && deadline - Date.now() <= 0) throw new Error('Terminal input expired before delivery.');
|
|
633
|
+
this.#live(entry.id).child.write(bytes);
|
|
634
|
+
};
|
|
635
|
+
const submit = async () => {
|
|
636
|
+
for (const delayMs of options.submitMode === 'single' ? [SINGLE_SUBMIT_DELAY_MS] : SUBMITTED_LINE_DELAYS_MS) {
|
|
637
|
+
await sleep(delayMs);
|
|
638
|
+
write('\r');
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
const submittedText = defaultTmux.submittedTextFromInput(data);
|
|
642
|
+
if (submittedText !== undefined) {
|
|
643
|
+
if (submittedText.length > 0) {
|
|
644
|
+
// tmux's `paste-buffer -p`: bracketed when the program asked for bracketed paste, else newlines as Enter.
|
|
645
|
+
write(entry.screen.modes.bracketedPasteMode ? `\u001b[200~${submittedText}\u001b[201~` : submittedText.replace(/\n/g, '\r'));
|
|
646
|
+
await submit();
|
|
647
|
+
return 'submitted-paste';
|
|
648
|
+
}
|
|
649
|
+
await submit();
|
|
650
|
+
return 'submitted-line';
|
|
651
|
+
}
|
|
652
|
+
// tmux sends a newline in a keystream as Enter.
|
|
653
|
+
write(data.replace(/\n/g, '\r'));
|
|
654
|
+
return 'keystream';
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async bindContext(sessionId, contextKey) {
|
|
658
|
+
assertContextKey(contextKey);
|
|
659
|
+
this.#entry(sessionId).contextKey = contextKey;
|
|
660
|
+
const bound = (await this.listSessions()).find((session) => session.id === sessionId);
|
|
661
|
+
if (!bound) throw new TerminalHostError('session_not_found', 'The pane exited before it could be bound.');
|
|
662
|
+
return bound;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** There is no window to rename: a pane's title lives with the daemon that asked. */
|
|
666
|
+
renameWindow(sessionId) {
|
|
667
|
+
return this.#entry(sessionId).name;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
issueAttachment(sessionId, options = {}) {
|
|
671
|
+
this.#pruneAttachments();
|
|
672
|
+
const entry = this.#entry(sessionId);
|
|
673
|
+
const mode = options.mode ?? 'observe';
|
|
674
|
+
if (mode !== 'observe' && mode !== 'control') {
|
|
675
|
+
throw new TerminalHostError('invalid_attachment_mode', 'Attachment mode must be observe or control.');
|
|
676
|
+
}
|
|
677
|
+
const id = `sct_${this.#randomToken()}`;
|
|
678
|
+
const expiresAt = this.#now() + this.#ttlMs;
|
|
679
|
+
this.#attachments.set(id, { expiresAt, mode, sessionId: entry.id });
|
|
680
|
+
return Object.freeze({ expiresAt, id, mode });
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** The tmux-stream protocol over a pane: `hello`, the current screen, then live `output` and `exit`. */
|
|
684
|
+
async attachSocket(attachmentId, socket, size = {}) {
|
|
685
|
+
this.#pruneAttachments();
|
|
686
|
+
const grant = this.#attachments.get(attachmentId);
|
|
687
|
+
this.#attachments.delete(attachmentId);
|
|
688
|
+
if (!grant) {
|
|
689
|
+
throw new TerminalHostError('attachment_invalid', 'The terminal attachment is invalid, expired, or already used.');
|
|
690
|
+
}
|
|
691
|
+
const entry = this.#entry(grant.sessionId);
|
|
692
|
+
// A remote viewer names the size: bounded like a resize, or one attach could make the screen any size.
|
|
693
|
+
for (const label of ['cols', 'rows']) {
|
|
694
|
+
if (size[label] !== undefined) {
|
|
695
|
+
try { assertDimension(size[label], label); } catch (error) { throw new TerminalHostError('invalid_size', error.message); }
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const readOnly = grant.mode === 'observe';
|
|
699
|
+
// Observers take the pane's size as it is: a read-only mirror never reflows someone else's terminal.
|
|
700
|
+
if (!readOnly && !entry.exit && size.cols && size.rows) resizePane(entry, size.cols, size.rows);
|
|
701
|
+
const send = (value) => { if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(value)); };
|
|
702
|
+
const refuse = (error) => {
|
|
703
|
+
send({ message: `\r\n[supercode] ${errorMessage(error)}\r\n`, type: 'error' });
|
|
704
|
+
return error;
|
|
705
|
+
};
|
|
706
|
+
let disposed = false;
|
|
707
|
+
const listener = {
|
|
708
|
+
exit: (exitCode, signal) => {
|
|
709
|
+
send({ exitCode, signal, type: 'exit' });
|
|
710
|
+
if (socket.readyState === socket.OPEN) socket.close(1000, 'pane exited');
|
|
711
|
+
wrapped.dispose();
|
|
712
|
+
},
|
|
713
|
+
output: (data) => send({ data: wellFormed(data), type: 'output' }),
|
|
714
|
+
};
|
|
715
|
+
const onMessage = (raw) => {
|
|
716
|
+
try {
|
|
717
|
+
const message = JSON.parse(raw.toString());
|
|
718
|
+
if (message.type === 'resize') {
|
|
719
|
+
if (readOnly) throw new Error('Read-only terminal attachments cannot resize the pane.');
|
|
720
|
+
assertDimension(message.cols, 'cols');
|
|
721
|
+
assertDimension(message.rows, 'rows');
|
|
722
|
+
if (this.#sessions.has(entry.id) && !entry.exit) resizePane(entry, message.cols, message.rows);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (message.type !== 'input') throw new Error(`Unsupported websocket message type: ${message.type}`);
|
|
726
|
+
if (typeof message.data !== 'string') throw new Error('Input message did not include string data.');
|
|
727
|
+
if (readOnly) {
|
|
728
|
+
refuse(new TerminalHostError('input_not_allowed', 'This is a read-only terminal attachment.'));
|
|
729
|
+
socket.close(1008, 'terminal access ended');
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (this.#sessions.has(entry.id) && !entry.exit) entry.child.write(message.data);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
refuse(error);
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
const onClose = () => wrapped.dispose();
|
|
738
|
+
const wrapped = {
|
|
739
|
+
dispose: () => {
|
|
740
|
+
if (disposed) return;
|
|
741
|
+
disposed = true;
|
|
742
|
+
socket?.off?.('close', onClose);
|
|
743
|
+
socket?.off?.('message', onMessage);
|
|
744
|
+
entry.listeners.delete(listener);
|
|
745
|
+
this.#liveAttachments.delete(wrapped);
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
socket?.once?.('close', onClose);
|
|
749
|
+
send({ cols: entry.screen.cols, rows: entry.screen.rows, type: 'hello' });
|
|
750
|
+
// The screen as it stands once the output already received has been parsed, and live output from exactly
|
|
751
|
+
// there: both happen in the screen's own write order. A pane closed meanwhile ends the wait.
|
|
752
|
+
const attached = await new Promise((resolve) => {
|
|
753
|
+
const gone = () => resolve(false);
|
|
754
|
+
entry.gone.add(gone);
|
|
755
|
+
entry.screen.write('', () => {
|
|
756
|
+
entry.gone.delete(gone);
|
|
757
|
+
if (disposed || !this.#sessions.has(entry.id)) return resolve(false);
|
|
758
|
+
send({ data: wellFormed(entry.serializer.serialize({ scrollback: PTY_SCROLLBACK })), type: 'output' });
|
|
759
|
+
if (entry.exit) listener.exit(entry.exit.exitCode, entry.exit.signal);
|
|
760
|
+
else entry.listeners.add(listener);
|
|
761
|
+
resolve(true);
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
if (!attached && !disposed) {
|
|
765
|
+
wrapped.dispose();
|
|
766
|
+
throw new TerminalHostError('session_not_found', 'The pane closed while it was being attached.');
|
|
767
|
+
}
|
|
768
|
+
if (!disposed) {
|
|
769
|
+
this.#liveAttachments.add(wrapped);
|
|
770
|
+
socket?.on?.('message', onMessage);
|
|
771
|
+
}
|
|
772
|
+
return wrapped;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
localAttachLaunch() {
|
|
776
|
+
throw new TerminalHostError('unsupported', 'A pane on this platform has no local terminal to attach: open it through a viewer.');
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async openLocalTerminal(sessionId) {
|
|
780
|
+
this.localAttachLaunch(sessionId);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
closeSession(sessionId) {
|
|
784
|
+
const entry = this.#entry(sessionId);
|
|
785
|
+
if (!entry.exit) endPane(entry.child);
|
|
786
|
+
this.#forget(entry);
|
|
787
|
+
this.#writeWatchList();
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** The daemon is going, and its panes with it: none is left running unowned. */
|
|
791
|
+
dispose() {
|
|
792
|
+
for (const attachment of [...this.#liveAttachments]) attachment.dispose();
|
|
793
|
+
for (const entry of [...this.#sessions.values()]) {
|
|
794
|
+
if (!entry.exit) endPane(entry.child);
|
|
795
|
+
this.#forget(entry);
|
|
796
|
+
}
|
|
797
|
+
this.#attachments.clear();
|
|
798
|
+
// Released cleanly: the watchdog leaves without sweeping.
|
|
799
|
+
this.#watchdog?.stdin.end('bye\n');
|
|
800
|
+
this.#watchdog = null;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
#describe(entry, table) {
|
|
804
|
+
return Object.freeze({
|
|
805
|
+
// A dead pane runs nothing; otherwise the first program beneath its shell, else what it launched.
|
|
806
|
+
activeCommand: entry.exit ? null : foregroundCommand(entry.child.pid, table) ?? commandName(entry.program),
|
|
807
|
+
contextKey: entry.contextKey,
|
|
808
|
+
// The launch directory: another process's current directory is not readable here as tmux reads it.
|
|
809
|
+
cwd: entry.cwd,
|
|
810
|
+
dead: entry.exit !== null,
|
|
811
|
+
id: entry.id,
|
|
812
|
+
label: entry.name,
|
|
813
|
+
owned: true,
|
|
814
|
+
rootPid: entry.exit ? null : entry.child.pid,
|
|
815
|
+
size: Object.freeze({ columns: entry.screen.cols, rows: entry.screen.rows }),
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
#entry(id) {
|
|
820
|
+
const entry = this.#sessions.get(id);
|
|
821
|
+
if (!entry) throw new TerminalHostError('session_not_found', 'The pane no longer exists.');
|
|
822
|
+
return entry;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** A pane whose program still runs. */
|
|
826
|
+
#live(id) {
|
|
827
|
+
const entry = this.#entry(id);
|
|
828
|
+
if (entry.exit) throw new TerminalHostError('session_not_found', `The pane's program has exited (${entry.exit.exitCode}).`);
|
|
829
|
+
return entry;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
#forget(entry) {
|
|
833
|
+
if (!this.#sessions.delete(entry.id)) return;
|
|
834
|
+
for (const [id, grant] of this.#attachments) if (grant.sessionId === entry.id) this.#attachments.delete(id);
|
|
835
|
+
this.#tellExit(entry);
|
|
836
|
+
for (const gone of [...entry.gone]) gone();
|
|
837
|
+
entry.gone.clear();
|
|
838
|
+
entry.screen.dispose();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Every viewer hears the pane end, once. */
|
|
842
|
+
#tellExit(entry) {
|
|
843
|
+
const { exitCode = 0, signal } = entry.exit ?? {};
|
|
844
|
+
for (const listener of [...entry.listeners]) listener.exit(exitCode, signal);
|
|
845
|
+
entry.listeners.clear();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
#pruneAttachments() {
|
|
849
|
+
const now = this.#now();
|
|
850
|
+
for (const [id, grant] of this.#attachments) if (grant.expiresAt <= now) this.#attachments.delete(id);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* The process table, read at most once per PROCESS_TABLE_TTL_MS and shared by every caller that asks while a read
|
|
855
|
+
* runs. `fresh` starts a read now, for a caller that needs a process started since the last one began.
|
|
856
|
+
*/
|
|
857
|
+
async #readProcessTable({ fresh = false } = {}) {
|
|
858
|
+
if (!fresh && this.#processTable && this.#now() - this.#processTable.at < PROCESS_TABLE_TTL_MS) return this.#processTable.rows;
|
|
859
|
+
if (!fresh && this.#processTableRead) return this.#processTableRead;
|
|
860
|
+
const at = this.#now();
|
|
861
|
+
const read = new Promise((resolve) => {
|
|
862
|
+
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
863
|
+
'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.Ticks) $($_.Name)" }'],
|
|
864
|
+
{ timeout: 10_000, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
|
|
865
|
+
if (error) return resolve(null);
|
|
866
|
+
// `ticks` is exact (past 2^53, so text); `created` orders processes, where microseconds do not matter.
|
|
867
|
+
resolve(stdout.split(/\r?\n/).map((line) => /^(\d+) (\d+) (\d*) (.+)$/.exec(line.trim())).filter(Boolean)
|
|
868
|
+
.map(([, pid, parent, ticks, name]) => ({ pid: Number(pid), parent: Number(parent), created: Number(ticks) || 0, ticks: ticks || '0', name })));
|
|
869
|
+
});
|
|
870
|
+
}).then((rows) => {
|
|
871
|
+
if (this.#processTableRead === read) this.#processTableRead = null;
|
|
872
|
+
if (!this.#processTable || this.#processTable.at <= at) this.#processTable = { at, rows };
|
|
873
|
+
return rows;
|
|
874
|
+
});
|
|
875
|
+
this.#processTableRead = read;
|
|
876
|
+
return read;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
#startWatchdog() {
|
|
880
|
+
if (this.#watchdog) return;
|
|
881
|
+
this.#watchdog = spawn(process.execPath, ['-e', WATCHDOG_SOURCE, SWEEP_SCRIPT], { detached: true, stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true });
|
|
882
|
+
this.#watchdog.stdin.on('error', () => {});
|
|
883
|
+
// Neither the watchdog nor its pipe keeps this process running.
|
|
884
|
+
this.#watchdog.unref();
|
|
885
|
+
this.#watchdog.stdin.unref?.();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Tell the watchdog the live panes, each named exactly where the table gave its start. */
|
|
889
|
+
#writeWatchList() {
|
|
890
|
+
if (!this.#watchdog) return;
|
|
891
|
+
const roots = [...this.#sessions.values()].filter((entry) => !entry.exit).map((entry) => `${entry.child.pid}:${entry.created}:${entry.exact ? 1 : 0}`);
|
|
892
|
+
this.#watchdog.stdin.write(`${roots.join(';')}\n`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** What a Windows pane runs: the program itself, or a `.cmd`/`.bat` (npm's shims: `claude`, `codex`) through cmd. */
|
|
897
|
+
function windowsLaunch(program, args, env) {
|
|
898
|
+
const resolved = resolveWindowsProgram(program, env);
|
|
899
|
+
if (!/\.(cmd|bat)$/i.test(resolved)) return { file: resolved, args };
|
|
900
|
+
const line = [resolved, ...args].map(batchArgument).join(' ');
|
|
901
|
+
// A string is node-pty's raw command line: `/s` makes cmd take everything between the outer quotes as it is.
|
|
902
|
+
// As Rust runs batch files: `/v:OFF` so a machine that turns on delayed expansion cannot expand `!x!` or eat
|
|
903
|
+
// `^`, `/e:ON` for the command extensions the `%%cd:~,%` escape relies on, `/d` for no AutoRun.
|
|
904
|
+
return { file: env.ComSpec ?? 'cmd.exe', args: `/e:ON /v:OFF /d /s /c "${line}"` };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function resolveWindowsProgram(program, env) {
|
|
908
|
+
if (/[\\/]/.test(program) || /\.[^.\\/]+$/.test(program)) return program;
|
|
909
|
+
const extensions = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
|
910
|
+
for (const directory of (env.PATH ?? env.Path ?? '').split(';').filter(Boolean)) {
|
|
911
|
+
for (const extension of extensions) {
|
|
912
|
+
const candidate = `${directory.replace(/[\\/]+$/, '')}\\${program}${extension.toLowerCase()}`;
|
|
913
|
+
if (existsSync(candidate)) return candidate;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return program;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* One argument of a batch file's command line, quoted so cmd passes it on whole and the program behind the batch
|
|
921
|
+
* file (a C runtime's parser) reads it back exactly: Rust's BatBadBut rules. Always quoted, so cmd's `&|<>^` are
|
|
922
|
+
* text; a quote is doubled; backslashes are doubled before a quote and before the closing quote; each `%` becomes
|
|
923
|
+
* `%%cd:~,%`, which cmd expands to a bare `%`, so no variable is ever expanded. A line break would end cmd's line,
|
|
924
|
+
* so it is refused.
|
|
925
|
+
*/
|
|
926
|
+
function batchArgument(value) {
|
|
927
|
+
const text = String(value);
|
|
928
|
+
if (/[\r\n\0]/.test(text)) {
|
|
929
|
+
throw new TerminalHostError('invalid_command', 'An argument to a batch program cannot contain a line break.');
|
|
930
|
+
}
|
|
931
|
+
let quoted = '"';
|
|
932
|
+
let backslashes = 0;
|
|
933
|
+
for (const character of text) {
|
|
934
|
+
if (character === '\\') {
|
|
935
|
+
backslashes += 1;
|
|
936
|
+
} else {
|
|
937
|
+
if (character === '"') quoted += '\\'.repeat(backslashes);
|
|
938
|
+
backslashes = 0;
|
|
939
|
+
}
|
|
940
|
+
if (character === '"') quoted += '"';
|
|
941
|
+
else if (character === '%') quoted += '%%cd:~,';
|
|
942
|
+
quoted += character;
|
|
943
|
+
}
|
|
944
|
+
return `${quoted}${'\\'.repeat(backslashes)}"`;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** The first process beneath a pane's shell that is not a shell, as tmux names a pane's foreground command. */
|
|
948
|
+
function foregroundCommand(rootPid, table) {
|
|
949
|
+
if (!table) return null;
|
|
950
|
+
let current = table.find((row) => row.pid === rootPid);
|
|
951
|
+
if (!current) return null;
|
|
952
|
+
for (;;) {
|
|
953
|
+
const name = commandName(current.name);
|
|
954
|
+
if (!PTY_SHELLS.has(name.toLowerCase())) return name;
|
|
955
|
+
const children = table.filter((row) => row.parent === current.pid && row.created >= current.created && !CONSOLE_HOSTS.has(row.name.toLowerCase()));
|
|
956
|
+
if (!children.length) return name;
|
|
957
|
+
current = children.reduce((oldest, row) => (row.created < oldest.created ? row : oldest));
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** A moment as CIM gives a process's CreationDate: .NET ticks of local time. */
|
|
962
|
+
function cimTicks(ms) {
|
|
963
|
+
return String((BigInt(ms - new Date(ms).getTimezoneOffset() * 60_000) * 10_000n) + 621_355_968_000_000_000n);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function commandName(value) {
|
|
967
|
+
return value ? String(value).split(/[\\/]/).pop().replace(/\.(exe|cmd|bat|com)$/i, '') : null;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** One captured row as `capture-pane -e` gives it: the row's text with an SGR sequence wherever its style changes. */
|
|
971
|
+
function styledRow(line, cell) {
|
|
972
|
+
let text = '';
|
|
973
|
+
let current = '';
|
|
974
|
+
let pending = '';
|
|
975
|
+
for (let column = 0; column < line.length; column += 1) {
|
|
976
|
+
line.getCell(column, cell);
|
|
977
|
+
if (cell.getWidth() === 0) continue;
|
|
978
|
+
const style = sgrOf(cell);
|
|
979
|
+
const character = cell.getChars() || ' ';
|
|
980
|
+
if (character === ' ' && style === '') { pending += ' '; continue; }
|
|
981
|
+
if (style !== current) {
|
|
982
|
+
pending += style === '' ? '\u001b[0m' : `\u001b[0;${style}m`;
|
|
983
|
+
current = style;
|
|
984
|
+
}
|
|
985
|
+
text += pending + character;
|
|
986
|
+
pending = '';
|
|
987
|
+
}
|
|
988
|
+
return current === '' ? text : `${text}\u001b[0m`;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function sgrOf(cell) {
|
|
992
|
+
const codes = [];
|
|
993
|
+
if (cell.isBold()) codes.push(1);
|
|
994
|
+
if (cell.isDim()) codes.push(2);
|
|
995
|
+
if (cell.isItalic()) codes.push(3);
|
|
996
|
+
if (cell.isUnderline()) codes.push(4);
|
|
997
|
+
if (cell.isBlink()) codes.push(5);
|
|
998
|
+
if (cell.isInverse()) codes.push(7);
|
|
999
|
+
if (cell.isInvisible()) codes.push(8);
|
|
1000
|
+
if (cell.isStrikethrough()) codes.push(9);
|
|
1001
|
+
colorCodes(codes, cell.isFgPalette(), cell.isFgRGB(), cell.getFgColor(), 30, 90, 38);
|
|
1002
|
+
colorCodes(codes, cell.isBgPalette(), cell.isBgRGB(), cell.getBgColor(), 40, 100, 48);
|
|
1003
|
+
return codes.join(';');
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function colorCodes(codes, palette, rgb, color, base, bright, extended) {
|
|
1007
|
+
if (palette) codes.push(...(color < 8 ? [base + color] : color < 16 ? [bright + color - 8] : [extended, 5, color]));
|
|
1008
|
+
else if (rgb) codes.push(extended, 2, (color >> 16) & 255, (color >> 8) & 255, color & 255);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function resizePane(entry, cols, rows) {
|
|
1012
|
+
entry.child.resize(cols, rows);
|
|
1013
|
+
entry.screen.resize(cols, rows);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** End a live pane and everything it started (`taskkill /T` follows the tree), then release its ConPTY. */
|
|
1017
|
+
function endPane(child) {
|
|
1018
|
+
spawnSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1019
|
+
releasePane(child);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Close a pane's pseudoconsole and pipes, which otherwise hold this process open after its program ends. node-pty's
|
|
1024
|
+
* kill first forks a helper to list the console's processes, and once the console is gone that helper prints
|
|
1025
|
+
* "AttachConsole failed" to this process's stderr for every pane; the pane's tree is ended here instead (taskkill
|
|
1026
|
+
* on close, the watchdog when the host dies). node-pty also defers kill until a pane's first output, so a pane that never printed
|
|
1027
|
+
* is released through its agent directly.
|
|
1028
|
+
*/
|
|
1029
|
+
function releasePane(child) {
|
|
1030
|
+
const agent = child._agent;
|
|
1031
|
+
if (typeof agent?._getConsoleProcessList === 'function') agent._getConsoleProcessList = async () => [];
|
|
1032
|
+
try {
|
|
1033
|
+
if (child._isReady === false && agent) {
|
|
1034
|
+
child._close?.();
|
|
1035
|
+
agent.kill();
|
|
1036
|
+
} else {
|
|
1037
|
+
child.kill();
|
|
1038
|
+
}
|
|
1039
|
+
} catch { /* already released */ }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function assertDimension(value, label) {
|
|
1043
|
+
if (!Number.isInteger(value) || value < 1 || value > 1_000) {
|
|
1044
|
+
throw new Error(`Terminal ${label} must be an integer between 1 and 1000.`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function wellFormed(value) {
|
|
1049
|
+
return value.toWellFormed?.() ?? value;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function sleep(ms) {
|
|
1053
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1054
|
+
}
|
|
1055
|
+
|
|
434
1056
|
/**
|
|
435
1057
|
* Complete reusable terminal companion for Supercode frontends.
|
|
436
1058
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-terminal",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.31",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Optional leak-safe terminal and tmux host adapter for Volter Harness frontends",
|
|
6
6
|
"exports": {
|
|
@@ -103,7 +103,9 @@
|
|
|
103
103
|
"preact": ">=10.19.0 <11",
|
|
104
104
|
"react": ">=18 <20",
|
|
105
105
|
"ws": "^8.21.0",
|
|
106
|
-
"@volter/supercode-harness-sdk": "^0.3.18"
|
|
106
|
+
"@volter/supercode-harness-sdk": "^0.3.18",
|
|
107
|
+
"@xterm/headless": "^6.0.0",
|
|
108
|
+
"@xterm/addon-serialize": "^0.14.0"
|
|
107
109
|
},
|
|
108
110
|
"peerDependenciesMeta": {
|
|
109
111
|
"@homebridge/node-pty-prebuilt-multiarch": {
|
|
@@ -132,16 +134,24 @@
|
|
|
132
134
|
},
|
|
133
135
|
"@volter/supercode-harness-sdk": {
|
|
134
136
|
"optional": true
|
|
137
|
+
},
|
|
138
|
+
"@xterm/headless": {
|
|
139
|
+
"optional": true
|
|
140
|
+
},
|
|
141
|
+
"@xterm/addon-serialize": {
|
|
142
|
+
"optional": true
|
|
135
143
|
}
|
|
136
144
|
},
|
|
137
145
|
"devDependencies": {
|
|
138
146
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
|
|
139
147
|
"@types/node": "^22.20.1",
|
|
140
148
|
"@types/ws": "^8.18.1",
|
|
141
|
-
"@volter/supercode-harness-sdk": "
|
|
149
|
+
"@volter/supercode-harness-sdk": "^0.3.18",
|
|
142
150
|
"@xterm/addon-fit": "^0.11.0",
|
|
143
151
|
"@xterm/addon-search": "^0.16.0",
|
|
152
|
+
"@xterm/addon-serialize": "^0.14.0",
|
|
144
153
|
"@xterm/addon-web-links": "^0.12.0",
|
|
154
|
+
"@xterm/headless": "^6.0.0",
|
|
145
155
|
"@xterm/xterm": "^6.0.0",
|
|
146
156
|
"esbuild": "^0.25.0",
|
|
147
157
|
"preact": "^10.19.0",
|