@volter/supercode-terminal 0.2.37 → 0.2.39
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 +22 -1
- package/index.mjs +150 -0
- package/package.json +1 -1
- package/substrate/pane-server.mjs +109 -0
package/index.d.ts
CHANGED
|
@@ -72,9 +72,30 @@ export class TmuxTerminalHost {
|
|
|
72
72
|
dispose(): void;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Terminal panes held by the machine's pane server (a process of its own, reached over a named pipe; Windows):
|
|
77
|
+
* PtyTerminalHost's surface, asynchronous, and the panes outlive this client, as tmux panes outlive a daemon.
|
|
78
|
+
*/
|
|
79
|
+
export class PaneServerHost {
|
|
80
|
+
constructor(options: { pipe: string; serverScript?: string });
|
|
81
|
+
listSessions(): Promise<TerminalSession[]>;
|
|
82
|
+
createSession(input: { command: TmuxCommand; contextKey?: string; cwd?: string; env?: Record<string, string>; name?: string }): Promise<TerminalSession>;
|
|
83
|
+
capture(sessionId: string, options?: { lines?: number; preserveEscapes?: boolean }): Promise<string>;
|
|
84
|
+
sendInput(sessionId: string, data: string, options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' }): Promise<TerminalInputDelivery>;
|
|
85
|
+
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
86
|
+
renameWindow(sessionId: string, title?: string | null): Promise<string>;
|
|
87
|
+
issueAttachment(sessionId: string, options?: { mode?: 'observe' | 'control' }): Promise<TerminalAttachmentGrant>;
|
|
88
|
+
attachSocket(attachmentId: string, socket: unknown, size?: { cols?: number; rows?: number }): Promise<{ dispose(): void }>;
|
|
89
|
+
closeSession(sessionId: string): Promise<void>;
|
|
90
|
+
localAttachLaunch(sessionId: string): never;
|
|
91
|
+
openLocalTerminal(sessionId: string): Promise<void>;
|
|
92
|
+
/** Closes this client's connection; the panes stay with the server. */
|
|
93
|
+
dispose(): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
/**
|
|
76
97
|
* 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.
|
|
98
|
+
* capture and attach. Same surface as TmuxTerminalHost; panes end with the host. The pane server runs one.
|
|
78
99
|
*/
|
|
79
100
|
export class PtyTerminalHost {
|
|
80
101
|
constructor(options?: { attachmentTtlMs?: number });
|
package/index.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { execFile, spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
+
import { connect as netConnect } from 'node:net';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
4
6
|
import { createServer as defaultCreateServer } from 'node:http';
|
|
5
7
|
|
|
6
8
|
import { NativeTerminalSessionHost } from './substrate/native-host.js';
|
|
@@ -808,6 +810,8 @@ export class PtyTerminalHost {
|
|
|
808
810
|
// The launch directory: another process's current directory is not readable here as tmux reads it.
|
|
809
811
|
cwd: entry.cwd,
|
|
810
812
|
dead: entry.exit !== null,
|
|
813
|
+
// How a dead pane's program ended (a failed one is kept so its last screen can be read).
|
|
814
|
+
exitCode: entry.exit ? entry.exit.exitCode ?? null : null,
|
|
811
815
|
id: entry.id,
|
|
812
816
|
label: entry.name,
|
|
813
817
|
owned: true,
|
|
@@ -893,6 +897,152 @@ export class PtyTerminalHost {
|
|
|
893
897
|
}
|
|
894
898
|
}
|
|
895
899
|
|
|
900
|
+
/**
|
|
901
|
+
* Terminal panes held by the machine's pane server (substrate/pane-server.mjs), a process of its own reached over a
|
|
902
|
+
* named pipe: the surface is PtyTerminalHost's, so a daemon drives it the same way, but the panes belong to the
|
|
903
|
+
* server, as tmux panes belong to tmux's server. Restarting or upgrading the daemon ends only this client's
|
|
904
|
+
* connection; the panes and their programs go on, and the next daemon finds them there. The server starts on first
|
|
905
|
+
* use, detached, and leaves a minute after it holds no pane and no client.
|
|
906
|
+
*/
|
|
907
|
+
export class PaneServerHost {
|
|
908
|
+
#connection = null;
|
|
909
|
+
#connecting = null;
|
|
910
|
+
#next = 1;
|
|
911
|
+
#pending = new Map();
|
|
912
|
+
#streams = new Map();
|
|
913
|
+
#pipe;
|
|
914
|
+
#serverScript;
|
|
915
|
+
|
|
916
|
+
constructor(options = {}) {
|
|
917
|
+
if (!options.pipe) throw new TypeError('PaneServerHost requires a pipe name');
|
|
918
|
+
this.#pipe = options.pipe;
|
|
919
|
+
this.#serverScript = options.serverScript ?? fileURLToPath(new URL('./substrate/pane-server.mjs', import.meta.url));
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
listSessions() { return this.#request('listSessions', []); }
|
|
923
|
+
createSession(input) { return this.#request('createSession', [input]); }
|
|
924
|
+
capture(sessionId, options = {}) { return this.#request('capture', [sessionId, options]); }
|
|
925
|
+
sendInput(sessionId, data, options = {}) { return this.#request('sendInput', [sessionId, data, options]); }
|
|
926
|
+
closeSession(sessionId) { return this.#request('closeSession', [sessionId]); }
|
|
927
|
+
renameWindow(sessionId, title) { return this.#request('renameWindow', [sessionId, title]); }
|
|
928
|
+
issueAttachment(sessionId, options = {}) { return this.#request('issueAttachment', [sessionId, options]); }
|
|
929
|
+
bindContext(sessionId, contextKey) { return this.#request('bindContext', [sessionId, contextKey]); }
|
|
930
|
+
|
|
931
|
+
localAttachLaunch() {
|
|
932
|
+
throw new TerminalHostError('unsupported', 'A pane on this platform has no local terminal to attach: open it through a viewer.');
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async openLocalTerminal(sessionId) { this.localAttachLaunch(sessionId); }
|
|
936
|
+
|
|
937
|
+
/** The viewer's socket, relayed to the pane server's attachment: its frames out, the viewer's input in. */
|
|
938
|
+
async attachSocket(attachmentId, socket, size = {}) {
|
|
939
|
+
const connection = await this.#connect();
|
|
940
|
+
const stream = `s${this.#next++}`;
|
|
941
|
+
const relay = {
|
|
942
|
+
socket,
|
|
943
|
+
onMessage: (raw) => { if (!connection.destroyed) connection.write(`${JSON.stringify({ stream, message: raw.toString() })}\n`); },
|
|
944
|
+
onClose: () => relay.dispose(),
|
|
945
|
+
disposed: false,
|
|
946
|
+
dispose: () => {
|
|
947
|
+
if (relay.disposed) return;
|
|
948
|
+
relay.disposed = true;
|
|
949
|
+
socket?.off?.('message', relay.onMessage);
|
|
950
|
+
socket?.off?.('close', relay.onClose);
|
|
951
|
+
this.#streams.delete(stream);
|
|
952
|
+
if (!connection.destroyed) connection.write(`${JSON.stringify({ stream, closed: true })}\n`);
|
|
953
|
+
},
|
|
954
|
+
};
|
|
955
|
+
this.#streams.set(stream, relay);
|
|
956
|
+
socket?.on?.('message', relay.onMessage);
|
|
957
|
+
socket?.once?.('close', relay.onClose);
|
|
958
|
+
try {
|
|
959
|
+
await this.#request('attach', [attachmentId, size], { stream });
|
|
960
|
+
} catch (error) {
|
|
961
|
+
relay.dispose();
|
|
962
|
+
throw error;
|
|
963
|
+
}
|
|
964
|
+
return { dispose: () => relay.dispose() };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** The daemon is going: its connection closes and its viewers with it; the panes stay with the server. */
|
|
968
|
+
dispose() {
|
|
969
|
+
for (const relay of [...this.#streams.values()]) relay.dispose();
|
|
970
|
+
this.#connection?.end();
|
|
971
|
+
this.#connection = null;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async #request(op, args, extra = {}) {
|
|
975
|
+
const connection = await this.#connect();
|
|
976
|
+
const id = this.#next++;
|
|
977
|
+
return new Promise((resolve, reject) => {
|
|
978
|
+
this.#pending.set(id, { resolve, reject });
|
|
979
|
+
connection.write(`${JSON.stringify({ id, op, args, ...extra })}\n`);
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
#connect() {
|
|
984
|
+
if (this.#connection && !this.#connection.destroyed) return Promise.resolve(this.#connection);
|
|
985
|
+
return (this.#connecting ??= this.#open().finally(() => { this.#connecting = null; }));
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async #open() {
|
|
989
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
990
|
+
try {
|
|
991
|
+
const connection = await new Promise((resolve, reject) => {
|
|
992
|
+
const candidate = netConnect(this.#pipe);
|
|
993
|
+
candidate.once('connect', () => resolve(candidate));
|
|
994
|
+
candidate.once('error', reject);
|
|
995
|
+
});
|
|
996
|
+
this.#adopt(connection);
|
|
997
|
+
return connection;
|
|
998
|
+
} catch (error) {
|
|
999
|
+
// No server on the pipe yet: start one, detached from this daemon, and try again as it comes up.
|
|
1000
|
+
if (attempt === 0 && (error.code === 'ENOENT' || error.code === 'ECONNREFUSED')) {
|
|
1001
|
+
const child = spawn(process.execPath, [this.#serverScript, this.#pipe], { detached: true, stdio: 'ignore', windowsHide: true, env: process.env });
|
|
1002
|
+
child.on('error', () => {});
|
|
1003
|
+
child.unref();
|
|
1004
|
+
}
|
|
1005
|
+
await sleep(250);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
throw new TerminalHostError('pane_server_unreachable', `The pane server at ${this.#pipe} did not answer.`);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
#adopt(connection) {
|
|
1012
|
+
this.#connection = connection;
|
|
1013
|
+
connection.setEncoding('utf8');
|
|
1014
|
+
let pending = '';
|
|
1015
|
+
connection.on('data', (chunk) => {
|
|
1016
|
+
pending += chunk;
|
|
1017
|
+
for (let end; (end = pending.indexOf('\n')) >= 0; pending = pending.slice(end + 1)) {
|
|
1018
|
+
let message;
|
|
1019
|
+
try { message = JSON.parse(pending.slice(0, end)); } catch { continue; }
|
|
1020
|
+
if (message.stream !== undefined && message.id === undefined) {
|
|
1021
|
+
const relay = this.#streams.get(message.stream);
|
|
1022
|
+
if (!relay) continue;
|
|
1023
|
+
if (typeof message.send === 'string' && relay.socket?.readyState === relay.socket?.OPEN) relay.socket.send(message.send);
|
|
1024
|
+
if (message.close) { this.#streams.delete(message.stream); relay.disposed = true; relay.socket?.close?.(message.close[0], message.close[1]); }
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
const waiter = this.#pending.get(message.id);
|
|
1028
|
+
if (!waiter) continue;
|
|
1029
|
+
this.#pending.delete(message.id);
|
|
1030
|
+
if (message.error) waiter.reject(new TerminalHostError(message.error.code, message.error.message));
|
|
1031
|
+
else waiter.resolve(message.result);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
// The server went (or this connection did): waiting requests fail, and viewers are told; the next call reconnects.
|
|
1035
|
+
connection.on('close', () => {
|
|
1036
|
+
if (this.#connection === connection) this.#connection = null;
|
|
1037
|
+
for (const waiter of this.#pending.values()) waiter.reject(new TerminalHostError('pane_server_unreachable', 'The pane server connection closed.'));
|
|
1038
|
+
this.#pending.clear();
|
|
1039
|
+
for (const relay of [...this.#streams.values()]) { relay.disposed = true; relay.socket?.close?.(1011, 'pane server connection closed'); }
|
|
1040
|
+
this.#streams.clear();
|
|
1041
|
+
});
|
|
1042
|
+
connection.on('error', () => {});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
896
1046
|
/** What a Windows pane runs: the program itself, or a `.cmd`/`.bat` (npm's shims: `claude`, `codex`) through cmd. */
|
|
897
1047
|
function windowsLaunch(program, args, env) {
|
|
898
1048
|
const resolved = resolveWindowsProgram(program, env);
|
package/package.json
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The machine's pane server where there is no tmux (Windows): a process of its own that holds the ConPTY panes, as
|
|
2
|
+
// tmux's server holds tmux panes, so restarting or upgrading the daemon never ends a pane. The daemon is a client
|
|
3
|
+
// (PaneServerHost) over a named pipe; this process serves PtyTerminalHost's surface to it, one JSON line per message.
|
|
4
|
+
//
|
|
5
|
+
// request { id, op, args } → { id, result } | { id, error: { code, message } }
|
|
6
|
+
// attach { id, op: 'attach', args: [attachmentId, size], stream }
|
|
7
|
+
// then the server sends { stream, send: <text> } per viewer frame and { stream, close: [code, reason] };
|
|
8
|
+
// the client sends { stream, message: <text> } and { stream, closed: true }
|
|
9
|
+
//
|
|
10
|
+
// It keeps running while it holds a pane or a client is connected, and leaves a minute after it has neither. If the
|
|
11
|
+
// process itself dies, PtyTerminalHost's watchdog ends its panes (nothing is left running unowned).
|
|
12
|
+
import { createServer } from 'node:net';
|
|
13
|
+
import { PtyTerminalHost } from '../index.mjs';
|
|
14
|
+
|
|
15
|
+
const pipe = process.argv[2];
|
|
16
|
+
if (!pipe) { process.stderr.write('pane-server: a pipe name is required\n'); process.exit(2); }
|
|
17
|
+
|
|
18
|
+
const host = new PtyTerminalHost();
|
|
19
|
+
const clients = new Set();
|
|
20
|
+
const IDLE_EXIT_MS = 60_000;
|
|
21
|
+
let idleTimer = null;
|
|
22
|
+
|
|
23
|
+
const OPS = new Set(['listSessions', 'createSession', 'capture', 'sendInput', 'closeSession', 'renameWindow', 'issueAttachment', 'bindContext']);
|
|
24
|
+
|
|
25
|
+
function scheduleIdleExit() {
|
|
26
|
+
clearTimeout(idleTimer);
|
|
27
|
+
idleTimer = setTimeout(async () => {
|
|
28
|
+
const panes = await host.listSessions().catch(() => []);
|
|
29
|
+
if (clients.size === 0 && panes.length === 0) { host.dispose(); process.exit(0); }
|
|
30
|
+
scheduleIdleExit();
|
|
31
|
+
}, IDLE_EXIT_MS);
|
|
32
|
+
idleTimer.unref?.();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const server = createServer((connection) => {
|
|
36
|
+
clients.add(connection);
|
|
37
|
+
connection.setEncoding('utf8');
|
|
38
|
+
const streams = new Map(); // stream id → { socketLike, attachment }
|
|
39
|
+
const write = (value) => { if (!connection.destroyed) connection.write(`${JSON.stringify(value)}\n`); };
|
|
40
|
+
const answer = (id, result) => write({ id, result: result === undefined ? null : result });
|
|
41
|
+
const refuse = (id, error) => write({ id, error: { code: error?.code ?? 'pane_server_error', message: error?.message ?? String(error) } });
|
|
42
|
+
|
|
43
|
+
const attach = async (id, [attachmentId, size], stream) => {
|
|
44
|
+
const listeners = { message: new Set(), close: new Set() };
|
|
45
|
+
const socketLike = {
|
|
46
|
+
OPEN: 1, CLOSED: 3, readyState: 1,
|
|
47
|
+
send: (data) => write({ stream, send: String(data) }),
|
|
48
|
+
close: (code, reason) => {
|
|
49
|
+
if (socketLike.readyState !== 1) return;
|
|
50
|
+
socketLike.readyState = 3;
|
|
51
|
+
write({ stream, close: [code ?? 1000, reason ?? ''] });
|
|
52
|
+
for (const listener of [...listeners.close]) listener();
|
|
53
|
+
streams.delete(stream);
|
|
54
|
+
},
|
|
55
|
+
on: (event, listener) => { listeners[event]?.add(listener); return socketLike; },
|
|
56
|
+
once: (event, listener) => { const once = (...a) => { listeners[event]?.delete(once); listener(...a); }; listeners[event]?.add(once); return socketLike; },
|
|
57
|
+
off: (event, listener) => { listeners[event]?.delete(listener); return socketLike; },
|
|
58
|
+
emitMessage: (text) => { for (const listener of [...listeners.message]) listener(text); },
|
|
59
|
+
emitClose: () => { socketLike.readyState = 3; for (const listener of [...listeners.close]) listener(); },
|
|
60
|
+
};
|
|
61
|
+
streams.set(stream, { socketLike, attachment: null });
|
|
62
|
+
try {
|
|
63
|
+
const attachment = await host.attachSocket(attachmentId, socketLike, size ?? {});
|
|
64
|
+
const entry = streams.get(stream);
|
|
65
|
+
if (entry) entry.attachment = attachment; else attachment.dispose();
|
|
66
|
+
answer(id, true);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
streams.delete(stream);
|
|
69
|
+
refuse(id, error);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let pending = '';
|
|
74
|
+
connection.on('data', (chunk) => {
|
|
75
|
+
pending += chunk;
|
|
76
|
+
for (let end; (end = pending.indexOf('\n')) >= 0; pending = pending.slice(end + 1)) {
|
|
77
|
+
let message;
|
|
78
|
+
try { message = JSON.parse(pending.slice(0, end)); } catch { continue; }
|
|
79
|
+
if (message.stream !== undefined && message.op === undefined) {
|
|
80
|
+
const entry = streams.get(message.stream);
|
|
81
|
+
if (!entry) continue;
|
|
82
|
+
if (typeof message.message === 'string') entry.socketLike.emitMessage(message.message);
|
|
83
|
+
if (message.closed) { streams.delete(message.stream); entry.socketLike.emitClose(); entry.attachment?.dispose(); }
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const { id, op, args = [] } = message;
|
|
87
|
+
if (op === 'attach') { attach(id, args, message.stream); continue; }
|
|
88
|
+
if (!OPS.has(op)) { refuse(id, { code: 'unsupported', message: `pane server: no operation ${op}` }); continue; }
|
|
89
|
+
Promise.resolve().then(() => host[op](...args)).then((result) => answer(id, result), (error) => refuse(id, error));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// A client that goes (its daemon restarting) takes only its viewers with it: the panes stay.
|
|
93
|
+
connection.on('close', () => {
|
|
94
|
+
clients.delete(connection);
|
|
95
|
+
for (const { socketLike, attachment } of streams.values()) { socketLike.emitClose(); attachment?.dispose(); }
|
|
96
|
+
streams.clear();
|
|
97
|
+
scheduleIdleExit();
|
|
98
|
+
});
|
|
99
|
+
connection.on('error', () => {});
|
|
100
|
+
clearTimeout(idleTimer);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
server.on('error', (error) => {
|
|
104
|
+
// Another pane server already holds the pipe: it serves this machine; this one is not needed.
|
|
105
|
+
if (error.code === 'EADDRINUSE') process.exit(0);
|
|
106
|
+
process.stderr.write(`pane-server: ${error.message}\n`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
|
109
|
+
server.listen(pipe, () => scheduleIdleExit());
|