@volter/supercode-terminal 0.2.30

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,147 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { TerminalClient } from "./client.js";
3
+ // Connect the calling terminal directly to a live provider terminal — the raw
4
+ // PTY pipe behind an `attach` / `control` / `chat --attach` door. It reuses
5
+ // the same duplex socket the browser mirror speaks (TerminalClient: an `output`
6
+ // stream in, `input` writes out), so there is no second transport: stdin →
7
+ // input, output → stdout, and a tmux-style prefix key detaches without killing
8
+ // the session (it stays live on the provider, durable on disk, reapable).
9
+ // Ctrl-b, then `d` — the tmux detach chord. A lone prefix is held until the next
10
+ // key so a real Ctrl-b still reaches the inner program (delayed one keystroke);
11
+ // Ctrl-b Ctrl-b sends a literal Ctrl-b. This is the ONE place the chord is
12
+ // decoded, kept pure (no socket/tty) so it is unit-testable.
13
+ export const detachPrefixByte = 0x02; // Ctrl-b
14
+ export const detachKeyByte = 0x64; // d
15
+ export function createDetachFilter(opts) {
16
+ const prefix = opts.prefix ?? detachPrefixByte;
17
+ const key = opts.key ?? detachKeyByte;
18
+ let armed = false;
19
+ return {
20
+ feed(chunk) {
21
+ const bytes = typeof chunk === "string" ? [...Buffer.from(chunk, "utf8")] : [...chunk];
22
+ const out = [];
23
+ const flush = () => {
24
+ if (out.length === 0)
25
+ return;
26
+ opts.onInput(Buffer.from(out).toString("utf8"));
27
+ out.length = 0;
28
+ };
29
+ for (const byte of bytes) {
30
+ if (armed) {
31
+ armed = false;
32
+ if (byte === key) {
33
+ flush();
34
+ opts.onDetach();
35
+ return;
36
+ }
37
+ // Ctrl-b Ctrl-b → a literal Ctrl-b reaches the inner program; any
38
+ // other key after the prefix forwards both bytes unchanged.
39
+ out.push(prefix);
40
+ if (byte !== prefix)
41
+ out.push(byte);
42
+ continue;
43
+ }
44
+ if (byte === prefix) {
45
+ armed = true;
46
+ continue;
47
+ }
48
+ out.push(byte);
49
+ }
50
+ flush();
51
+ }
52
+ };
53
+ }
54
+ // Raw-pipe the calling terminal to `terminalId` on the provider at `baseUrl`.
55
+ // Resolves when the remote pane exits or the user detaches (Ctrl-b d). Restores
56
+ // the local tty on every exit path and follows live TTY resize events.
57
+ export async function attachTerminal(opts) {
58
+ const stdin = opts.streams?.stdin ?? process.stdin;
59
+ const stdout = opts.streams?.stdout ?? process.stdout;
60
+ const cols = stdout.columns ?? 80;
61
+ const rows = stdout.rows ?? 24;
62
+ const client = new TerminalClient({
63
+ ...(opts.authToken ? { authToken: opts.authToken } : {}),
64
+ baseUrl: opts.baseUrl,
65
+ cols,
66
+ rows,
67
+ terminalId: opts.terminalId
68
+ });
69
+ const socket = client.connect();
70
+ return await new Promise((resolve, reject) => {
71
+ let settled = false;
72
+ const onStdin = (chunk) => filter.feed(chunk);
73
+ const onResize = () => {
74
+ if (socket.readyState !== socket.OPEN)
75
+ return;
76
+ client.resize(stdout.columns ?? 80, stdout.rows ?? 24);
77
+ };
78
+ // A real Ctrl-C is forwarded to the remote pane as a raw byte (raw mode), so
79
+ // it never lands here — these fire only on an EXTERNAL kill (or a parent
80
+ // termination). Without restoring the tty first, the process would die with
81
+ // raw mode still on, leaving the user's shell with no echo (needs `reset`).
82
+ const onSignal = () => finish({ reason: "detached" });
83
+ const cleanup = () => {
84
+ stdin.off("data", onStdin);
85
+ stdout.off("resize", onResize);
86
+ process.off("SIGINT", onSignal);
87
+ process.off("SIGTERM", onSignal);
88
+ if (stdin.isTTY)
89
+ stdin.setRawMode(false);
90
+ stdin.pause();
91
+ try {
92
+ client.close();
93
+ }
94
+ catch {
95
+ /* socket already closing */
96
+ }
97
+ };
98
+ const finish = (result) => {
99
+ if (settled)
100
+ return;
101
+ settled = true;
102
+ cleanup();
103
+ resolve(result);
104
+ };
105
+ process.once("SIGINT", onSignal);
106
+ process.once("SIGTERM", onSignal);
107
+ const filter = createDetachFilter({
108
+ onDetach: () => finish({ reason: "detached" }),
109
+ onInput: (data) => {
110
+ if (socket.readyState === socket.OPEN)
111
+ client.write(data);
112
+ }
113
+ });
114
+ socket.onopen = () => {
115
+ if (stdin.isTTY)
116
+ stdin.setRawMode(true);
117
+ stdin.resume();
118
+ stdin.on("data", onStdin);
119
+ stdout.on("resize", onResize);
120
+ };
121
+ socket.onmessage = (event) => {
122
+ // After detach/exit, a still-buffered frame must not write stray bytes to
123
+ // the now-foreground shell.
124
+ if (settled)
125
+ return;
126
+ const raw = typeof event.data === "string" ? event.data : String(event.data);
127
+ const message = client.parse(raw);
128
+ if (message.type === "output") {
129
+ stdout.write(message.data);
130
+ }
131
+ else if (message.type === "exit") {
132
+ finish({ exitCode: message.exitCode, reason: "exited" });
133
+ }
134
+ else if (message.type === "error" || message.type === "input-rejected") {
135
+ stdout.write(`\r\n[attach] ${message.message}\r\n`);
136
+ }
137
+ };
138
+ socket.onclose = () => finish({ reason: "exited" });
139
+ socket.onerror = () => {
140
+ if (settled)
141
+ return;
142
+ settled = true;
143
+ cleanup();
144
+ reject(new Error(`Could not attach to terminal ${opts.terminalId} at ${opts.baseUrl}.`));
145
+ };
146
+ });
147
+ }
@@ -0,0 +1,62 @@
1
+ export type TerminalUrlResolver = {
2
+ wsAuthQuery(): Record<string, string>;
3
+ wsBase(baseUrl: string): string;
4
+ /**
5
+ * The socket of one terminal, for a host that addresses each by path (a
6
+ * Conduit terminal binding's `/sessions/{id}/ws`). Given, the terminal and
7
+ * its size are not added to the URL: the size goes as a `resize` message
8
+ * once the socket is open, as it does for any resize.
9
+ */
10
+ wsUrl?(baseUrl: string, terminalId: string): string;
11
+ };
12
+ export declare const directTerminalUrlResolver: TerminalUrlResolver;
13
+ export type TerminalHelloMessage = {
14
+ type: "hello";
15
+ cols: number;
16
+ rows: number;
17
+ };
18
+ export type TerminalOutputMessage = {
19
+ type: "output";
20
+ data: string;
21
+ };
22
+ export type TerminalExitMessage = {
23
+ type: "exit";
24
+ exitCode: number;
25
+ signal?: number;
26
+ };
27
+ export type TerminalErrorMessage = {
28
+ type: "error";
29
+ message: string;
30
+ };
31
+ export type TerminalInputRejectedMessage = {
32
+ type: "input-rejected";
33
+ message: string;
34
+ };
35
+ export type TerminalMessage = TerminalHelloMessage | TerminalOutputMessage | TerminalExitMessage | TerminalErrorMessage | TerminalInputRejectedMessage;
36
+ export type TerminalClientOptions = {
37
+ authToken?: () => string | undefined;
38
+ baseUrl: string;
39
+ cols?: number;
40
+ rows?: number;
41
+ terminalId: string;
42
+ urlResolver?: TerminalUrlResolver;
43
+ };
44
+ export declare class TerminalClient {
45
+ /** The size last asked for: the constructor's, then each resize's. */
46
+ cols: number | undefined;
47
+ rows: number | undefined;
48
+ readonly terminalId: string;
49
+ private readonly authToken?;
50
+ private socket?;
51
+ private readonly baseUrl;
52
+ private readonly urls;
53
+ constructor({ authToken, baseUrl, cols, rows, terminalId, urlResolver }: TerminalClientOptions);
54
+ connect(): WebSocket;
55
+ write(data: string): void;
56
+ resize(cols: number, rows: number): void;
57
+ close(): void;
58
+ parse(raw: string): TerminalMessage;
59
+ private requireOpenSocket;
60
+ private url;
61
+ }
62
+ export declare function webSocketBaseUrl(baseUrl: string): string;
@@ -0,0 +1,138 @@
1
+ export const directTerminalUrlResolver = {
2
+ wsAuthQuery: () => ({}),
3
+ wsBase: (baseUrl) => webSocketBaseUrl(baseUrl)
4
+ };
5
+ export class TerminalClient {
6
+ /** The size last asked for: the constructor's, then each resize's. */
7
+ cols;
8
+ rows;
9
+ terminalId;
10
+ authToken;
11
+ socket;
12
+ baseUrl;
13
+ urls;
14
+ constructor({ authToken, baseUrl, cols, rows, terminalId, urlResolver }) {
15
+ this.authToken = authToken;
16
+ this.baseUrl = baseUrl;
17
+ this.cols = cols;
18
+ this.rows = rows;
19
+ this.terminalId = terminalId;
20
+ this.urls = urlResolver ?? directTerminalUrlResolver;
21
+ }
22
+ connect() {
23
+ if (this.socket && this.socket.readyState !== WebSocket.CLOSED && this.socket.readyState !== WebSocket.CLOSING) {
24
+ return this.socket;
25
+ }
26
+ const socket = new WebSocket(this.url());
27
+ this.socket = socket;
28
+ // a host addressed by path learns the size as the first resize, after its hello
29
+ if (this.urls.wsUrl && this.cols !== undefined && this.rows !== undefined) {
30
+ const onHello = (event) => {
31
+ try {
32
+ if (this.parse(String(event.data)).type !== "hello")
33
+ return;
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ socket.removeEventListener("message", onHello);
39
+ // the size as it is now: a view that fitted while the socket opened resized since
40
+ if (socket.readyState === WebSocket.OPEN)
41
+ socket.send(JSON.stringify({ cols: this.cols, rows: this.rows, type: "resize" }));
42
+ };
43
+ socket.addEventListener("message", onHello);
44
+ }
45
+ return socket;
46
+ }
47
+ write(data) {
48
+ const socket = this.requireOpenSocket();
49
+ socket.send(JSON.stringify({ data, type: "input" }));
50
+ }
51
+ resize(cols, rows) {
52
+ assertTerminalDimension(cols, "cols");
53
+ assertTerminalDimension(rows, "rows");
54
+ this.cols = cols;
55
+ this.rows = rows;
56
+ const socket = this.requireOpenSocket();
57
+ socket.send(JSON.stringify({ cols, rows, type: "resize" }));
58
+ }
59
+ close() {
60
+ this.socket?.close();
61
+ }
62
+ parse(raw) {
63
+ const message = JSON.parse(raw);
64
+ if (message.type === "hello") {
65
+ const cols = message.cols;
66
+ const rows = message.rows;
67
+ if (cols === undefined || rows === undefined || !Number.isInteger(cols) || !Number.isInteger(rows)) {
68
+ throw new Error(`Hello message for ${this.terminalId} did not include cols and rows.`);
69
+ }
70
+ return { cols, rows, type: "hello" };
71
+ }
72
+ if (message.type === "output") {
73
+ if (typeof message.data !== "string") {
74
+ throw new Error(`Output message for ${this.terminalId} did not include data.`);
75
+ }
76
+ return { data: message.data, type: "output" };
77
+ }
78
+ if (message.type === "exit") {
79
+ const exitCode = message.exitCode;
80
+ if (exitCode === undefined || !Number.isInteger(exitCode)) {
81
+ throw new Error(`Exit message for ${this.terminalId} did not include an exit code.`);
82
+ }
83
+ return { exitCode, signal: message.signal, type: "exit" };
84
+ }
85
+ if (message.type === "error") {
86
+ return { message: message.message ?? `WebSocket error for ${this.terminalId}.`, type: "error" };
87
+ }
88
+ if (message.type === "input-rejected") {
89
+ return { message: message.message ?? `Input rejected for ${this.terminalId}.`, type: "input-rejected" };
90
+ }
91
+ throw new Error(`Unsupported message type: ${String(message.type)}`);
92
+ }
93
+ requireOpenSocket() {
94
+ const socket = this.socket;
95
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
96
+ throw new Error(`Terminal socket for ${this.terminalId} is not open.`);
97
+ }
98
+ return socket;
99
+ }
100
+ url() {
101
+ if (this.urls.wsUrl) {
102
+ const addressed = new URL(this.urls.wsUrl(this.baseUrl, this.terminalId));
103
+ for (const [name, value] of Object.entries(this.urls.wsAuthQuery()))
104
+ addressed.searchParams.set(name, value);
105
+ return addressed.href;
106
+ }
107
+ const url = new URL(`${this.urls.wsBase(this.baseUrl)}/ws`);
108
+ url.searchParams.set("terminalId", this.terminalId);
109
+ // Only send a size when the caller actually specified one (see
110
+ // TerminalClientOptions.cols/rows) — an omitted cols/rows must reach the
111
+ // gateway as an ABSENT query param, never "undefined" as a string, so its
112
+ // own `value === null` fallback to the pane's live size still fires.
113
+ if (this.cols !== undefined) {
114
+ url.searchParams.set("cols", String(this.cols));
115
+ }
116
+ if (this.rows !== undefined) {
117
+ url.searchParams.set("rows", String(this.rows));
118
+ }
119
+ const token = this.authToken?.();
120
+ if (token) {
121
+ url.searchParams.set("token", token);
122
+ }
123
+ for (const [name, value] of Object.entries(this.urls.wsAuthQuery())) {
124
+ url.searchParams.set(name, value);
125
+ }
126
+ return url.href;
127
+ }
128
+ }
129
+ function assertTerminalDimension(value, label) {
130
+ if (!Number.isInteger(value) || value < 1 || value > 1_000) {
131
+ throw new Error(`Terminal ${label} must be an integer between 1 and 1000.`);
132
+ }
133
+ }
134
+ export function webSocketBaseUrl(baseUrl) {
135
+ const url = new URL(baseUrl);
136
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
137
+ return url.origin;
138
+ }
@@ -0,0 +1,41 @@
1
+ import type { TerminalConnector, TerminalUiState } from "@volter/supercode-terminal/ui";
2
+ /** A WebSocket, or a page's own socket onto an exec's stream with that shape. */
3
+ export interface DockerExecSocket extends EventTarget {
4
+ readonly readyState: number;
5
+ binaryType: "blob" | "arraybuffer";
6
+ send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
7
+ close(code?: number, reason?: string): void;
8
+ }
9
+ export interface DockerExecTerminalOptions {
10
+ /** The daemon's base, versioned or not: `http://agent/v1.51`, a binding's base, this page's origin. */
11
+ base: string;
12
+ containerId: string;
13
+ /** Opens `/exec/{id}/start`; a WebSocket to the same address by default. */
14
+ socket?: (url: string) => DockerExecSocket;
15
+ /** Sent with every request: a bearer token for an agent, nothing in a tab. */
16
+ headers?: Record<string, string>;
17
+ shell?: readonly string[];
18
+ cwd?: string;
19
+ env?: Record<string, string>;
20
+ /** Bytes of each shell's newest output a view replays when it attaches. */
21
+ bufferBytes?: number;
22
+ }
23
+ /** One shell's own start, over the controller's defaults. */
24
+ export interface DockerExecShellOptions {
25
+ cwd?: string;
26
+ env?: Record<string, string>;
27
+ label?: string;
28
+ }
29
+ export interface DockerExecTerminal {
30
+ getState(): TerminalUiState;
31
+ subscribe(listener: () => void): () => void;
32
+ readonly connector: TerminalConnector;
33
+ /** Opens a shell and attaches to it; resolves to its id, rejects with the daemon's words. */
34
+ create(shell?: DockerExecShellOptions): Promise<string>;
35
+ attach(sessionId: string): void;
36
+ /** Ends a shell, or dismisses one that exited. */
37
+ stop(sessionId: string): Promise<void>;
38
+ /** Ends every shell. */
39
+ dispose(): void;
40
+ }
41
+ export declare function dockerExecTerminal(options: DockerExecTerminalOptions): DockerExecTerminal;
@@ -0,0 +1,101 @@
1
+ export function dockerExecTerminal(options) {
2
+ const base = options.base.replace(/\/$/u, "");
3
+ const headers = options.headers ?? {};
4
+ const limit = options.bufferBytes ?? 256 * 1024;
5
+ const openSocket = options.socket ?? ((url) => new WebSocket(url.replace(/^http/u, "ws")));
6
+ let state = { available: true, canOpenLocal: false, error: null, sessions: [], attachment: null };
7
+ const listeners = new Set();
8
+ const set = (next) => { state = { ...state, ...next }; for (const listener of listeners)
9
+ listener(); };
10
+ const shells = new Map();
11
+ let count = 0;
12
+ const resize = (id, cols, rows) => {
13
+ void fetch(`${base}/exec/${id}/resize?h=${rows}&w=${cols}`, { method: "POST", headers }).catch(() => undefined);
14
+ };
15
+ const attach = (sessionId) => set({ attachment: { id: `${sessionId}:${Date.now()}`, baseUrl: base, mode: "control", sessionId } });
16
+ const connector = (attachment, handlers, size) => {
17
+ const id = attachment.sessionId ?? attachment.id;
18
+ const shell = shells.get(id);
19
+ if (!shell) {
20
+ handlers.onError("This shell has ended.");
21
+ return { write() { }, resize() { }, close() { } };
22
+ }
23
+ handlers.onOpen();
24
+ if (shell.output)
25
+ handlers.onOutput(shell.output);
26
+ if (shell.exited)
27
+ handlers.onExit();
28
+ else
29
+ resize(id, size.cols, size.rows);
30
+ shell.views.add(handlers);
31
+ return {
32
+ write(data) { if (!shell.exited && shell.socket.readyState === 1)
33
+ shell.socket.send(data); },
34
+ resize(cols, rows) { if (!shell.exited)
35
+ resize(id, cols, rows); },
36
+ // a view detaching leaves its shell running
37
+ close() { shell.views.delete(handlers); },
38
+ };
39
+ };
40
+ async function stop(id) {
41
+ const shell = shells.get(id);
42
+ shells.delete(id);
43
+ shell?.socket.close();
44
+ set({
45
+ sessions: state.sessions.filter((x) => x.id !== id),
46
+ attachment: state.attachment?.sessionId === id ? null : state.attachment,
47
+ });
48
+ }
49
+ return {
50
+ getState: () => state,
51
+ subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
52
+ connector,
53
+ attach,
54
+ stop,
55
+ async create(start = {}) {
56
+ const cwd = start.cwd ?? options.cwd;
57
+ const env = { ...(options.env ?? {}), ...(start.env ?? {}) };
58
+ const response = await fetch(`${base}/containers/${options.containerId}/exec`, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json", ...headers },
61
+ body: JSON.stringify({
62
+ Cmd: options.shell ?? ["sh"], Tty: true, AttachStdin: true, AttachStdout: true, AttachStderr: true,
63
+ ...(cwd ? { WorkingDir: cwd } : {}),
64
+ Env: ["TERM=xterm-256color", ...Object.entries(env).map(([key, value]) => `${key}=${value}`)],
65
+ }),
66
+ });
67
+ const created = await response.json().catch(() => ({}));
68
+ if (!response.ok || !created.Id)
69
+ throw new Error(created.message ?? `The container refused a shell (${response.status}).`);
70
+ const id = created.Id;
71
+ const socket = openSocket(`${base}/exec/${id}/start?h=24&w=80`);
72
+ socket.binaryType = "arraybuffer";
73
+ const shell = { socket, output: "", exited: false, views: new Set() };
74
+ // each shell's own decoder: a character split across frames stays whole
75
+ const decoder = new TextDecoder();
76
+ shells.set(id, shell);
77
+ socket.addEventListener("message", (event) => {
78
+ const data = event.data;
79
+ const text = typeof data === "string" ? data : decoder.decode(data, { stream: true });
80
+ shell.output = (shell.output + text).slice(-limit);
81
+ for (const view of shell.views)
82
+ view.onOutput(text);
83
+ });
84
+ socket.addEventListener("error", () => { for (const view of shell.views)
85
+ view.onError("The shell's connection failed."); });
86
+ socket.addEventListener("close", () => {
87
+ if (shells.get(id) !== shell)
88
+ return;
89
+ shell.exited = true;
90
+ set({ sessions: state.sessions.map((x) => (x.id === id ? { ...x, activeCommand: "exited" } : x)) });
91
+ for (const view of shell.views)
92
+ view.onExit();
93
+ });
94
+ set({ sessions: [...state.sessions, { id, label: start.label ?? `Shell ${++count}`, cwd: cwd ?? null, activeCommand: null, owned: true }], error: null });
95
+ attach(id);
96
+ return id;
97
+ },
98
+ dispose() { for (const id of [...shells.keys()])
99
+ void stop(id); },
100
+ };
101
+ }
@@ -0,0 +1,15 @@
1
+ export type RunOptions = {
2
+ cwd?: string;
3
+ env?: Record<string, string | undefined>;
4
+ inheritEnv?: boolean;
5
+ timeoutMs?: number;
6
+ };
7
+ export declare function commandExists(command: string): boolean;
8
+ export declare function requireCommand(command: string, installHint?: string): void;
9
+ export declare function ensureSystemToolsOnPath(): void;
10
+ export declare function run(command: string, args: string[], options?: RunOptions): string;
11
+ export declare function runBuffer(command: string, args: string[], options?: RunOptions): Buffer;
12
+ export declare function runWithInput(command: string, args: string[], input: Buffer, options?: RunOptions): Buffer;
13
+ export declare function runAsync(command: string, args: string[], options?: RunOptions): Promise<string>;
14
+ export declare function runWithInputAsync(command: string, args: string[], input: Buffer | string, options?: RunOptions): Promise<string>;
15
+ export declare function spawnInherited(command: string, args: string[]): void;