@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.
- package/CAPABILITIES.md +57 -0
- package/LICENSE-SUBSTRATE +201 -0
- package/NOTICE-SUBSTRATE +14 -0
- package/README.md +190 -0
- package/client.d.ts +17 -0
- package/client.mjs +32 -0
- package/index.d.ts +195 -0
- package/index.mjs +981 -0
- package/package.json +156 -0
- package/react/stream.d.ts +3 -0
- package/react/stream.mjs +446 -0
- package/react/ui.d.ts +136 -0
- package/react/ui.mjs +892 -0
- package/stream.d.ts +3 -0
- package/stream.mjs +446 -0
- package/styles.css +160 -0
- package/substrate/attach.d.ts +29 -0
- package/substrate/attach.js +147 -0
- package/substrate/client.d.ts +62 -0
- package/substrate/client.js +138 -0
- package/substrate/docker-exec.d.ts +41 -0
- package/substrate/docker-exec.js +101 -0
- package/substrate/internal/exec.d.ts +15 -0
- package/substrate/internal/exec.js +192 -0
- package/substrate/internal/process-tree.d.ts +1 -0
- package/substrate/internal/process-tree.js +41 -0
- package/substrate/local-terminal.d.ts +13 -0
- package/substrate/local-terminal.js +41 -0
- package/substrate/native-host.d.ts +57 -0
- package/substrate/native-host.js +529 -0
- package/substrate/session-lifecycle.d.ts +162 -0
- package/substrate/session-lifecycle.js +1344 -0
- package/substrate/session-roll.d.ts +14 -0
- package/substrate/session-roll.js +407 -0
- package/substrate/tmux-stream.d.ts +32 -0
- package/substrate/tmux-stream.js +212 -0
- package/substrate/tmux.d.ts +144 -0
- package/substrate/tmux.js +899 -0
- package/ui.d.ts +136 -0
- package/ui.mjs +892 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createServer as defaultCreateServer } from 'node:http';
|
|
3
|
+
|
|
4
|
+
import { NativeTerminalSessionHost } from './substrate/native-host.js';
|
|
5
|
+
import * as defaultTmux from './substrate/tmux.js';
|
|
6
|
+
import { openLocalTerminal as defaultOpenLocalTerminal } from './substrate/local-terminal.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_ATTACHMENT_TTL_MS = 30_000;
|
|
9
|
+
const DEFAULT_CONTEXT_OPTION = '@supercode-context';
|
|
10
|
+
const DEFAULT_OWNER_OPTION = '@supercode-owner';
|
|
11
|
+
const SESSION_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
12
|
+
// A pane should feel like the terminal it is shown in, not like tmux: no status
|
|
13
|
+
// bar, no prefix key taking keystrokes from the program, and no mouse capture,
|
|
14
|
+
// so the viewer's own terminal selects, copies and scrolls. (Native scrollback
|
|
15
|
+
// for attached viewers comes from the server option the substrate sets for its
|
|
16
|
+
// relay clients.) Session-scoped, so other sessions on the same tmux server
|
|
17
|
+
// keep their own settings.
|
|
18
|
+
const TERMINAL_LIKE_SESSION = Object.freeze({ status: 'off', mouse: 'off', prefix: 'None', prefix2: 'None', 'remain-on-exit': 'failed' });
|
|
19
|
+
|
|
20
|
+
// The four native harness kinds this package can host interactively, mapped to
|
|
21
|
+
// the vendor CLI a tmux pane runs. These are the plain interactive TUIs, not the
|
|
22
|
+
// registry's stdio/ACP launches (crates/harness/src/support.rs), whose protocol
|
|
23
|
+
// flags (`--experimental-acp`, `agent … stdio`) drive a headless runtime instead
|
|
24
|
+
// of a pane. A kind whose binary is absent from PATH dies at startup and surfaces
|
|
25
|
+
// as the host's existing `session_not_found` TerminalHostError, naming the tmux
|
|
26
|
+
// session that could not be attached.
|
|
27
|
+
const TERMINAL_HARNESS_PROGRAMS = Object.freeze({
|
|
28
|
+
'claude-code': 'claude',
|
|
29
|
+
codex: 'codex',
|
|
30
|
+
gemini: 'gemini',
|
|
31
|
+
grok: 'grok',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// The native agent-session prefix each kind's pane id carries. substrate/
|
|
35
|
+
// session-lifecycle.js mints `<agent>:<id>` from KNOWN_AGENT_IDS, and the
|
|
36
|
+
// interchange catalog discovers the same four stores under HarnessId::
|
|
37
|
+
// {CLAUDE_CODE, CODEX, GEMINI, GROK}; only claude-code's product id differs from
|
|
38
|
+
// its agent id, so a launched pane binds to the session interchange discovered.
|
|
39
|
+
const NATIVE_AGENT_PREFIXES = Object.freeze({
|
|
40
|
+
'claude-code': 'claude',
|
|
41
|
+
codex: 'codex',
|
|
42
|
+
gemini: 'gemini',
|
|
43
|
+
grok: 'grok',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function assertContextKey(value) {
|
|
47
|
+
if (!value || value.length > 256 || /\0|\t|\r|\n/.test(value)) {
|
|
48
|
+
throw new TerminalHostError('invalid_context_key', 'A terminal context key must be a non-empty opaque value of at most 256 characters.');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function allowedOrigin(value) {
|
|
53
|
+
const origin = typeof value === 'string' ? value.trim().replace(/\/$/, '') : '';
|
|
54
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\/[^/?#]+$/i.test(origin)) {
|
|
55
|
+
throw new TypeError('TerminalWebSocketBridge requires an exact allowed origin');
|
|
56
|
+
}
|
|
57
|
+
return origin;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Exact-origin loopback transport for browser and extension terminal viewers. */
|
|
61
|
+
export class TerminalWebSocketBridge {
|
|
62
|
+
#allowedOrigins;
|
|
63
|
+
#baseUrl = '';
|
|
64
|
+
#createServer;
|
|
65
|
+
#host;
|
|
66
|
+
#server = null;
|
|
67
|
+
#sockets = null;
|
|
68
|
+
#WebSocketServer;
|
|
69
|
+
|
|
70
|
+
constructor(options = {}) {
|
|
71
|
+
if (!options.host?.issueAttachment || !options.host?.attachSocket) {
|
|
72
|
+
throw new TypeError('TerminalWebSocketBridge requires a TmuxTerminalHost-compatible host');
|
|
73
|
+
}
|
|
74
|
+
const origins = Array.isArray(options.allowedOrigins) ? options.allowedOrigins : [options.allowedOrigins];
|
|
75
|
+
this.#allowedOrigins = new Set(origins.map(allowedOrigin));
|
|
76
|
+
this.#createServer = options.createServer ?? defaultCreateServer;
|
|
77
|
+
this.#host = options.host;
|
|
78
|
+
this.#WebSocketServer = options.WebSocketServer;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get baseUrl() {
|
|
82
|
+
return this.#baseUrl;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async start() {
|
|
86
|
+
if (this.#server) return this.#baseUrl;
|
|
87
|
+
const WebSocketServer = this.#WebSocketServer ?? (await import('ws')).WebSocketServer;
|
|
88
|
+
const sockets = new WebSocketServer({ noServer: true });
|
|
89
|
+
const server = this.#createServer((_request, response) => response.writeHead(404).end());
|
|
90
|
+
server.on('upgrade', (request, socket, head) => this.#upgrade(request, socket, head));
|
|
91
|
+
this.#server = server;
|
|
92
|
+
this.#sockets = sockets;
|
|
93
|
+
try {
|
|
94
|
+
await new Promise((resolve, reject) => {
|
|
95
|
+
server.once('error', reject);
|
|
96
|
+
server.listen(0, '127.0.0.1', () => {
|
|
97
|
+
server.off('error', reject);
|
|
98
|
+
resolve();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
const address = server.address();
|
|
102
|
+
if (!address || typeof address === 'string') throw new Error('Terminal bridge did not bind a local port');
|
|
103
|
+
this.#baseUrl = `http://127.0.0.1:${address.port}`;
|
|
104
|
+
return this.#baseUrl;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
this.#server = null;
|
|
107
|
+
this.#sockets = null;
|
|
108
|
+
sockets.close();
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
issueAttachment(sessionId, options = {}) {
|
|
114
|
+
if (!this.#baseUrl) throw new TerminalHostError('bridge_not_started', 'The terminal bridge is not started.');
|
|
115
|
+
return Object.freeze({ ...this.#host.issueAttachment(sessionId, options), baseUrl: this.#baseUrl });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async stop() {
|
|
119
|
+
const server = this.#server;
|
|
120
|
+
const sockets = this.#sockets;
|
|
121
|
+
this.#server = null;
|
|
122
|
+
this.#sockets = null;
|
|
123
|
+
this.#baseUrl = '';
|
|
124
|
+
if (!server || !sockets) return;
|
|
125
|
+
for (const socket of sockets.clients) socket.terminate();
|
|
126
|
+
sockets.close();
|
|
127
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#upgrade(request, socket, head) {
|
|
131
|
+
const origin = String(request.headers.origin ?? '').replace(/\/$/, '');
|
|
132
|
+
const url = new URL(request.url ?? '/', this.#baseUrl || 'http://127.0.0.1');
|
|
133
|
+
const attachmentId = url.searchParams.get('terminalId');
|
|
134
|
+
const cols = positiveOptionalInteger(url.searchParams.get('cols'));
|
|
135
|
+
const rows = positiveOptionalInteger(url.searchParams.get('rows'));
|
|
136
|
+
if (url.pathname !== '/ws' || !attachmentId || !this.#allowedOrigins.has(origin)) {
|
|
137
|
+
socket.destroy();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.#sockets.handleUpgrade(request, socket, head, (webSocket) => {
|
|
141
|
+
void this.#host.attachSocket(attachmentId, webSocket, { cols, rows }).catch((error) => {
|
|
142
|
+
if (webSocket.readyState === webSocket.OPEN) {
|
|
143
|
+
webSocket.send(JSON.stringify({ message: errorMessage(error), type: 'error' }));
|
|
144
|
+
webSocket.close(1008, 'terminal attachment rejected');
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class TmuxTerminalHost {
|
|
152
|
+
#allowUnownedControl;
|
|
153
|
+
#attachments = new Map();
|
|
154
|
+
#attachSocket;
|
|
155
|
+
#catalog = new Map();
|
|
156
|
+
#contextOption;
|
|
157
|
+
#idByName = new Map();
|
|
158
|
+
#liveAttachments = new Set();
|
|
159
|
+
#now;
|
|
160
|
+
#openLocalTerminal;
|
|
161
|
+
#ownerId;
|
|
162
|
+
#ownerOption;
|
|
163
|
+
#randomToken;
|
|
164
|
+
#socket;
|
|
165
|
+
#tmux;
|
|
166
|
+
#ttlMs;
|
|
167
|
+
|
|
168
|
+
constructor(options = {}) {
|
|
169
|
+
this.#allowUnownedControl = options.allowUnownedControl === true;
|
|
170
|
+
this.#attachSocket = options.attachSocket;
|
|
171
|
+
this.#contextOption = options.contextOption ?? DEFAULT_CONTEXT_OPTION;
|
|
172
|
+
this.#now = options.now ?? Date.now;
|
|
173
|
+
this.#openLocalTerminal = options.openLocalTerminal ?? defaultOpenLocalTerminal;
|
|
174
|
+
this.#ownerId = options.ownerId ?? `supercode-${process.pid}-${randomToken()}`;
|
|
175
|
+
this.#ownerOption = options.ownerOption ?? DEFAULT_OWNER_OPTION;
|
|
176
|
+
this.#randomToken = options.randomToken ?? randomToken;
|
|
177
|
+
this.#socket = options.socket;
|
|
178
|
+
this.#tmux = options.tmux ?? defaultTmux;
|
|
179
|
+
this.#ttlMs = positiveInteger(options.attachmentTtlMs ?? DEFAULT_ATTACHMENT_TTL_MS, 'attachmentTtlMs');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async listSessions() {
|
|
183
|
+
this.#pruneAttachments();
|
|
184
|
+
const names = this.#sessionNames();
|
|
185
|
+
const liveNames = new Set(names);
|
|
186
|
+
for (const [name, id] of this.#idByName) {
|
|
187
|
+
if (!liveNames.has(name)) {
|
|
188
|
+
this.#idByName.delete(name);
|
|
189
|
+
this.#catalog.delete(id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const panes = this.#panes();
|
|
193
|
+
const [contextKeys, sizes] = await Promise.all([
|
|
194
|
+
this.#tmux.sessionOptionValuesAsync(this.#contextOption, this.#socket),
|
|
195
|
+
this.#tmux.listWindowSizesAsync(this.#socket),
|
|
196
|
+
]);
|
|
197
|
+
const ownedNames = new Set(this.#tmux.sessionsOwnedBy(this.#ownerId, this.#socket, this.#ownerOption));
|
|
198
|
+
return names.map((name) => {
|
|
199
|
+
const id = this.#catalogId(name);
|
|
200
|
+
const pane = panes.find((candidate) => candidate.session === name && candidate.window === 0 && candidate.pane === 0);
|
|
201
|
+
const size = sizes.get(name);
|
|
202
|
+
return Object.freeze({
|
|
203
|
+
activeCommand: pane?.currentCommand ?? null,
|
|
204
|
+
rootPid: pane?.rootPid ?? null,
|
|
205
|
+
contextKey: contextKeys.get(name) ?? null,
|
|
206
|
+
cwd: pane?.cwd ?? null,
|
|
207
|
+
id,
|
|
208
|
+
label: name,
|
|
209
|
+
owned: ownedNames.has(name),
|
|
210
|
+
size: size ? Object.freeze({ columns: size.width, rows: size.height }) : null,
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async createSession(input) {
|
|
216
|
+
const name = input.name ?? `supercode-${this.#randomToken().slice(0, 16)}`;
|
|
217
|
+
if (!SESSION_NAME.test(name)) {
|
|
218
|
+
throw new TerminalHostError('invalid_session_name', 'A tmux session name must contain 1–64 letters, numbers, underscores, or hyphens.');
|
|
219
|
+
}
|
|
220
|
+
if (!input.command?.program) {
|
|
221
|
+
throw new TerminalHostError('invalid_command', 'A structured command program is required.');
|
|
222
|
+
}
|
|
223
|
+
if (input.contextKey !== undefined) assertContextKey(input.contextKey);
|
|
224
|
+
if (this.#sessionNames().includes(name)) {
|
|
225
|
+
throw new TerminalHostError('session_exists', `A tmux session named ${name} already exists.`);
|
|
226
|
+
}
|
|
227
|
+
await this.#tmux.createSession({
|
|
228
|
+
command: {
|
|
229
|
+
arguments: input.command.arguments ?? [],
|
|
230
|
+
program: input.command.program,
|
|
231
|
+
},
|
|
232
|
+
cwd: input.cwd,
|
|
233
|
+
env: input.env,
|
|
234
|
+
name,
|
|
235
|
+
owner: this.#ownerId,
|
|
236
|
+
ownerOption: this.#ownerOption,
|
|
237
|
+
panes: 1,
|
|
238
|
+
sessionOptions: input.contextKey ? { [this.#contextOption]: input.contextKey } : {},
|
|
239
|
+
settings: TERMINAL_LIKE_SESSION,
|
|
240
|
+
socket: this.#socket,
|
|
241
|
+
});
|
|
242
|
+
const id = this.#catalogId(name);
|
|
243
|
+
const created = (await this.listSessions()).find((session) => session.id === id);
|
|
244
|
+
if (!created) {
|
|
245
|
+
throw new TerminalHostError('session_not_found', 'The tmux session exited before it could be attached.');
|
|
246
|
+
}
|
|
247
|
+
return created;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
capture(sessionId, options = {}) {
|
|
251
|
+
const entry = this.#entry(sessionId);
|
|
252
|
+
return this.#tmux.capturePane({
|
|
253
|
+
lines: options.lines,
|
|
254
|
+
preserveEscapes: options.preserveEscapes === true,
|
|
255
|
+
socket: this.#socket,
|
|
256
|
+
target: `${entry.name}:0.0`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async sendInput(sessionId, data, options = {}) {
|
|
261
|
+
const entry = this.#entry(sessionId);
|
|
262
|
+
if (!this.#canControl(entry.name)) {
|
|
263
|
+
throw new TerminalHostError('input_not_allowed', 'This host does not own that tmux session.');
|
|
264
|
+
}
|
|
265
|
+
return await this.#tmux.sendInputAsync({
|
|
266
|
+
data,
|
|
267
|
+
deadlineMs: options.deadlineMs,
|
|
268
|
+
socket: this.#socket,
|
|
269
|
+
submitMode: options.submitMode,
|
|
270
|
+
target: `${entry.name}:0.0`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async bindContext(sessionId, contextKey) {
|
|
275
|
+
assertContextKey(contextKey);
|
|
276
|
+
const entry = this.#entry(sessionId);
|
|
277
|
+
if (!this.#canControl(entry.name)) {
|
|
278
|
+
throw new TerminalHostError('bind_not_allowed', 'This host does not own that tmux session.');
|
|
279
|
+
}
|
|
280
|
+
await this.#tmux.setSessionOptionAsync(entry.name, this.#contextOption, contextKey, this.#socket);
|
|
281
|
+
const bound = (await this.listSessions()).find((session) => session.id === sessionId);
|
|
282
|
+
if (!bound) {
|
|
283
|
+
throw new TerminalHostError('session_not_found', 'The tmux session exited before it could be bound.');
|
|
284
|
+
}
|
|
285
|
+
return bound;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
issueAttachment(sessionId, options = {}) {
|
|
289
|
+
this.#pruneAttachments();
|
|
290
|
+
const entry = this.#entry(sessionId);
|
|
291
|
+
const mode = options.mode ?? 'observe';
|
|
292
|
+
if (mode !== 'observe' && mode !== 'control') {
|
|
293
|
+
throw new TerminalHostError('invalid_attachment_mode', 'Attachment mode must be observe or control.');
|
|
294
|
+
}
|
|
295
|
+
if (mode === 'control' && !this.#canControl(entry.name)) {
|
|
296
|
+
throw new TerminalHostError('control_not_allowed', 'This host does not own that tmux session.');
|
|
297
|
+
}
|
|
298
|
+
const id = `sct_${this.#randomToken()}`;
|
|
299
|
+
const expiresAt = this.#now() + this.#ttlMs;
|
|
300
|
+
this.#attachments.set(id, { expiresAt, mode, name: entry.name });
|
|
301
|
+
return Object.freeze({ expiresAt, id, mode });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async attachSocket(attachmentId, socket, size = {}) {
|
|
305
|
+
this.#pruneAttachments();
|
|
306
|
+
const grant = this.#attachments.get(attachmentId);
|
|
307
|
+
this.#attachments.delete(attachmentId);
|
|
308
|
+
if (!grant) {
|
|
309
|
+
throw new TerminalHostError('attachment_invalid', 'The terminal attachment is invalid, expired, or already used.');
|
|
310
|
+
}
|
|
311
|
+
if (!this.#sessionNames().includes(grant.name)) {
|
|
312
|
+
throw new TerminalHostError('session_not_found', 'The tmux session no longer exists.');
|
|
313
|
+
}
|
|
314
|
+
const attach = this.#attachSocket ?? (await import('./substrate/tmux-stream.js')).attachTmuxTerminalSocket;
|
|
315
|
+
const attachment = await attach({
|
|
316
|
+
authorizeInput: grant.mode === 'control'
|
|
317
|
+
? undefined
|
|
318
|
+
: () => {
|
|
319
|
+
throw new TerminalHostError('input_not_allowed', 'This is a read-only terminal attachment.');
|
|
320
|
+
},
|
|
321
|
+
errorLabel: 'supercode',
|
|
322
|
+
readOnly: grant.mode === 'observe',
|
|
323
|
+
...(grant.mode === 'control' && size.cols ? { cols: size.cols } : {}),
|
|
324
|
+
...(grant.mode === 'control' && size.rows ? { rows: size.rows } : {}),
|
|
325
|
+
socket,
|
|
326
|
+
terminalId: grant.name,
|
|
327
|
+
tmuxSocket: this.#socket,
|
|
328
|
+
window: 0,
|
|
329
|
+
});
|
|
330
|
+
let disposed = false;
|
|
331
|
+
const onClose = () => wrapped.dispose();
|
|
332
|
+
const wrapped = {
|
|
333
|
+
dispose: () => {
|
|
334
|
+
if (disposed) return;
|
|
335
|
+
disposed = true;
|
|
336
|
+
socket?.off?.('close', onClose);
|
|
337
|
+
this.#liveAttachments.delete(wrapped);
|
|
338
|
+
attachment.dispose();
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
this.#liveAttachments.add(wrapped);
|
|
342
|
+
socket?.once?.('close', onClose);
|
|
343
|
+
return wrapped;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
localAttachLaunch(sessionId) {
|
|
347
|
+
const entry = this.#entry(sessionId);
|
|
348
|
+
if (!this.#canControl(entry.name)) {
|
|
349
|
+
throw new TerminalHostError('control_not_allowed', 'This host does not own that tmux session.');
|
|
350
|
+
}
|
|
351
|
+
return Object.freeze({
|
|
352
|
+
arguments: [...(this.#socket ? ['-L', this.#socket] : []), 'attach-session', '-t', entry.name],
|
|
353
|
+
env: Object.freeze({}),
|
|
354
|
+
program: 'tmux',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async openLocalTerminal(sessionId) {
|
|
359
|
+
await this.#openLocalTerminal(this.localAttachLaunch(sessionId));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
closeSession(sessionId) {
|
|
363
|
+
const entry = this.#entry(sessionId);
|
|
364
|
+
if (!this.#canControl(entry.name)) {
|
|
365
|
+
throw new TerminalHostError('close_not_allowed', 'This host does not own that tmux session.');
|
|
366
|
+
}
|
|
367
|
+
this.#tmux.killSessionProcessTree({ session: entry.name, socket: this.#socket });
|
|
368
|
+
this.#catalog.delete(sessionId);
|
|
369
|
+
this.#idByName.delete(entry.name);
|
|
370
|
+
for (const [id, grant] of this.#attachments) {
|
|
371
|
+
if (grant.name === entry.name) this.#attachments.delete(id);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
dispose() {
|
|
376
|
+
for (const attachment of [...this.#liveAttachments]) attachment.dispose();
|
|
377
|
+
this.#attachments.clear();
|
|
378
|
+
this.#catalog.clear();
|
|
379
|
+
this.#idByName.clear();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#canControl(name) {
|
|
383
|
+
if (this.#allowUnownedControl) return true;
|
|
384
|
+
return this.#tmux.sessionOwner(name, this.#socket, this.#ownerOption) === this.#ownerId;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#catalogId(name) {
|
|
388
|
+
const existing = this.#idByName.get(name);
|
|
389
|
+
if (existing) return existing;
|
|
390
|
+
const id = `scts_${this.#randomToken()}`;
|
|
391
|
+
this.#idByName.set(name, id);
|
|
392
|
+
this.#catalog.set(id, { name });
|
|
393
|
+
return id;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#entry(id) {
|
|
397
|
+
const entry = this.#catalog.get(id);
|
|
398
|
+
if (!entry || !this.#sessionNames().includes(entry.name)) {
|
|
399
|
+
if (entry) {
|
|
400
|
+
this.#catalog.delete(id);
|
|
401
|
+
this.#idByName.delete(entry.name);
|
|
402
|
+
}
|
|
403
|
+
throw new TerminalHostError('session_not_found', 'The tmux session no longer exists.');
|
|
404
|
+
}
|
|
405
|
+
return entry;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#panes() {
|
|
409
|
+
try {
|
|
410
|
+
return this.#tmux.listPanes(this.#socket);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (isEmptyServerError(error)) return [];
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#pruneAttachments() {
|
|
418
|
+
const now = this.#now();
|
|
419
|
+
for (const [id, grant] of this.#attachments) {
|
|
420
|
+
if (grant.expiresAt <= now) this.#attachments.delete(id);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#sessionNames() {
|
|
425
|
+
try {
|
|
426
|
+
return this.#tmux.listTmuxSessionNames(this.#socket);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (isEmptyServerError(error)) return [];
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Complete reusable terminal companion for Supercode frontends.
|
|
436
|
+
*
|
|
437
|
+
* This controller composes durable tmux hosting, leak-safe browser grants, native-session handoff,
|
|
438
|
+
* and harness-aware initial input delivery. Products supply origins and display policy; they do not
|
|
439
|
+
* need to rebuild recovery registries or scrape agent TUIs themselves.
|
|
440
|
+
*/
|
|
441
|
+
export class SupercodeTerminalController {
|
|
442
|
+
#attachment = null;
|
|
443
|
+
#bridge;
|
|
444
|
+
#conversationBindingInFlight = null;
|
|
445
|
+
#contextBySession = new Map();
|
|
446
|
+
#error = null;
|
|
447
|
+
#formatCwd;
|
|
448
|
+
#host;
|
|
449
|
+
#nativeHost;
|
|
450
|
+
#nativeSessionIds = new Set();
|
|
451
|
+
#pendingConversationBindings = new Map();
|
|
452
|
+
#pendingInitialInputs = new Map();
|
|
453
|
+
#sessionNamePrefix;
|
|
454
|
+
|
|
455
|
+
constructor(options = {}) {
|
|
456
|
+
if (!options.allowedOrigins) {
|
|
457
|
+
throw new TypeError('SupercodeTerminalController requires at least one exact browser origin');
|
|
458
|
+
}
|
|
459
|
+
this.#formatCwd = options.formatCwd ?? ((cwd) => cwd);
|
|
460
|
+
this.#host = new TmuxTerminalHost({
|
|
461
|
+
...(options.host ?? {}),
|
|
462
|
+
ownerId: options.host?.ownerId ?? 'supercode-native',
|
|
463
|
+
});
|
|
464
|
+
this.#bridge = new TerminalWebSocketBridge({
|
|
465
|
+
allowedOrigins: options.allowedOrigins,
|
|
466
|
+
host: this.#host,
|
|
467
|
+
});
|
|
468
|
+
this.#nativeHost = new NativeTerminalSessionHost(options.nativeHost);
|
|
469
|
+
this.#sessionNamePrefix = validSessionPrefix(options.sessionNamePrefix ?? 'supercode');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async start() {
|
|
473
|
+
await this.#bridge.start();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async snapshot() {
|
|
477
|
+
try {
|
|
478
|
+
const nativeSessions = await this.#host.listSessions();
|
|
479
|
+
const sessions = nativeSessions.map((session) => Object.freeze({
|
|
480
|
+
...session,
|
|
481
|
+
cwd: session.cwd ? (this.#formatCwd(session.cwd) ?? null) : null,
|
|
482
|
+
}));
|
|
483
|
+
this.#contextBySession.clear();
|
|
484
|
+
for (const session of sessions) {
|
|
485
|
+
if (session.contextKey) this.#contextBySession.set(session.id, session.contextKey);
|
|
486
|
+
}
|
|
487
|
+
return Object.freeze({
|
|
488
|
+
attachment: this.#attachment,
|
|
489
|
+
available: true,
|
|
490
|
+
bindings: Object.freeze([...this.#contextBySession].map(([sessionId, conversationKey]) =>
|
|
491
|
+
Object.freeze({ conversationKey, sessionId }),
|
|
492
|
+
)),
|
|
493
|
+
canOpenLocal: process.platform === 'darwin',
|
|
494
|
+
error: this.#error,
|
|
495
|
+
sessions: Object.freeze(sessions),
|
|
496
|
+
});
|
|
497
|
+
} catch (error) {
|
|
498
|
+
this.#error = errorMessage(error);
|
|
499
|
+
return Object.freeze({
|
|
500
|
+
attachment: null,
|
|
501
|
+
available: false,
|
|
502
|
+
bindings: Object.freeze([]),
|
|
503
|
+
canOpenLocal: process.platform === 'darwin',
|
|
504
|
+
error: this.#error,
|
|
505
|
+
sessions: Object.freeze([]),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async create(harness, cwd) {
|
|
511
|
+
assertTerminalHarness(harness);
|
|
512
|
+
const program = TERMINAL_HARNESS_PROGRAMS[harness];
|
|
513
|
+
const session = await this.#host.createSession({
|
|
514
|
+
command: { program },
|
|
515
|
+
cwd,
|
|
516
|
+
name: this.#sessionName(harness),
|
|
517
|
+
});
|
|
518
|
+
this.#attachment = this.#attachmentFor(session.id, 'control');
|
|
519
|
+
return await this.snapshot();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async launchSession(harness, launch, conversationKey = null, initialInput, options = {}) {
|
|
523
|
+
const harnessLabel = validHarnessLabel(harness);
|
|
524
|
+
if (initialInput !== undefined) assertTerminalHarness(harness);
|
|
525
|
+
let pendingConversationBinding = null;
|
|
526
|
+
if (!conversationKey && options?.conversationDiscovery) {
|
|
527
|
+
assertTerminalHarness(harness);
|
|
528
|
+
const discovery = options.conversationDiscovery;
|
|
529
|
+
if (
|
|
530
|
+
!Array.isArray(discovery.knownConversationKeys) ||
|
|
531
|
+
typeof discovery.prompt !== 'string' ||
|
|
532
|
+
!discovery.prompt.trim()
|
|
533
|
+
) {
|
|
534
|
+
throw new TypeError('conversationDiscovery requires knownConversationKeys and a prompt.');
|
|
535
|
+
}
|
|
536
|
+
pendingConversationBinding = {
|
|
537
|
+
cwd: launch.cwd,
|
|
538
|
+
harness: harnessLabel,
|
|
539
|
+
knownConversationKeys: new Set(discovery.knownConversationKeys.slice(0, 500).filter(
|
|
540
|
+
(key) => typeof key === 'string' && key && key.length <= 200,
|
|
541
|
+
)),
|
|
542
|
+
prompt: discovery.prompt.trim().slice(0, 50_000),
|
|
543
|
+
startedAtMs: Number.isFinite(discovery.startedAtMs) ? discovery.startedAtMs : Date.now(),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
this.#error = null;
|
|
547
|
+
const session = await this.#host.createSession({
|
|
548
|
+
command: { arguments: launch.arguments ?? [], program: launch.program },
|
|
549
|
+
...(conversationKey ? { contextKey: conversationKey } : {}),
|
|
550
|
+
cwd: launch.cwd,
|
|
551
|
+
env: launch.env,
|
|
552
|
+
name: this.#sessionName(harnessLabel),
|
|
553
|
+
});
|
|
554
|
+
if (session.contextKey) this.#contextBySession.set(session.id, session.contextKey);
|
|
555
|
+
if (pendingConversationBinding) {
|
|
556
|
+
this.#pendingConversationBindings.set(session.id, pendingConversationBinding);
|
|
557
|
+
}
|
|
558
|
+
this.#attachment = this.#attachmentFor(session.id, 'control');
|
|
559
|
+
if (initialInput !== undefined) this.#queueInitialInput(session.id, harness, initialInput);
|
|
560
|
+
return await this.snapshot();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async attach(sessionId, mode) {
|
|
564
|
+
this.#attachment = this.#attachmentFor(sessionId, mode);
|
|
565
|
+
return await this.snapshot();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async bindContext(sessionId, conversationKey) {
|
|
569
|
+
await this.#host.bindContext(sessionId, conversationKey);
|
|
570
|
+
this.#contextBySession.set(sessionId, conversationKey);
|
|
571
|
+
if (this.#attachment?.sessionId === sessionId) {
|
|
572
|
+
this.#attachment = Object.freeze({ ...this.#attachment, conversationKey });
|
|
573
|
+
}
|
|
574
|
+
return await this.snapshot();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async reconcileConversationBindings(descriptors, keyForDescriptor, options = {}) {
|
|
578
|
+
if (!Array.isArray(descriptors) || typeof keyForDescriptor !== 'function') {
|
|
579
|
+
throw new TypeError('reconcileConversationBindings requires descriptors and keyForDescriptor.');
|
|
580
|
+
}
|
|
581
|
+
if (this.#conversationBindingInFlight) {
|
|
582
|
+
await this.#conversationBindingInFlight.catch(() => undefined);
|
|
583
|
+
return await this.reconcileConversationBindings(descriptors, keyForDescriptor, options);
|
|
584
|
+
}
|
|
585
|
+
const task = this.#reconcileConversationBindings(descriptors, keyForDescriptor, options);
|
|
586
|
+
this.#conversationBindingInFlight = task;
|
|
587
|
+
try {
|
|
588
|
+
return await task;
|
|
589
|
+
} finally {
|
|
590
|
+
if (this.#conversationBindingInFlight === task) this.#conversationBindingInFlight = null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async #reconcileConversationBindings(descriptors, keyForDescriptor, options) {
|
|
595
|
+
const now = Number.isFinite(options.now) ? options.now : Date.now();
|
|
596
|
+
const bound = [];
|
|
597
|
+
for (const [terminalSessionId, pending] of this.#pendingConversationBindings) {
|
|
598
|
+
if (now - pending.startedAtMs > 10 * 60_000) {
|
|
599
|
+
this.#pendingConversationBindings.delete(terminalSessionId);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
const matches = descriptors.slice(0, 500).filter((descriptor) => {
|
|
603
|
+
const key = keyForDescriptor(descriptor);
|
|
604
|
+
if (
|
|
605
|
+
pending.knownConversationKeys.has(key) ||
|
|
606
|
+
descriptor?.locator?.harness !== pending.harness ||
|
|
607
|
+
descriptor.cwd !== pending.cwd
|
|
608
|
+
) return false;
|
|
609
|
+
return [
|
|
610
|
+
...(descriptor.preview_candidates ?? []),
|
|
611
|
+
...(descriptor.latest_message_candidates ?? []),
|
|
612
|
+
].some((candidate) =>
|
|
613
|
+
candidate?.role === 'user' &&
|
|
614
|
+
typeof candidate.content === 'string' &&
|
|
615
|
+
candidate.content.trim() === pending.prompt);
|
|
616
|
+
});
|
|
617
|
+
const [match] = matches;
|
|
618
|
+
if (matches.length !== 1 || !match) continue;
|
|
619
|
+
const conversationKey = keyForDescriptor(match);
|
|
620
|
+
await this.bindContext(terminalSessionId, conversationKey);
|
|
621
|
+
this.#pendingConversationBindings.delete(terminalSessionId);
|
|
622
|
+
bound.push(Object.freeze({ conversationKey, sessionId: terminalSessionId }));
|
|
623
|
+
}
|
|
624
|
+
return Object.freeze({
|
|
625
|
+
bindings: Object.freeze(bound),
|
|
626
|
+
snapshot: await this.snapshot(),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async close(sessionId) {
|
|
631
|
+
this.#pendingInitialInputs.get(sessionId)?.abort.abort();
|
|
632
|
+
this.#pendingInitialInputs.delete(sessionId);
|
|
633
|
+
this.#pendingConversationBindings.delete(sessionId);
|
|
634
|
+
const live = await this.#host.listSessions();
|
|
635
|
+
if (live.some((session) => session.id === sessionId)) {
|
|
636
|
+
try {
|
|
637
|
+
this.#host.closeSession(sessionId);
|
|
638
|
+
} catch (error) {
|
|
639
|
+
if (!(error instanceof TerminalHostError) || error.code !== 'session_not_found') throw error;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
this.#contextBySession.delete(sessionId);
|
|
643
|
+
if (this.#attachment?.sessionId === sessionId) this.#attachment = null;
|
|
644
|
+
return await this.snapshot();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async openLocal(sessionId) {
|
|
648
|
+
await this.#host.openLocalTerminal(sessionId);
|
|
649
|
+
return await this.snapshot();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async dismiss() {
|
|
653
|
+
this.#attachment = null;
|
|
654
|
+
return await this.snapshot();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async refreshNativeSessions() {
|
|
658
|
+
const sessions = await this.#nativeHost.listSessions();
|
|
659
|
+
this.#nativeSessionIds.clear();
|
|
660
|
+
for (const session of sessions) this.#nativeSessionIds.add(session.agentSessionId);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
canMoveSession(harness, sessionId, cwd) {
|
|
664
|
+
if (!isInteractiveTerminalHarness(harness)) return false;
|
|
665
|
+
const agentSessionId = nativeAgentSessionId(harness, sessionId);
|
|
666
|
+
return this.#nativeSessionIds.has(agentSessionId)
|
|
667
|
+
|| this.#nativeHost.hasSessionCandidate(agentSessionId, { cwd });
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async prepareMoveSession(harness, sessionId, cwd, proof) {
|
|
671
|
+
const agentSessionId = nativeAgentSessionId(harness, sessionId);
|
|
672
|
+
if (proof && this.#nativeSessionIds.has(agentSessionId)) return proof;
|
|
673
|
+
const resolution = await this.#nativeHost.resolveSession(agentSessionId, { cwd });
|
|
674
|
+
if (!resolution) return null;
|
|
675
|
+
this.#nativeSessionIds.add(resolution.session.agentSessionId);
|
|
676
|
+
return resolution.proof;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async moveSession(harness, sessionId, launch, conversationKey, proof) {
|
|
680
|
+
const agentSessionId = nativeAgentSessionId(harness, sessionId);
|
|
681
|
+
if (!this.#nativeSessionIds.has(agentSessionId)) {
|
|
682
|
+
throw new TerminalHostError(
|
|
683
|
+
'native_session_not_found',
|
|
684
|
+
'This conversation is not running in a visible local terminal.',
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
await this.#nativeHost.relinquishSession(agentSessionId, proof);
|
|
688
|
+
this.#nativeSessionIds.delete(agentSessionId);
|
|
689
|
+
return await this.launchSession(harness, launch, conversationKey);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async stop() {
|
|
693
|
+
this.#attachment = null;
|
|
694
|
+
await this.#conversationBindingInFlight?.catch(() => undefined);
|
|
695
|
+
for (const pending of this.#pendingInitialInputs.values()) pending.abort.abort();
|
|
696
|
+
await Promise.allSettled([...this.#pendingInitialInputs.values()].map(({ task }) => task));
|
|
697
|
+
this.#pendingInitialInputs.clear();
|
|
698
|
+
this.#pendingConversationBindings.clear();
|
|
699
|
+
this.#host.dispose();
|
|
700
|
+
await this.#bridge.stop();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
#attachmentFor(sessionId, mode) {
|
|
704
|
+
return Object.freeze({
|
|
705
|
+
...this.#bridge.issueAttachment(sessionId, { mode }),
|
|
706
|
+
conversationKey: this.#contextBySession.get(sessionId) ?? null,
|
|
707
|
+
sessionId,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
#queueInitialInput(sessionId, harness, input) {
|
|
712
|
+
const abort = new AbortController();
|
|
713
|
+
const task = (async () => {
|
|
714
|
+
const deadline = Date.now() + 5 * 60_000;
|
|
715
|
+
while (!abort.signal.aborted && Date.now() < deadline) {
|
|
716
|
+
const content = this.#host.capture(sessionId, { lines: 80 });
|
|
717
|
+
if (composerReadyForInput(harness, content) === true) {
|
|
718
|
+
if (harness === 'codex') {
|
|
719
|
+
await this.#submitCodexInitialInput(sessionId, input, abort.signal);
|
|
720
|
+
} else {
|
|
721
|
+
await this.#host.sendInput(sessionId, input, {
|
|
722
|
+
deadlineMs: Date.now() + 15_000,
|
|
723
|
+
submitMode: 'retry',
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
729
|
+
}
|
|
730
|
+
if (!abort.signal.aborted) throw new Error(`${harness} did not reach its input prompt`);
|
|
731
|
+
})().catch((error) => {
|
|
732
|
+
if (!abort.signal.aborted) {
|
|
733
|
+
this.#error = `initial terminal message was not delivered: ${errorMessage(error)}`;
|
|
734
|
+
}
|
|
735
|
+
}).finally(() => {
|
|
736
|
+
if (this.#pendingInitialInputs.get(sessionId)?.task === task) {
|
|
737
|
+
this.#pendingInitialInputs.delete(sessionId);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
this.#pendingInitialInputs.set(sessionId, { abort, task });
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async #submitCodexInitialInput(sessionId, input, signal) {
|
|
744
|
+
await this.#host.sendInput(sessionId, input, {
|
|
745
|
+
deadlineMs: Date.now() + 15_000,
|
|
746
|
+
submitMode: 'single',
|
|
747
|
+
});
|
|
748
|
+
const deadline = Date.now() + 60_000;
|
|
749
|
+
let sawPrompt = false;
|
|
750
|
+
while (!signal.aborted && Date.now() < deadline) {
|
|
751
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
752
|
+
const state = codexInitialInputState(this.#host.capture(sessionId, { lines: 100 }), input);
|
|
753
|
+
sawPrompt ||= state.promptVisible;
|
|
754
|
+
if (state.turnStarted || (sawPrompt && state.emptyComposerVisible)) return;
|
|
755
|
+
await this.#host.sendInput(sessionId, '\n', {
|
|
756
|
+
deadlineMs: Date.now() + 15_000,
|
|
757
|
+
submitMode: 'retry',
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
if (!signal.aborted) {
|
|
761
|
+
throw new Error('codex accepted the initial terminal text but did not start the turn');
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
#sessionName(harness) {
|
|
766
|
+
const label = harness === 'claude-code' ? 'claude' : harness;
|
|
767
|
+
return `${this.#sessionNamePrefix}-${label}-${randomUUID().slice(0, 8)}`;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export class TerminalHostError extends Error {
|
|
772
|
+
constructor(code, message) {
|
|
773
|
+
super(message);
|
|
774
|
+
this.code = code;
|
|
775
|
+
this.name = 'TerminalHostError';
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function errorMessage(error) {
|
|
780
|
+
return error instanceof Error ? error.message : String(error);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function positiveOptionalInteger(value) {
|
|
784
|
+
if (value === null || value === '') return undefined;
|
|
785
|
+
const parsed = Number(value);
|
|
786
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function isEmptyServerError(error) {
|
|
790
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
791
|
+
return message.includes('no server running') || message.includes('error connecting to');
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function positiveInteger(value, name) {
|
|
795
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
796
|
+
throw new TerminalHostError('invalid_option', `${name} must be a positive integer.`);
|
|
797
|
+
}
|
|
798
|
+
return value;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function assertTerminalHarness(harness) {
|
|
802
|
+
if (!isInteractiveTerminalHarness(harness)) {
|
|
803
|
+
throw new TerminalHostError(
|
|
804
|
+
'unsupported_harness',
|
|
805
|
+
`${String(harness)} native terminal handoff is not supported`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function isInteractiveTerminalHarness(harness) {
|
|
811
|
+
return typeof harness === 'string' && Object.hasOwn(TERMINAL_HARNESS_PROGRAMS, harness);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function validHarnessLabel(harness) {
|
|
815
|
+
if (typeof harness !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(harness)) {
|
|
816
|
+
throw new TerminalHostError('invalid_harness', 'A terminal harness id must be a safe name.');
|
|
817
|
+
}
|
|
818
|
+
return harness;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function nativeAgentSessionId(harness, sessionId) {
|
|
822
|
+
assertTerminalHarness(harness);
|
|
823
|
+
return `${NATIVE_AGENT_PREFIXES[harness]}:${sessionId}`;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* What each interactive kind's IDLE COMPOSER looks like on a captured screen:
|
|
828
|
+
* the program has finished booting its TUI and is waiting for a first message.
|
|
829
|
+
* A table rather than a branch chain so the set of kinds that HAVE a pattern is
|
|
830
|
+
* the table's own keys — no second list to drift out of step with it.
|
|
831
|
+
*/
|
|
832
|
+
const COMPOSER_READY = Object.freeze({
|
|
833
|
+
// codex's placeholder text IS the empty-composer state, which is exactly the
|
|
834
|
+
// question here: not "is there a caret" but "is it waiting for a first
|
|
835
|
+
// message". A composer already holding a draft does not show it.
|
|
836
|
+
codex: (screen) => screen.includes('› Ask Codex to do anything'),
|
|
837
|
+
'claude-code': (screen) => composerLine(screen, /^❯(?:\s|$)/u),
|
|
838
|
+
// gemini draws its idle input caret as a bare ASCII `>` opening a line. The
|
|
839
|
+
// pattern is transcribed from substrate/session-lifecycle.js's isGeminiIdle,
|
|
840
|
+
// which derived it from real `gemini` captures, rather than invented here.
|
|
841
|
+
gemini: (screen) => composerLine(screen, /^>(?:\s|$)/u),
|
|
842
|
+
// grok v1.0.13, captured live from a tmux pane: the banner is followed by a
|
|
843
|
+
// `minimal · /help` hint, a lone `❯` composer line, and a status row
|
|
844
|
+
// (`Grok 4.6 (high) · always-approve · … · ctrl+o transcript`). The caret glyph is
|
|
845
|
+
// the same one claude uses; this check is per-kind, so they cannot collide.
|
|
846
|
+
grok: (screen) => composerLine(screen, /^❯(?:\s|$)/u),
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Does any line of the screen open with this caret? Lines are trimmed first,
|
|
851
|
+
* exactly as substrate/session-lifecycle.js's `visibleLines` does for the same
|
|
852
|
+
* carets: a composer indented by a box or a left gutter is still a composer,
|
|
853
|
+
* and anchoring on column 0 instead would read a drawn-in prompt as absent.
|
|
854
|
+
*/
|
|
855
|
+
function composerLine(screen, caret) {
|
|
856
|
+
return screen.split('\n').some((line) => caret.test(line.trim()));
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Is the program of this kind showing its idle composer — ready to be given a
|
|
861
|
+
* first message — on this captured screen?
|
|
862
|
+
*
|
|
863
|
+
* Three answers, not two: `true` and `false` are readings of a kind whose
|
|
864
|
+
* composer this package knows, and `null` is "no pattern for that kind", which
|
|
865
|
+
* a caller must not read as "not ready yet". Anyone driving a pane it cannot
|
|
866
|
+
* recognize needs to fall back to its own evidence rather than wait forever on
|
|
867
|
+
* a pattern that will never match.
|
|
868
|
+
*
|
|
869
|
+
* Exported because the same question is asked wherever a pane is given text it
|
|
870
|
+
* did not ask for — this controller's own initial message and the teams node's
|
|
871
|
+
* `launch --input` — and one set of patterns with one precision beats a copy
|
|
872
|
+
* per surface.
|
|
873
|
+
*/
|
|
874
|
+
export function composerReadyForInput(kind, screen) {
|
|
875
|
+
if (!Object.hasOwn(COMPOSER_READY, kind)) return null;
|
|
876
|
+
return COMPOSER_READY[kind](String(screen ?? ''));
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Does the composer on this screen hold text the person typed (a draft)?
|
|
881
|
+
* `screen` is a capture WITH escapes, because an empty composer may show a
|
|
882
|
+
* placeholder, and only its styling (dim or grey) tells it from a draft.
|
|
883
|
+
*
|
|
884
|
+
* true the last composer line holds typed text
|
|
885
|
+
* false the composer is on screen and empty (or shows only a placeholder)
|
|
886
|
+
* null no composer is on screen (a menu, a dialog, a program still
|
|
887
|
+
* booting), or the kind has no composer pattern
|
|
888
|
+
*
|
|
889
|
+
* The caret is the kind's own, the one `composerReadyForInput` reads.
|
|
890
|
+
*/
|
|
891
|
+
export function composerHoldsDraft(kind, screen) {
|
|
892
|
+
const caret = COMPOSER_CARETS[kind];
|
|
893
|
+
if (!caret) return null;
|
|
894
|
+
const lines = String(screen ?? '').split('\n');
|
|
895
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
896
|
+
const cells = styledCells(lines[index]);
|
|
897
|
+
const text = cells.map((cell) => cell.char).join('');
|
|
898
|
+
const start = text.length - text.trimStart().length;
|
|
899
|
+
if (!text.trimStart().startsWith(caret)) continue;
|
|
900
|
+
const typed = cells
|
|
901
|
+
.slice(start + caret.length)
|
|
902
|
+
.filter((cell) => !cell.placeholder)
|
|
903
|
+
.map((cell) => cell.char)
|
|
904
|
+
.join('')
|
|
905
|
+
.trim();
|
|
906
|
+
if (kind === 'codex' && typed.startsWith('Ask Codex to do anything')) return false;
|
|
907
|
+
return typed.length > 0;
|
|
908
|
+
}
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** The caret each kind draws at the start of its composer line. */
|
|
913
|
+
const COMPOSER_CARETS = Object.freeze({ 'claude-code': '❯', codex: '›', gemini: '>', grok: '❯' });
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* The visible characters of one captured line, each marked as placeholder
|
|
917
|
+
* styling (dim, bright black, or a grey foreground) or not. Only SGR
|
|
918
|
+
* sequences carry meaning here; any other escape is skipped.
|
|
919
|
+
*/
|
|
920
|
+
function styledCells(line) {
|
|
921
|
+
const cells = [];
|
|
922
|
+
let dim = false;
|
|
923
|
+
let grey = false;
|
|
924
|
+
const pattern = /\u001b\[([0-9;:]*)([A-Za-z])|\u001b[^[]|([^\u001b])/gu;
|
|
925
|
+
for (const match of line.matchAll(pattern)) {
|
|
926
|
+
if (match[3] !== undefined) {
|
|
927
|
+
cells.push({ char: match[3], placeholder: dim || grey });
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (match[2] !== 'm') continue;
|
|
931
|
+
const codes = (match[1] || '0').split(/[;:]/u).map(Number);
|
|
932
|
+
for (let at = 0; at < codes.length; at += 1) {
|
|
933
|
+
const code = codes[at];
|
|
934
|
+
if (code === 0) { dim = false; grey = false; }
|
|
935
|
+
else if (code === 2) dim = true;
|
|
936
|
+
else if (code === 22) dim = false;
|
|
937
|
+
else if (code === 90) grey = true;
|
|
938
|
+
else if (code === 39 || (code >= 30 && code <= 37) || (code >= 91 && code <= 97)) grey = false;
|
|
939
|
+
else if (code === 38 && codes[at + 1] === 5) { const shade = codes[at + 2]; grey = shade === 8 || (shade >= 232 && shade <= 250); at += 2; }
|
|
940
|
+
else if (code === 38 && codes[at + 1] === 2) { const [r, g, b] = codes.slice(at + 2, at + 5); grey = r === g && g === b && r < 200; at += 4; }
|
|
941
|
+
else if (code === 48) at += codes[at + 1] === 5 ? 2 : codes[at + 1] === 2 ? 4 : 0;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return cells;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function codexInitialInputState(content, input) {
|
|
948
|
+
const firstLine = input.replace(/(?:\r\n|\n|\r)$/u, '').split(/\r?\n/u, 1)[0] ?? '';
|
|
949
|
+
const marker = `› ${firstLine.slice(0, 24)}`;
|
|
950
|
+
const promptAt = marker.length > 2 ? content.lastIndexOf(marker) : -1;
|
|
951
|
+
const emptyComposerAt = content.lastIndexOf('› Ask Codex to do anything');
|
|
952
|
+
const tail = promptAt >= 0 ? content.slice(promptAt + marker.length) : '';
|
|
953
|
+
return {
|
|
954
|
+
emptyComposerVisible: emptyComposerAt > promptAt,
|
|
955
|
+
promptVisible: promptAt >= 0,
|
|
956
|
+
turnStarted: promptAt >= 0 && /(?:^|\n)\s*[•◦✦⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s/u.test(tail),
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function validSessionPrefix(value) {
|
|
961
|
+
if (!/^[A-Za-z0-9_-]{1,32}$/.test(value)) {
|
|
962
|
+
throw new TerminalHostError(
|
|
963
|
+
'invalid_session_prefix',
|
|
964
|
+
'A terminal session prefix must contain 1–32 letters, numbers, underscores, or hyphens.',
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
return value;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function randomToken() {
|
|
971
|
+
return randomBytes(24).toString('base64url');
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// The lifecycle observation the substrate already performs, re-exported from the
|
|
975
|
+
// package's main entry so a consumer of this SDK can read a pane's evidence
|
|
976
|
+
// without reaching into `substrate/` by path. `classifyAttentionSignal` is the
|
|
977
|
+
// one classifier for the finer screen read (a crash, a pending choice): it is
|
|
978
|
+
// deliberately conservative and returns undefined on anything ambiguous, so
|
|
979
|
+
// every surface that triages panes shares one precision rather than each
|
|
980
|
+
// inventing its own patterns.
|
|
981
|
+
export { classifyAttentionSignal, observeLifecycle, gatherLifecycleObservation, createLifecycleStore, snapshotProcessTree, snapshotProcessTreeAsync } from './substrate/session-lifecycle.js';
|