@volter/supercode-terminal 0.2.75 → 0.3.1
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 +0 -1
- package/README.md +0 -6
- package/index.d.ts +8 -22
- package/index.mjs +119 -297
- package/package.json +1 -1
- package/substrate/pane-server.mjs +1 -1
- package/substrate/tmux.d.ts +29 -1
- package/substrate/tmux.js +51 -8
package/CAPABILITIES.md
CHANGED
|
@@ -23,7 +23,6 @@ means it must not be presented as supported.
|
|
|
23
23
|
| Scrollback, selection and copy | UI/xterm | verified | Bounded scrollback and native xterm selection. |
|
|
24
24
|
| Find in one terminal, next/previous match | UI | verified | Search covers the active xterm buffer. Cross-tab search is missing. |
|
|
25
25
|
| URL detection | UI | partial | The stable xterm web-link provider is active; host policy and end-to-end link opening still need a dedicated proof. |
|
|
26
|
-
| Exclusive input ownership | tmux/host | partial | `acquireInput`, `ownsInput` and `releaseInput` serialize a paste with other input and remain with the pane across a client restart. [Recorded tmux proof](../../docs/evidence/claude-input-2026-10-07.md#recorded-review-fixes) rejects a replacement while the badge is unrecorded, then confirms one native message after daemon restart. `PaneServerHost.createSession` can require this ABI before creating a pane; [manual old/current server admission](../../docs/evidence/launch-input-ownership-admission-2026-10-09.md) is recorded on macOS. Older pane servers refuse this door; Windows ownership restart and admission remain unmeasured. |
|
|
27
26
|
| Paste and bracketed paste | UI | verified | Multiline or large pastes require confirmation; history is memory-only. Advanced transforms and slow paste are missing. |
|
|
28
27
|
| Save terminal contents | UI | partial | The normal buffer is projected to a bounded plain-text download; the browser download boundary and continuous logging still need dedicated proof. |
|
|
29
28
|
| Bell and background activity | UI | verified | A controlled tmux shell proved active visual bell plus inactive activity/bell tab state. System notifications are host policy. |
|
package/README.md
CHANGED
|
@@ -52,12 +52,6 @@ PTY host reads its own live catalog; an older pane server refuses this operation
|
|
|
52
52
|
This is a host observation, not a physical window/tab reading or an existence
|
|
53
53
|
lock against a subsequent launch.
|
|
54
54
|
|
|
55
|
-
`PaneServerHost.createSession({ ..., requireInputOwnership: true })` requires
|
|
56
|
-
the connected server's ownership operation before creating a pane. An older
|
|
57
|
-
server rejects with `input_ownership_unsupported` and starts no process; the
|
|
58
|
-
same connection handles admission and creation. Omitting the option preserves
|
|
59
|
-
ordinary creation. [Manual old/current server SDK readings](../../docs/evidence/launch-input-ownership-admission-2026-10-09.md)
|
|
60
|
-
do not qualify Windows or native harness first-message delivery.
|
|
61
55
|
|
|
62
56
|
The substrate was moved from a retired package without behavioral changes.
|
|
63
57
|
Its existing home paths, environment variables, and tmux ownership markers stay
|
package/index.d.ts
CHANGED
|
@@ -62,13 +62,11 @@ export class TmuxTerminalHost {
|
|
|
62
62
|
sendInput(
|
|
63
63
|
sessionId: string,
|
|
64
64
|
data: string,
|
|
65
|
-
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none'
|
|
65
|
+
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' },
|
|
66
66
|
): Promise<TerminalInputDelivery>;
|
|
67
67
|
/** Persist an opaque application context association on a session owned by this host. */
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
ownsInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
71
|
-
releaseInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
68
|
+
/** Every pane this host controls takes its keyboard and keeps no pane-scoped option of supercode's; answers the panes it changed. */
|
|
69
|
+
openPanes(): Promise<string[]>;
|
|
72
70
|
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
73
71
|
issueAttachment(sessionId: string, options?: { mode?: 'observe' | 'control' }): TerminalAttachmentGrant;
|
|
74
72
|
attachSocket(
|
|
@@ -91,14 +89,9 @@ export class PaneServerHost {
|
|
|
91
89
|
listSessions(): Promise<TerminalSession[]>;
|
|
92
90
|
/** Requires a compatible pane server; an unsupported operation rejects. */
|
|
93
91
|
hasLiveContext(contextKey: string): Promise<boolean>;
|
|
94
|
-
|
|
95
|
-
createSession(input: { command: TmuxCommand; contextKey?: string; cwd?: string; env?: Record<string, string>; name?: string; requireInputOwnership?: boolean }): Promise<TerminalSession>;
|
|
92
|
+
createSession(input: { command: TmuxCommand; contextKey?: string; cwd?: string; env?: Record<string, string>; name?: string }): Promise<TerminalSession>;
|
|
96
93
|
capture(sessionId: string, options?: { lines?: number; preserveEscapes?: boolean }): Promise<string>;
|
|
97
|
-
sendInput(sessionId: string, data: string, options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none'
|
|
98
|
-
/** Exclusive input ownership, retained by the pane host across client restart. */
|
|
99
|
-
acquireInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
100
|
-
ownsInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
101
|
-
releaseInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
94
|
+
sendInput(sessionId: string, data: string, options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' }): Promise<TerminalInputDelivery>;
|
|
102
95
|
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
103
96
|
renameWindow(sessionId: string, title?: string | null): Promise<string>;
|
|
104
97
|
issueAttachment(sessionId: string, options?: { mode?: 'observe' | 'control' }): Promise<TerminalAttachmentGrant>;
|
|
@@ -130,12 +123,8 @@ export class PtyTerminalHost {
|
|
|
130
123
|
sendInput(
|
|
131
124
|
sessionId: string,
|
|
132
125
|
data: string,
|
|
133
|
-
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none'
|
|
126
|
+
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' },
|
|
134
127
|
): Promise<TerminalInputDelivery>;
|
|
135
|
-
/** Exclusive input ownership, retained by the pane host across client restart. */
|
|
136
|
-
acquireInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
137
|
-
ownsInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
138
|
-
releaseInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
139
128
|
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
140
129
|
/** A ConPTY pane has no window to rename; returns its label. */
|
|
141
130
|
renameWindow(sessionId: string, title?: string | null): string;
|
|
@@ -254,8 +243,6 @@ export class TerminalHostError extends Error {
|
|
|
254
243
|
* it as "not ready yet".
|
|
255
244
|
*/
|
|
256
245
|
export function composerReadyForInput(kind: string, screen: string | undefined): boolean | null;
|
|
257
|
-
/** Does the composer hold a typed draft? `screen` is a capture with escapes; null when no composer is on screen. */
|
|
258
|
-
export function composerHoldsDraft(kind: string, screen: string | undefined): boolean | null;
|
|
259
246
|
|
|
260
247
|
export { classifyAttentionSignal, observeLifecycle, gatherLifecycleObservation, createLifecycleStore, snapshotProcessTree, snapshotProcessTreeAsync } from './substrate/session-lifecycle.js';
|
|
261
248
|
export { HARNESS_MARKERS, HARNESS_CHILD_SESSION_MARKERS, isHarnessMarker, withoutHarnessMarkers, dropOwnHarnessMarkers } from './substrate/harness-env.js';
|
|
@@ -277,6 +264,5 @@ export type { TerminalInputDelivery, TmuxCommand, TmuxWindowSize };
|
|
|
277
264
|
|
|
278
265
|
/** The bounded live composer text, or null for an unrecognized screen. */
|
|
279
266
|
export function composerText(kind: string, screen: string | undefined): string | null;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
export function deliverComposerInput(host: TmuxTerminalHost | PtyTerminalHost | PaneServerHost, sessionId: string, kind: string, text: string, options?: { deadlineMs?: number; signal?: AbortSignal }): Promise<{ delivered: boolean; reason?: string; restored?: boolean; cleanup_required?: boolean; cleanup_reason?: string; cleanup_remaining?: string; delivery?: TerminalInputDelivery }>;
|
|
267
|
+
/** Paste and submit, whatever the composer holds. */
|
|
268
|
+
export function deliverComposerInput(host: TmuxTerminalHost | PtyTerminalHost | PaneServerHost, sessionId: string, kind: string, text: string, options?: { deadlineMs?: number; signal?: AbortSignal }): Promise<{ delivered: boolean; reason?: string; path?: string; delivery?: TerminalInputDelivery }>;
|
package/index.mjs
CHANGED
|
@@ -16,7 +16,6 @@ import { openLocalTerminal as defaultOpenLocalTerminal } from './substrate/local
|
|
|
16
16
|
const DEFAULT_ATTACHMENT_TTL_MS = 30_000;
|
|
17
17
|
const DEFAULT_CONTEXT_OPTION = '@supercode-context';
|
|
18
18
|
const DEFAULT_OWNER_OPTION = '@supercode-owner';
|
|
19
|
-
const INPUT_OWNER_OPTION = '@supercode-input-owner';
|
|
20
19
|
const SESSION_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
21
20
|
// A pane should feel like the terminal it is shown in, not like tmux: no status
|
|
22
21
|
// bar, no prefix key taking keystrokes from the program, and no mouse capture,
|
|
@@ -25,11 +24,14 @@ const SESSION_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
25
24
|
// relay clients.) Session-scoped, so other sessions on the same tmux server
|
|
26
25
|
// keep their own settings.
|
|
27
26
|
const TERMINAL_LIKE_SESSION = Object.freeze({ status: 'off', mouse: 'off', prefix: 'None', prefix2: 'None', 'remain-on-exit': 'failed' });
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
27
|
+
// A pane session (p_*) keeps its pane after its program ends however it ended, a clean end included, until the
|
|
28
|
+
// pane's end is in the pane-exit log: the record is written from the dead pane's own status, by the substrate's
|
|
29
|
+
// pane-died hook or by this host (#sessionNames), whichever reads the pane first, and the pane is closed after it.
|
|
30
|
+
// Only p_* sessions: they are the ones the hook and the host close.
|
|
31
|
+
const PANE_SESSION = Object.freeze({ ...TERMINAL_LIKE_SESSION, 'remain-on-exit': 'on' });
|
|
32
|
+
// What tmux draws in a dead pane (tmux 3.3 and later; before it the line is tmux's own and cannot be set): nothing
|
|
33
|
+
// for a clean end, whose pane is about to close, and tmux's own default line for a failed one.
|
|
34
|
+
const PANE_DEAD_LINE = '#{?#{&&:#{==:#{pane_dead_status},0},#{==:#{pane_dead_signal},}},,Pane is dead (#{?#{!=:#{pane_dead_status},},status #{pane_dead_status},}#{?#{!=:#{pane_dead_signal},},signal #{pane_dead_signal},}#, #{t:pane_dead_time})}';
|
|
33
35
|
|
|
34
36
|
function runTmuxInput(socket, args, input = null) {
|
|
35
37
|
return new Promise((resolve, reject) => {
|
|
@@ -173,6 +175,8 @@ export class TerminalWebSocketBridge {
|
|
|
173
175
|
|
|
174
176
|
export class TmuxTerminalHost {
|
|
175
177
|
#allowUnownedControl;
|
|
178
|
+
#answered = false;
|
|
179
|
+
#dead = new Map();
|
|
176
180
|
#generation = 0;
|
|
177
181
|
#listing = null;
|
|
178
182
|
#attachments = new Map();
|
|
@@ -219,6 +223,12 @@ export class TmuxTerminalHost {
|
|
|
219
223
|
/** Complete context observation for callers deciding whether a pane has ended. */
|
|
220
224
|
hasLiveContext(contextKey) {
|
|
221
225
|
assertContextKey(contextKey);
|
|
226
|
+
// A pane session whose program has ended is closed first, so tmux's answer is of the sessions something runs in.
|
|
227
|
+
// When the server answered and none is left, that is the complete reading: a server closing its last pane has
|
|
228
|
+
// no session to list and is not yet gone. A server that could not be read here is tmux's own reading below.
|
|
229
|
+
let names = null;
|
|
230
|
+
try { names = this.#sessionNames(); } catch { /* read below */ }
|
|
231
|
+
if (names && !names.length && this.#answered) return Promise.resolve(false);
|
|
222
232
|
return this.#tmux.hasSessionContextAsync(this.#contextOption, contextKey, this.#socket);
|
|
223
233
|
}
|
|
224
234
|
|
|
@@ -232,6 +242,9 @@ export class TmuxTerminalHost {
|
|
|
232
242
|
this.#catalog.delete(id);
|
|
233
243
|
}
|
|
234
244
|
}
|
|
245
|
+
// No session, nothing more to ask: a server left with none is on its way out, and answers the readings below
|
|
246
|
+
// "no current target" until it is gone.
|
|
247
|
+
if (!names.length) return [];
|
|
235
248
|
const panes = this.#panes();
|
|
236
249
|
const [contextKeys, sizes] = await Promise.all([
|
|
237
250
|
this.#tmux.sessionOptionValuesAsync(this.#contextOption, this.#socket),
|
|
@@ -242,7 +255,10 @@ export class TmuxTerminalHost {
|
|
|
242
255
|
const id = this.#catalogId(name);
|
|
243
256
|
const pane = panes.find((candidate) => candidate.session === name && candidate.window === 0 && candidate.pane === 0);
|
|
244
257
|
const size = sizes.get(name);
|
|
258
|
+
const dead = this.#dead.get(name);
|
|
245
259
|
return Object.freeze({
|
|
260
|
+
// a session of another name whose program failed: tmux keeps its pane for its last screen, and nothing runs in it
|
|
261
|
+
...(dead ? { dead: true, exitCode: dead.exitCode } : {}),
|
|
246
262
|
activeCommand: pane?.currentCommand ?? null,
|
|
247
263
|
rootPid: pane?.rootPid ?? null,
|
|
248
264
|
contextKey: contextKeys.get(name) ?? null,
|
|
@@ -281,7 +297,8 @@ export class TmuxTerminalHost {
|
|
|
281
297
|
ownerOption: this.#ownerOption,
|
|
282
298
|
panes: 1,
|
|
283
299
|
sessionOptions: input.contextKey ? { [this.#contextOption]: input.contextKey } : {},
|
|
284
|
-
settings: TERMINAL_LIKE_SESSION,
|
|
300
|
+
settings: name.startsWith('p_') ? PANE_SESSION : TERMINAL_LIKE_SESSION,
|
|
301
|
+
optionalSettings: name.startsWith('p_') ? { 'remain-on-exit-format': PANE_DEAD_LINE } : {},
|
|
285
302
|
socket: this.#socket,
|
|
286
303
|
});
|
|
287
304
|
// Raised once tmux has the session: a listing started before then cannot be the one that answers below.
|
|
@@ -319,8 +336,6 @@ export class TmuxTerminalHost {
|
|
|
319
336
|
if (!this.#canControl(entry.name)) {
|
|
320
337
|
throw new TerminalHostError('input_not_allowed', 'This host does not own that tmux session.');
|
|
321
338
|
}
|
|
322
|
-
if (options.inputOwner) return this.#sendOwnedInput(entry.name, data, options);
|
|
323
|
-
if (await this.#inputOwned(entry.name)) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
324
339
|
return await this.#tmux.sendInputAsync({
|
|
325
340
|
data,
|
|
326
341
|
deadlineMs: options.deadlineMs,
|
|
@@ -330,58 +345,6 @@ export class TmuxTerminalHost {
|
|
|
330
345
|
});
|
|
331
346
|
}
|
|
332
347
|
|
|
333
|
-
async #inputOwned(name, owner = null) {
|
|
334
|
-
const state = await runTmuxInput(this.#socket, ['display-message', '-p', '-t', `${name}:0.0`, `#{${INPUT_OWNER_OPTION}}:#{pane_input_off}`]);
|
|
335
|
-
return owner == null ? state.split(':')[0] !== '' : state === `${owner}:1`;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
async acquireInput(sessionId, owner) {
|
|
339
|
-
inputOwner(owner);
|
|
340
|
-
const entry = this.#entry(sessionId);
|
|
341
|
-
if (!this.#canControl(entry.name)) throw new TerminalHostError('input_not_allowed', 'This host does not own that pane.');
|
|
342
|
-
if (await this.#inputOwned(entry.name, owner)) return true;
|
|
343
|
-
const target = `${entry.name}:0.0`;
|
|
344
|
-
const condition = `#{&&:#{==:#{${INPUT_OWNER_OPTION}},},#{==:#{pane_input_off},0}}`;
|
|
345
|
-
const command = `set-option -p -t ${target} ${INPUT_OWNER_OPTION} ${owner} ; select-pane -d -t ${target} ; display-message -p acquired`;
|
|
346
|
-
return await runTmuxInput(this.#socket, ['if-shell', '-F', '-t', target, condition, command, 'display-message -p busy']) === 'acquired';
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
ownsInput(sessionId, owner) { return this.#inputOwned(this.#entry(sessionId).name, inputOwner(owner)); }
|
|
350
|
-
|
|
351
|
-
async releaseInput(sessionId, owner) {
|
|
352
|
-
inputOwner(owner);
|
|
353
|
-
const target = `${this.#entry(sessionId).name}:0.0`;
|
|
354
|
-
const condition = `#{==:#{${INPUT_OWNER_OPTION}},${owner}}`;
|
|
355
|
-
const command = `set-option -pu -t ${target} ${INPUT_OWNER_OPTION} ; select-pane -e -t ${target} ; display-message -p released`;
|
|
356
|
-
return await runTmuxInput(this.#socket, ['if-shell', '-F', '-t', target, condition, command, 'display-message -p other']) === 'released';
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async #sendOwnedInput(name, data, options) {
|
|
360
|
-
const owner = inputOwner(options.inputOwner), target = `${name}:0.0`;
|
|
361
|
-
const write = async command => {
|
|
362
|
-
if (options.deadlineMs != null && Date.now() >= options.deadlineMs) throw new TerminalHostError('input_expired', 'Terminal input expired before delivery.');
|
|
363
|
-
const condition = `#{&&:#{==:#{${INPUT_OWNER_OPTION}},${owner}},#{==:#{pane_input_off},1}}`;
|
|
364
|
-
// The command queue does not yield between enable, write and disable:
|
|
365
|
-
// attached clients cannot replace a paste while it waits for its badge.
|
|
366
|
-
const guarded = `select-pane -e -t ${target} ; ${command} ; select-pane -d -t ${target} ; display-message -p sent`;
|
|
367
|
-
if (await runTmuxInput(this.#socket, ['if-shell', '-F', '-t', target, condition, guarded, 'display-message -p other']) !== 'sent')
|
|
368
|
-
throw new TerminalHostError('input_owned', 'Input ownership changed; no input was sent.');
|
|
369
|
-
};
|
|
370
|
-
const submitted = defaultTmux.submittedTextFromInput(data);
|
|
371
|
-
if (submitted != null && submitted !== '') {
|
|
372
|
-
const buffer = `supercode-input-${randomUUID()}`;
|
|
373
|
-
await runTmuxInput(this.#socket, ['load-buffer', '-b', buffer, '-'], submitted);
|
|
374
|
-
try { await write(`paste-buffer -d -p -b ${buffer} -t ${target}`); }
|
|
375
|
-
finally { await runTmuxInput(this.#socket, ['delete-buffer', '-b', buffer]).catch(() => {}); }
|
|
376
|
-
if (options.submitMode !== 'none') { await sleep(SINGLE_SUBMIT_DELAY_MS); await write(`send-keys -t ${target} Enter`); }
|
|
377
|
-
return 'submitted-paste';
|
|
378
|
-
}
|
|
379
|
-
if (submitted === '' && options.submitMode === 'none') return 'submitted-line';
|
|
380
|
-
const bytes = Buffer.from(data.replace(/\n/gu, '\r'));
|
|
381
|
-
if (bytes.length) await write(`send-keys -H -t ${target} ${[...bytes].map(byte => byte.toString(16)).join(' ')}`);
|
|
382
|
-
return submitted === '' ? 'submitted-line' : 'keystream';
|
|
383
|
-
}
|
|
384
|
-
|
|
385
348
|
async bindContext(sessionId, contextKey) {
|
|
386
349
|
assertContextKey(contextKey);
|
|
387
350
|
const entry = this.#entry(sessionId);
|
|
@@ -427,7 +390,6 @@ export class TmuxTerminalHost {
|
|
|
427
390
|
const attachment = await attach({
|
|
428
391
|
authorizeInput: async () => {
|
|
429
392
|
if (grant.mode !== 'control') throw new TerminalHostError('input_not_allowed', 'This is a read-only terminal attachment.');
|
|
430
|
-
if (await this.#inputOwned(grant.name)) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
431
393
|
},
|
|
432
394
|
errorLabel: 'supercode',
|
|
433
395
|
readOnly: grant.mode === 'observe',
|
|
@@ -476,6 +438,9 @@ export class TmuxTerminalHost {
|
|
|
476
438
|
throw new TerminalHostError('close_not_allowed', 'This host does not own that tmux session.');
|
|
477
439
|
}
|
|
478
440
|
this.#tmux.killSessionProcessTree({ session: entry.name, socket: this.#socket });
|
|
441
|
+
// The close's own record: tmux's session-closed hook writes it too, but not when this session was the server's
|
|
442
|
+
// last (the server exits before the hook's run-shell has started).
|
|
443
|
+
if (entry.name.startsWith('p_') && !paneExitRecorded(entry.name)) recordPaneExit(entry.name, 0, null, null, hookStamp());
|
|
479
444
|
this.#generation += 1;
|
|
480
445
|
this.#catalog.delete(sessionId);
|
|
481
446
|
this.#idByName.delete(entry.name);
|
|
@@ -484,6 +449,25 @@ export class TmuxTerminalHost {
|
|
|
484
449
|
}
|
|
485
450
|
}
|
|
486
451
|
|
|
452
|
+
/** Every pane this host controls takes its keyboard and keeps no pane-scoped option of supercode's (what supercode
|
|
453
|
+
* keeps of a pane is its session's options). A daemon calls it once as it starts; answers the panes it changed. */
|
|
454
|
+
async openPanes() {
|
|
455
|
+
const changed = [];
|
|
456
|
+
for (const name of this.#sessionNames()) {
|
|
457
|
+
if (!this.#canControl(name)) continue;
|
|
458
|
+
const target = `${name}:0.0`;
|
|
459
|
+
try {
|
|
460
|
+
const off = await runTmuxInput(this.#socket, ['display-message', '-p', '-t', target, '#{pane_input_off}']) === '1';
|
|
461
|
+
const options = (await runTmuxInput(this.#socket, ['show-options', '-p', '-t', target])).split('\n')
|
|
462
|
+
.map((line) => line.split(' ')[0]).filter((key) => key.startsWith('@supercode-'));
|
|
463
|
+
if (off) await runTmuxInput(this.#socket, ['select-pane', '-e', '-t', target]);
|
|
464
|
+
for (const key of options) await runTmuxInput(this.#socket, ['set-option', '-pu', '-t', target, key]);
|
|
465
|
+
if (off || options.length) changed.push(name);
|
|
466
|
+
} catch { /* the pane ended meanwhile */ }
|
|
467
|
+
}
|
|
468
|
+
return changed;
|
|
469
|
+
}
|
|
470
|
+
|
|
487
471
|
dispose() {
|
|
488
472
|
for (const attachment of [...this.#liveAttachments]) attachment.dispose();
|
|
489
473
|
this.#attachments.clear();
|
|
@@ -533,13 +517,43 @@ export class TmuxTerminalHost {
|
|
|
533
517
|
}
|
|
534
518
|
}
|
|
535
519
|
|
|
520
|
+
/**
|
|
521
|
+
* The sessions something runs in. A session whose pane's program has ended is still a session to tmux, kept by
|
|
522
|
+
* `remain-on-exit`; it is never answered as one here, to any caller (a listing, a liveness reading, an attachment,
|
|
523
|
+
* input, a close, a name's reuse). A pane session (p_*) in that state is finished off here as the pane-died hook
|
|
524
|
+
* would: its end written to the pane-exit log when no line names the pane yet (the screen with it when it failed),
|
|
525
|
+
* then the pane closed. So neither the record nor the pane's removal waits on a run-shell inside the server, which
|
|
526
|
+
* a server without `tmux` on its PATH cannot run. A session of another name is not this host's to close (tmux keeps
|
|
527
|
+
* its failed pane for its last screen): it stays listed, marked `dead` with its `exitCode`, as the ConPTY host
|
|
528
|
+
* marks one.
|
|
529
|
+
*/
|
|
536
530
|
#sessionNames() {
|
|
531
|
+
let states;
|
|
532
|
+
this.#answered = false;
|
|
537
533
|
try {
|
|
538
|
-
|
|
534
|
+
states = this.#tmux.listSessionStates(this.#socket);
|
|
535
|
+
this.#answered = true;
|
|
539
536
|
} catch (error) {
|
|
540
|
-
if (isEmptyServerError(error))
|
|
541
|
-
|
|
537
|
+
if (!isEmptyServerError(error)) throw error;
|
|
538
|
+
states = [];
|
|
539
|
+
}
|
|
540
|
+
const names = [];
|
|
541
|
+
this.#dead.clear();
|
|
542
|
+
for (const state of states) {
|
|
543
|
+
if (!state.dead) names.push(state.name);
|
|
544
|
+
else if (!state.name.startsWith('p_')) { names.push(state.name); this.#dead.set(state.name, { exitCode: state.exitCode }); }
|
|
545
|
+
else {
|
|
546
|
+
if (!paneExitRecorded(state.name)) {
|
|
547
|
+
const failed = state.exitCode !== 0 || Boolean(state.signal);
|
|
548
|
+
let screen = null;
|
|
549
|
+
if (failed) { try { screen = this.#tmux.capturePane({ lines: 200, preserveEscapes: true, socket: this.#socket, target: state.paneId }); } catch { /* the pane is gone */ } }
|
|
550
|
+
recordPaneExit(state.name, state.exitCode, state.signal, screen, hookStamp());
|
|
551
|
+
}
|
|
552
|
+
this.#tmux.closeDeadPane({ paneId: state.paneId, session: state.name, socket: this.#socket });
|
|
553
|
+
this.#generation += 1;
|
|
554
|
+
}
|
|
542
555
|
}
|
|
556
|
+
return names;
|
|
543
557
|
}
|
|
544
558
|
}
|
|
545
559
|
|
|
@@ -620,9 +634,23 @@ for (const event of ['end', 'close', 'error']) process.stdin.on(event, sweep);
|
|
|
620
634
|
* signal=…` when it failed), under SUPERCODE_HOME or ~/.config/supercode: the machine daemon tells a clean finish from
|
|
621
635
|
* a failure by it, and a ConPTY pane that ended cleanly is otherwise gone without a trace.
|
|
622
636
|
*/
|
|
623
|
-
|
|
637
|
+
/** Whether the pane-exit log (either home's) holds a line of this pane's end. */
|
|
638
|
+
function paneExitRecorded(pane) {
|
|
639
|
+
for (const root of new Set([process.env.SUPERCODE_HOME, join(homedir(), '.config', 'supercode')].filter(Boolean))) {
|
|
640
|
+
try { if (readFileSync(join(root, 'pane-exits.log'), 'utf8').split('\n').some((line) => line.endsWith(` ${pane} closed`) || line.includes(` ${pane} died `))) return true; }
|
|
641
|
+
catch { /* no log there */ }
|
|
642
|
+
}
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Now as the tmux hooks stamp a line (`date +%FT%T`, the machine's own time): one log's lines are ordered by it. */
|
|
647
|
+
function hookStamp() {
|
|
648
|
+
const now = new Date(), two = (value) => String(value).padStart(2, '0');
|
|
649
|
+
return `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())}T${two(now.getHours())}:${two(now.getMinutes())}:${two(now.getSeconds())}`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function recordPaneExit(contextKey, exitCode, signal, screen = null, at = new Date().toISOString().slice(0, 19)) {
|
|
624
653
|
if (!contextKey) return;
|
|
625
|
-
const at = new Date().toISOString().slice(0, 19);
|
|
626
654
|
const line = exitCode === 0 && !signal ? `${at} ${contextKey} closed\n` : `${at} ${contextKey} died status=${exitCode ?? ''} signal=${signal || ''}\n`;
|
|
627
655
|
try { appendFileSync(join(process.env.SUPERCODE_HOME || join(homedir(), '.config', 'supercode'), 'pane-exits.log'), line); } catch { /* no log is no worse than before */ }
|
|
628
656
|
if ((exitCode !== 0 || signal) && typeof screen === 'string' && /^p_[A-Za-z0-9_-]+$/.test(contextKey)) {
|
|
@@ -769,12 +797,10 @@ export class PtyTerminalHost {
|
|
|
769
797
|
|
|
770
798
|
async sendInput(sessionId, data, options = {}) {
|
|
771
799
|
const entry = this.#live(sessionId);
|
|
772
|
-
if ((entry.inputOwner ?? null) !== (options.inputOwner ?? null)) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
773
800
|
if (typeof data !== 'string') throw new Error('Input data must be a string.');
|
|
774
801
|
const deadline = options.deadlineMs;
|
|
775
802
|
const write = (bytes) => {
|
|
776
803
|
if (deadline !== undefined && deadline - Date.now() <= 0) throw new Error('Terminal input expired before delivery.');
|
|
777
|
-
if ((entry.inputOwner ?? null) !== (options.inputOwner ?? null)) throw new TerminalHostError('input_owned', 'Input ownership changed; no input was sent.');
|
|
778
804
|
this.#live(entry.id).child.write(bytes);
|
|
779
805
|
};
|
|
780
806
|
const submit = async () => {
|
|
@@ -799,18 +825,6 @@ export class PtyTerminalHost {
|
|
|
799
825
|
return 'keystream';
|
|
800
826
|
}
|
|
801
827
|
|
|
802
|
-
acquireInput(sessionId, owner) {
|
|
803
|
-
const entry = this.#live(sessionId); inputOwner(owner);
|
|
804
|
-
if (entry.inputOwner && entry.inputOwner !== owner) return false;
|
|
805
|
-
entry.inputOwner = owner; return true;
|
|
806
|
-
}
|
|
807
|
-
ownsInput(sessionId, owner) { return this.#live(sessionId).inputOwner === inputOwner(owner); }
|
|
808
|
-
releaseInput(sessionId, owner) {
|
|
809
|
-
const entry = this.#live(sessionId); inputOwner(owner);
|
|
810
|
-
if (entry.inputOwner !== owner) return false;
|
|
811
|
-
entry.inputOwner = null; return true;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
828
|
async bindContext(sessionId, contextKey) {
|
|
815
829
|
assertContextKey(contextKey);
|
|
816
830
|
this.#entry(sessionId).contextKey = contextKey;
|
|
@@ -886,7 +900,6 @@ export class PtyTerminalHost {
|
|
|
886
900
|
socket.close(1008, 'terminal access ended');
|
|
887
901
|
return;
|
|
888
902
|
}
|
|
889
|
-
if (entry.inputOwner) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
890
903
|
if (this.#sessions.has(entry.id) && !entry.exit) entry.child.write(message.data);
|
|
891
904
|
} catch (error) {
|
|
892
905
|
refuse(error);
|
|
@@ -1128,12 +1141,9 @@ export class PaneServerHost {
|
|
|
1128
1141
|
|
|
1129
1142
|
listSessions() { return this.#request('listSessions', []); }
|
|
1130
1143
|
hasLiveContext(contextKey) { assertContextKey(contextKey); return this.#request('hasLiveContext', [contextKey]); }
|
|
1131
|
-
createSession(input) { return this.#request('createSession', [input]
|
|
1144
|
+
createSession(input) { return this.#request('createSession', [input]); }
|
|
1132
1145
|
capture(sessionId, options = {}) { return this.#request('capture', [sessionId, options]); }
|
|
1133
1146
|
sendInput(sessionId, data, options = {}) { return this.#request('sendInput', [sessionId, data, options]); }
|
|
1134
|
-
acquireInput(sessionId, owner) { return this.#request('acquireInput', [sessionId, owner]); }
|
|
1135
|
-
ownsInput(sessionId, owner) { return this.#request('ownsInput', [sessionId, owner]); }
|
|
1136
|
-
releaseInput(sessionId, owner) { return this.#request('releaseInput', [sessionId, owner]); }
|
|
1137
1147
|
closeSession(sessionId) { return this.#request('closeSession', [sessionId]); }
|
|
1138
1148
|
renameWindow(sessionId, title) { return this.#request('renameWindow', [sessionId, title]); }
|
|
1139
1149
|
issueAttachment(sessionId, options = {}) { return this.#request('issueAttachment', [sessionId, options]); }
|
|
@@ -1187,26 +1197,7 @@ export class PaneServerHost {
|
|
|
1187
1197
|
for (let attempt = 0; ; attempt += 1) {
|
|
1188
1198
|
const connection = await this.#connect();
|
|
1189
1199
|
try {
|
|
1190
|
-
|
|
1191
|
-
// The installed client can be newer than the server that still holds
|
|
1192
|
-
// this machine's panes. Check its actual ownership door before any
|
|
1193
|
-
// process starts, on the same connection that will create the pane.
|
|
1194
|
-
// null cannot identify a PtyTerminalHost session: session_not_found
|
|
1195
|
-
// means the vendor's ownership operation ran without touching a pane.
|
|
1196
|
-
try {
|
|
1197
|
-
await this.#call(connection, 'ownsInput', [null, 'capability_check']);
|
|
1198
|
-
throw new TerminalHostError('input_ownership_unconfirmed', 'The pane server did not confirm its input ownership door; no pane was created.');
|
|
1199
|
-
} catch (error) {
|
|
1200
|
-
if (error?.code === 'unsupported') {
|
|
1201
|
-
throw new TerminalHostError('input_ownership_unsupported', 'The running pane server does not support input ownership required for this first message; no pane was created. Update the pane server through its owner before retrying.');
|
|
1202
|
-
}
|
|
1203
|
-
if (error?.code !== 'session_not_found') throw error;
|
|
1204
|
-
}
|
|
1205
|
-
if (connection.destroyed || this.#connection !== connection) {
|
|
1206
|
-
throw new TerminalHostError('pane_server_unreachable', 'The pane server connection changed during input ownership admission; no pane was created.');
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
return await this.#call(connection, op, args, { ...extra, requireInputOwnership: undefined });
|
|
1200
|
+
return await this.#call(connection, op, args, extra);
|
|
1210
1201
|
} catch (error) {
|
|
1211
1202
|
if (error?.code !== 'pane_server_restarting' || attempt >= 20) throw error;
|
|
1212
1203
|
await sleep(250);
|
|
@@ -1839,7 +1830,7 @@ function nativeAgentSessionId(harness, sessionId) {
|
|
|
1839
1830
|
const COMPOSER_READY = Object.freeze({
|
|
1840
1831
|
// codex's placeholder text IS the empty-composer state, which is exactly the
|
|
1841
1832
|
// question here: not "is there a caret" but "is it waiting for a first
|
|
1842
|
-
// message".
|
|
1833
|
+
// message".
|
|
1843
1834
|
codex: (screen) => screen.includes('› Ask Codex to do anything'),
|
|
1844
1835
|
// Claude draws `❯`, or on Windows `>` and a no-break space (captured from a ConPTY pane on volter-desktop).
|
|
1845
1836
|
'claude-code': (screen) => composerLine(screen, /^[❯>](?:\s|$)/u),
|
|
@@ -1887,52 +1878,11 @@ const ANSI_SEQUENCE = /\u001b\[[0-?]*[ -\/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0
|
|
|
1887
1878
|
*/
|
|
1888
1879
|
export function composerReadyForInput(kind, screen) {
|
|
1889
1880
|
if (!Object.hasOwn(COMPOSER_READY, kind)) return null;
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
// read (no composerText for them), so for these two `true` means a composer present, not an empty one.
|
|
1881
|
+
// `true` means a composer is drawn, whatever it holds.
|
|
1882
|
+
if (kind === 'claude-code' || kind === 'codex') return composerText(kind, screen) !== null;
|
|
1893
1883
|
return COMPOSER_READY[kind](String(screen ?? ''));
|
|
1894
1884
|
}
|
|
1895
1885
|
|
|
1896
|
-
/**
|
|
1897
|
-
* Does the composer on this screen hold text the person typed (a draft)?
|
|
1898
|
-
* `screen` is a capture WITH escapes, because an empty composer may show a
|
|
1899
|
-
* placeholder, and only its styling (dim or grey) tells it from a draft.
|
|
1900
|
-
*
|
|
1901
|
-
* true the last composer line holds typed text
|
|
1902
|
-
* false the composer is on screen and empty (or shows only a placeholder)
|
|
1903
|
-
* null no composer is on screen (a menu, a dialog, a program still
|
|
1904
|
-
* booting), or the kind has no composer pattern
|
|
1905
|
-
*
|
|
1906
|
-
* The caret is the kind's own, the one `composerReadyForInput` reads.
|
|
1907
|
-
*/
|
|
1908
|
-
export function composerHoldsDraft(kind, screen) {
|
|
1909
|
-
if (kind === 'claude-code' || kind === 'codex') {
|
|
1910
|
-
const text = composerText(kind, screen);
|
|
1911
|
-
return text === null ? null : text.length > 0;
|
|
1912
|
-
}
|
|
1913
|
-
const carets = COMPOSER_CARETS[kind];
|
|
1914
|
-
if (!carets) return null;
|
|
1915
|
-
const lines = String(screen ?? '').split('\n');
|
|
1916
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1917
|
-
const cells = styledCells(lines[index]);
|
|
1918
|
-
const text = cells.map((cell) => cell.char).join('');
|
|
1919
|
-
const start = text.length - text.trimStart().length;
|
|
1920
|
-
const caret = carets.find((candidate) => text.trimStart().startsWith(candidate));
|
|
1921
|
-
if (!caret) continue;
|
|
1922
|
-
const typed = cells
|
|
1923
|
-
.slice(start + caret.length)
|
|
1924
|
-
.filter((cell) => !cell.placeholder)
|
|
1925
|
-
.map((cell) => cell.char)
|
|
1926
|
-
.join('')
|
|
1927
|
-
.trim();
|
|
1928
|
-
if (kind === 'codex' && typed.startsWith('Ask Codex to do anything')) return false;
|
|
1929
|
-
// Claude's own suggestion (`Try "fix typecheck errors"`), when the capture carries no styling to mark it dim.
|
|
1930
|
-
if (kind === 'claude-code' && /^Try ".*"$/u.test(typed)) return false;
|
|
1931
|
-
return typed.length > 0;
|
|
1932
|
-
}
|
|
1933
|
-
return null;
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
1886
|
/** Read only a bounded, live Claude/Codex composer, never a prompt in scrollback.
|
|
1937
1887
|
* Unknown layouts and collapsed pastes are deliberately unconfirmed.
|
|
1938
1888
|
*/
|
|
@@ -1945,8 +1895,7 @@ export function composerText(kind, screen) {
|
|
|
1945
1895
|
// Codex's status row is `model · cwd` plus whatever else it has to say (a goal's state, a warning count). On a row
|
|
1946
1896
|
// too narrow for all of it Codex cuts the cwd down to its ellipsis (`· … Goal stalled (/goal resume)`, `· ~… Goal
|
|
1947
1897
|
// paused (/goal resume)`, both read from 80-column panes on yuerans, 2026-10-10), and a row whose cwd was required
|
|
1948
|
-
// to open with a path root read as no footer: the idle composer above it read as absent
|
|
1949
|
-
// waited as `no-composer` until it expired.
|
|
1898
|
+
// to open with a path root read as no footer: the idle composer above it read as absent.
|
|
1950
1899
|
const footer = /^\s*(?:.*\btab to queue (?:a )?message\b.*$|\? for shortcuts\b|⚠\s+\d+\s+warnings?\s+·\s+f2 to view\s*$|[^\n]+ · (?:~?…|~(?:[\\/]|(?=\s|$))|[\\/]|[A-Z]:[\\/])[^\n]*$|.*← for agents\s*$|.*\bcontext(?:\s+\d+%)?\s+left\b)/iu;
|
|
1951
1900
|
const caret = kind === 'codex' ? /^\s*›(?:\s|$)/u : /^\s*[❯>](?:\s|$)/u;
|
|
1952
1901
|
for (let row = plain.length - 1; row >= 0; row -= 1) {
|
|
@@ -1974,158 +1923,31 @@ export function composerText(kind, screen) {
|
|
|
1974
1923
|
return null;
|
|
1975
1924
|
}
|
|
1976
1925
|
|
|
1977
|
-
/**
|
|
1978
|
-
*
|
|
1979
|
-
* whitespace within rows: a changed or partially pasted draft stays unconfirmed.
|
|
1980
|
-
*/
|
|
1981
|
-
function composerMatches(value, wanted) {
|
|
1982
|
-
if (value === null) return false;
|
|
1983
|
-
let offsets = new Set([0]);
|
|
1984
|
-
const rows = value.split('\n');
|
|
1985
|
-
for (let row = 0; row < rows.length; row += 1) {
|
|
1986
|
-
const next = new Set();
|
|
1987
|
-
for (const offset of offsets) {
|
|
1988
|
-
if (!wanted.startsWith(rows[row], offset)) continue;
|
|
1989
|
-
const end = offset + rows[row].length;
|
|
1990
|
-
if (row === rows.length - 1) next.add(end);
|
|
1991
|
-
else {
|
|
1992
|
-
next.add(end);
|
|
1993
|
-
if (wanted[end] === ' ' || wanted[end] === '\n') next.add(end + 1);
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
offsets = next;
|
|
1997
|
-
}
|
|
1998
|
-
return offsets.has(wanted.length);
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
/** Claude collapses a long single-line paste to `[Pasted text #N]` and a multi-line paste to
|
|
2002
|
-
* `[Pasted text #N +K lines]`. The placeholder (as shown) when it can stand for `text`: K is the pasted text's line breaks (the
|
|
2003
|
-
* trimmed text's, or the sent payload's with its closing newline). Which paste it is, #N, only the paste itself
|
|
2004
|
-
* tells: a caller holds the placeholder its own paste produced and accepts no other. */
|
|
2005
|
-
function pastePlaceholder(composer, text) {
|
|
2006
|
-
const shown=String(composer??'').trim();
|
|
2007
|
-
const placeholder=/^\[Pasted text #\d+(?: \+(\d+) lines?)?\]$/u.exec(shown);
|
|
2008
|
-
if(!placeholder)return null;
|
|
2009
|
-
const breaks=value=>(value.match(/\n/gu)??[]).length;
|
|
2010
|
-
return (placeholder[1]===undefined?breaks(String(text).trim())===0:
|
|
2011
|
-
[breaks(String(text).trim()),breaks(`${text}\n`)].includes(Number(placeholder[1])))?shown:null;
|
|
2012
|
-
}
|
|
2013
|
-
|
|
2014
|
-
/** Undo only text this input wrote, including when a turn started meanwhile.
|
|
2015
|
-
* Never Enter, Escape, or Ctrl-C: restoration must not submit or stop work.
|
|
2016
|
-
* `pasted`: the `[Pasted text #N …]` placeholder this input's own paste produced. Only that one is cleared: another
|
|
2017
|
-
* of the same size is a person's own paste. */
|
|
2018
|
-
export async function restoreComposerInput(host, sessionId, kind, text, { pasted = null, inputOwner: owner = null } = {}) {
|
|
2019
|
-
const read=async()=>composerText(kind,await host.capture(sessionId,{preserveEscapes:true}));
|
|
2020
|
-
let remaining=await read();
|
|
2021
|
-
if(remaining==='')return {restored:true};
|
|
2022
|
-
if(!composerMatches(remaining,String(text).trim())&&!(pasted&&remaining.trim()===pasted))return {restored:false,reason:'composer-changed-or-unreadable'};
|
|
2023
|
-
const lines=remaining.split('\n').length+2;
|
|
2024
|
-
for(let line=0;line<lines;line++) {
|
|
2025
|
-
if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
|
|
2026
|
-
await host.sendInput(sessionId,'\u0005\u0015',{submitMode:'none',...(owner ? { inputOwner: owner } : {})});
|
|
2027
|
-
await sleep(100);
|
|
2028
|
-
let after=await read();
|
|
2029
|
-
if(after==='')return {restored:true};
|
|
2030
|
-
if(after===null||!remaining.includes(after))return {restored:false,reason:'composer-changed',remaining};
|
|
2031
|
-
remaining=after;
|
|
2032
|
-
if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
|
|
2033
|
-
await host.sendInput(sessionId,'\u007f',{submitMode:'none',...(owner ? { inputOwner: owner } : {})});
|
|
2034
|
-
await sleep(100);after=await read();
|
|
2035
|
-
if(after==='')return {restored:true};
|
|
2036
|
-
if(after===null||!remaining.replace(/\n/g,'').includes(after.replace(/\n/g,'')))return {restored:false,reason:'composer-changed',remaining};
|
|
2037
|
-
remaining=after;
|
|
2038
|
-
}
|
|
2039
|
-
return {restored:false,reason:'restore-unconfirmed',remaining};
|
|
2040
|
-
}
|
|
2041
|
-
|
|
2042
|
-
/** Paste only into a stable idle composer. A failed paste is restored; an
|
|
2043
|
-
* unreadable/concurrently changed composer returns an explicit repair obligation. */
|
|
1926
|
+
/** Paste `text` into the pane's composer and submit it, whatever the composer holds. The composer is read afterwards
|
|
1927
|
+
* only to see the paste leave it: an Enter that arrived before the paste was drawn is pressed again. */
|
|
2044
1928
|
export async function deliverComposerInput(host, sessionId, kind, text, { deadlineMs = Date.now() + 10_000, signal } = {}) {
|
|
2045
|
-
|
|
2046
|
-
const
|
|
1929
|
+
const read = async () => composerText(kind, await host.capture(sessionId, { preserveEscapes: true }));
|
|
1930
|
+
const expired = () => signal?.aborted || Date.now() >= deadlineMs;
|
|
1931
|
+
if (!String(text).trim() || /[\x00-\x08\x0b-\x1f\x7f]/u.test(text)) return { delivered: false, reason: 'unsupported-text' };
|
|
1932
|
+
if (expired()) return { delivered: false, reason: 'deadline' };
|
|
2047
1933
|
try {
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
const capture=async()=>{
|
|
2056
|
-
if (owner && !await host.ownsInput(sessionId, owner)) throw new TerminalHostError('input_owned', 'Input ownership changed; the composer is preserved.');
|
|
2057
|
-
return host.capture(sessionId,{preserveEscapes:true});
|
|
2058
|
-
};
|
|
2059
|
-
const inputOptions = owner ? { inputOwner: owner } : {};
|
|
2060
|
-
const read=async()=>composerText(kind,await capture());
|
|
2061
|
-
const expired=()=>signal?.aborted||Date.now()>=deadlineMs;
|
|
2062
|
-
let touched=false;
|
|
2063
|
-
// The placeholder this paste produced: the first one seen after pasting into a composer read empty twice. From then
|
|
2064
|
-
// on only that one (its #N) is this text, and only that one is restored away.
|
|
2065
|
-
let pasted=null;
|
|
2066
|
-
const failed=async (reason, evidence={})=>{
|
|
2067
|
-
if(!touched)return {delivered:false,reason,...evidence};
|
|
2068
|
-
let restored;
|
|
2069
|
-
try{restored=await restoreComposerInput(host,sessionId,kind,text,{pasted,...inputOptions});}catch(error){restored={restored:false,reason:error.message};}
|
|
2070
|
-
return {delivered:false,reason,...evidence,restored:restored.restored,cleanup_required:!restored.restored,cleanup_reason:restored.reason,cleanup_remaining:restored.remaining};
|
|
2071
|
-
};
|
|
2072
|
-
if(expired())return failed('deadline');
|
|
2073
|
-
const wanted=String(text).trim();
|
|
2074
|
-
const holds=composer=>{
|
|
2075
|
-
if(composerMatches(composer,wanted))return true;
|
|
2076
|
-
const placeholder=pastePlaceholder(composer,text);
|
|
2077
|
-
if(placeholder&&touched&&pasted===null)pasted=placeholder;
|
|
2078
|
-
return placeholder!==null&&placeholder===pasted;
|
|
2079
|
-
};
|
|
2080
|
-
if(!wanted||/[\x00-\x08\x0b-\x1f\x7f]/u.test(text))return failed('unsupported-text');
|
|
2081
|
-
for(let check=0;check<2;check++) {
|
|
2082
|
-
const screen=await capture(),before=composerText(kind,screen);
|
|
2083
|
-
if(before!=='')return failed(before===null?'no-composer':'draft');
|
|
2084
|
-
if(check===0)await sleep(200);
|
|
2085
|
-
}
|
|
2086
|
-
if(expired())return failed('deadline');
|
|
2087
|
-
try {
|
|
2088
|
-
touched=true;
|
|
2089
|
-
const delivery=await host.sendInput(sessionId,`${text}\n`,{submitMode:'none',deadlineMs,...inputOptions});
|
|
2090
|
-
let pasteVisible=false;
|
|
2091
|
-
while(!expired()) {
|
|
2092
|
-
await sleep(100);
|
|
2093
|
-
if(holds(await read())){pasteVisible=true;break;}
|
|
2094
|
-
}
|
|
2095
|
-
// A final paint at the deadline still belongs to this paste. Submit it
|
|
2096
|
-
// once; its caller must confirm the native user-message receipt.
|
|
2097
|
-
if(expired()) {
|
|
2098
|
-
if(!signal?.aborted&&holds(await read())) {
|
|
2099
|
-
await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs:Date.now()+1000,...inputOptions});
|
|
2100
|
-
return {delivered:false,reason:'native-transcript-confirmation-required',delivery};
|
|
2101
|
-
}
|
|
2102
|
-
return failed('paste-unconfirmed');
|
|
2103
|
-
}
|
|
2104
|
-
if(!pasteVisible)return failed('paste-unconfirmed');
|
|
2105
|
-
for(let attempt=0;attempt<3;attempt++) {
|
|
2106
|
-
if(expired())return failed('deadline');
|
|
2107
|
-
const screen=await capture();
|
|
2108
|
-
if(!holds(composerText(kind,screen)))return failed('composer-changed');
|
|
2109
|
-
if(expired())return failed('deadline');
|
|
2110
|
-
await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs,...inputOptions});
|
|
2111
|
-
for(let poll=0;poll<10&&!expired();poll++) {
|
|
1934
|
+
const before = await read();
|
|
1935
|
+
const delivery = await host.sendInput(sessionId, `${text}\n`, { submitMode: 'none', deadlineMs });
|
|
1936
|
+
// the paste is drawn a moment after it is written: Enter follows its first sighting, or a second without one
|
|
1937
|
+
for (let poll = 0; poll < 10 && !expired() && await read() === before; poll++) await sleep(100);
|
|
1938
|
+
for (let attempt = 0; attempt < 3 && !expired(); attempt++) {
|
|
1939
|
+
await host.sendInput(sessionId, '\r', { submitMode: 'single', deadlineMs });
|
|
1940
|
+
for (let poll = 0; poll < 10 && !expired(); poll++) {
|
|
2112
1941
|
await sleep(100);
|
|
2113
|
-
|
|
2114
|
-
//
|
|
2115
|
-
|
|
2116
|
-
// queue; changing this SDK result would mark every controller launch
|
|
2117
|
-
// failed even when its input was submitted.
|
|
2118
|
-
if(current==='')return kind === 'claude-code' ? {delivered:true,delivery,path:'composer_submit'} : {delivered:false,reason:'native-transcript-confirmation-required',delivery};
|
|
2119
|
-
if(!holds(current))return failed('submission-unconfirmed');
|
|
1942
|
+
// This helper reports its transport submit to the standalone terminal controller. Teams panes use the
|
|
1943
|
+
// retained native-receipt queue, which confirms from the native transcript.
|
|
1944
|
+
if (!await read()) return kind === 'claude-code' ? { delivered: true, delivery, path: 'composer_submit' } : { delivered: false, reason: 'native-transcript-confirmation-required', delivery };
|
|
2120
1945
|
}
|
|
2121
1946
|
}
|
|
2122
|
-
return
|
|
2123
|
-
}catch(error){return
|
|
1947
|
+
return { delivered: false, reason: expired() ? 'deadline' : 'submit-unconfirmed', delivery };
|
|
1948
|
+
} catch (error) { return { delivered: false, reason: error?.message ?? String(error) }; }
|
|
2124
1949
|
}
|
|
2125
1950
|
|
|
2126
|
-
/** The carets each kind draws at the start of its composer line (Claude on Windows draws `>`). */
|
|
2127
|
-
const COMPOSER_CARETS = Object.freeze({ 'claude-code': ['❯', '>'], codex: ['›'], gemini: ['>'], grok: ['❯'] });
|
|
2128
|
-
|
|
2129
1951
|
/**
|
|
2130
1952
|
* The visible characters of one captured line, each marked as placeholder
|
|
2131
1953
|
* styling (dim, bright black, or a grey foreground) or not. Only SGR
|
package/package.json
CHANGED
|
@@ -32,7 +32,7 @@ function leave() {
|
|
|
32
32
|
setTimeout(() => { server.close(); for (const connection of clients) connection.end(); setTimeout(done, 500); }, 3_000).unref?.();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const OPS = new Set(['listSessions', 'hasLiveContext', 'createSession', 'capture', 'sendInput', '
|
|
35
|
+
const OPS = new Set(['listSessions', 'hasLiveContext', 'createSession', 'capture', 'sendInput', 'closeSession', 'renameWindow', 'issueAttachment', 'bindContext']);
|
|
36
36
|
|
|
37
37
|
function scheduleIdleExit() {
|
|
38
38
|
clearTimeout(idleTimer);
|
package/substrate/tmux.d.ts
CHANGED
|
@@ -25,12 +25,13 @@ export type CreateSessionOptions = {
|
|
|
25
25
|
panes: number;
|
|
26
26
|
sessionOptions?: Record<string, string>;
|
|
27
27
|
settings?: Record<string, string>;
|
|
28
|
+
optionalSettings?: Record<string, string>;
|
|
28
29
|
socket?: string;
|
|
29
30
|
};
|
|
30
31
|
export declare function assertTmux(): void;
|
|
31
32
|
export declare function targetForSession(sessionName: string): string;
|
|
32
33
|
export declare function assertSession(name: string, socket?: string): void;
|
|
33
|
-
export declare function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption, panes, sessionOptions, settings, socket }: CreateSessionOptions): Promise<{
|
|
34
|
+
export declare function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption, panes, sessionOptions, settings, optionalSettings, socket }: CreateSessionOptions): Promise<{
|
|
34
35
|
name: string;
|
|
35
36
|
panes: number;
|
|
36
37
|
}>;
|
|
@@ -49,6 +50,33 @@ export declare function killSessionProcessTree({ session, socket }: {
|
|
|
49
50
|
socket?: string;
|
|
50
51
|
}): void;
|
|
51
52
|
export declare function listTmuxSessionNames(socket?: string): string[];
|
|
53
|
+
export type TmuxSessionState = {
|
|
54
|
+
name: string;
|
|
55
|
+
/** The id (`%N`) of the session's pane. */
|
|
56
|
+
paneId: string;
|
|
57
|
+
/** Whether the pane's program has ended and tmux keeps the pane (`remain-on-exit`). */
|
|
58
|
+
dead: boolean;
|
|
59
|
+
/** The ended program's exit status, when it exited; null when a signal ended it or tmux does not say. */
|
|
60
|
+
exitCode: number | null;
|
|
61
|
+
/** The signal that ended the program, as tmux names it; empty when it exited. */
|
|
62
|
+
signal: string;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Every session with whether its pane's program has ended, in one tmux process. A session whose pane is dead is
|
|
66
|
+
* still a session to tmux (`list-sessions` names it, `has-session` finds it) though nothing runs in it: a caller
|
|
67
|
+
* that asks only for names takes it for a live one. Throws as `listTmuxSessionNames` does when there is no server.
|
|
68
|
+
*/
|
|
69
|
+
export declare function listSessionStates(socket?: string): TmuxSessionState[];
|
|
70
|
+
/**
|
|
71
|
+
* Closes one dead pane, and only it: the kill is tmux's own decision, in the server, on the pane being dead and
|
|
72
|
+
* still in the named session, so a pane id that another session has since been given (a server that restarted) or a
|
|
73
|
+
* session started again under the same name is never closed. Answers nothing; a pane already gone is the same end.
|
|
74
|
+
*/
|
|
75
|
+
export declare function closeDeadPane({ paneId, session, socket }: {
|
|
76
|
+
paneId: string;
|
|
77
|
+
session: string;
|
|
78
|
+
socket?: string;
|
|
79
|
+
}): void;
|
|
52
80
|
export declare function selectOrphanSessions({ allSessions, ownedSessions, prefix }: {
|
|
53
81
|
allSessions: string[];
|
|
54
82
|
ownedSessions: Iterable<string>;
|
package/substrate/tmux.js
CHANGED
|
@@ -41,7 +41,7 @@ export function assertSession(name, socket) {
|
|
|
41
41
|
// createWindowSession), so every tmux invocation here uses runAsync (spawn, not
|
|
42
42
|
// spawnSync) and the pane-ready poll below uses sleepAsync — see waitForPanesReady.
|
|
43
43
|
// Nothing in this function may block the loop.
|
|
44
|
-
export async function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption = ownerOption, panes, sessionOptions = {}, settings = {}, socket }) {
|
|
44
|
+
export async function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption = ownerOption, panes, sessionOptions = {}, settings = {}, optionalSettings = {}, socket }) {
|
|
45
45
|
assertTmux();
|
|
46
46
|
if (!name) {
|
|
47
47
|
throw new Error("--name is required.");
|
|
@@ -98,12 +98,12 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
|
|
|
98
98
|
// session with it before `remain-on-exit` or the owner stamp is set, and
|
|
99
99
|
// the next set-option's "no such session" replaces the program's own
|
|
100
100
|
// failure in what the launcher reports.
|
|
101
|
-
const setOnCreate = (option, value) => {
|
|
101
|
+
const setOnCreate = (option, value, quiet = false) => {
|
|
102
102
|
assertOptionValue(value);
|
|
103
103
|
// tmux splits commands at an argument that ends in `;`.
|
|
104
104
|
if (value.endsWith(";"))
|
|
105
105
|
throw new Error(`A tmux option value set at creation cannot end in ";": ${option}`);
|
|
106
|
-
startArgs.push(";", "set-option", "-t", name, option, value);
|
|
106
|
+
startArgs.push(";", "set-option", ...(quiet ? ["-q"] : []), "-t", name, option, value);
|
|
107
107
|
};
|
|
108
108
|
if (owner)
|
|
109
109
|
setOnCreate(ownershipOption, owner);
|
|
@@ -115,6 +115,10 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
|
|
|
115
115
|
assertSettingName(option);
|
|
116
116
|
setOnCreate(option, value);
|
|
117
117
|
}
|
|
118
|
+
for (const [option, value] of Object.entries(optionalSettings).sort(([left], [right]) => left.localeCompare(right))) {
|
|
119
|
+
assertSettingName(option);
|
|
120
|
+
setOnCreate(option, value, true);
|
|
121
|
+
}
|
|
118
122
|
await runTmuxSessionCreateAsync(startArgs, { cwd, operation: "create tmux session", session: name, socket });
|
|
119
123
|
await declareSessionColorEnvironmentAsync(socket);
|
|
120
124
|
const target = targetForSession(name);
|
|
@@ -329,6 +333,34 @@ export function killSessionProcessTree({ session, socket }) {
|
|
|
329
333
|
export function listTmuxSessionNames(socket) {
|
|
330
334
|
return run("tmux", tmuxArgs(socket, ["list-sessions", "-F", "#{session_name}"])).split("\n").map((line) => line.trim()).filter(Boolean);
|
|
331
335
|
}
|
|
336
|
+
// The name last: tmux guarantees a session name holds no colon, and the fields before it never do.
|
|
337
|
+
const sessionStateFormat = "#{pane_id}:#{pane_dead}:#{pane_dead_status}:#{pane_dead_signal}:#{session_name}";
|
|
338
|
+
/**
|
|
339
|
+
* Every session with whether its pane's program has ended, in one tmux process. A session whose pane is dead is
|
|
340
|
+
* still a session to tmux (`list-sessions` names it, `has-session` finds it) though nothing runs in it: a caller
|
|
341
|
+
* that asks only for names takes it for a live one. Throws as `listTmuxSessionNames` does when there is no server.
|
|
342
|
+
*/
|
|
343
|
+
export function listSessionStates(socket) {
|
|
344
|
+
return run("tmux", tmuxArgs(socket, ["list-sessions", "-F", sessionStateFormat])).split("\n").filter((line) => line.trim()).map((line) => {
|
|
345
|
+
const [paneId = "", dead = "", status = "", signal = "", ...name] = line.split(":");
|
|
346
|
+
return { name: name.join(":"), paneId, dead: dead === "1", exitCode: /^\d+$/.test(status) ? Number(status) : null, signal };
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Closes one dead pane, and only it: the kill is tmux's own decision, in the server, on the pane being dead and
|
|
351
|
+
* still in the named session, so a pane id that another session has since been given (a server that restarted) or a
|
|
352
|
+
* session started again under the same name is never closed. Answers nothing; a pane already gone is the same end.
|
|
353
|
+
*/
|
|
354
|
+
export function closeDeadPane({ paneId, session, socket }) {
|
|
355
|
+
if (!/^%\d+$/.test(paneId) || !/^[A-Za-z0-9_-]+$/.test(session))
|
|
356
|
+
return;
|
|
357
|
+
try {
|
|
358
|
+
run("tmux", tmuxArgs(socket, ["if-shell", "-F", "-t", paneId, `#{&&:#{pane_dead},#{==:#{session_name},${session}}}`, `kill-pane -t ${paneId}`]));
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// the pane is gone already
|
|
362
|
+
}
|
|
363
|
+
}
|
|
332
364
|
// A session belongs to a provider's prefix iff it is the bare `prefix` or a
|
|
333
365
|
// `prefix-*` child. The exact `-` boundary is load-bearing: prefix `worker` must
|
|
334
366
|
// not claim `worker-pool-1`. The single home for that rule.
|
|
@@ -380,8 +412,17 @@ async function declareSessionColorEnvironmentAsync(socket) {
|
|
|
380
412
|
// runs in the same invocation — `run` uses spawnSync without a shell, so the `;`
|
|
381
413
|
// reaches tmux as a literal command separator, not a shell metacharacter.
|
|
382
414
|
// A supercode pane (p_*) that ends leaves one line in the pane-exit log: its exit
|
|
383
|
-
// status or signal when it failed
|
|
384
|
-
//
|
|
415
|
+
// status or signal when it failed, `closed` when it ended cleanly. The pane-died
|
|
416
|
+
// hook writes it from the dead pane's own status while the session still holds
|
|
417
|
+
// the pane (its host creates p_* sessions with `remain-on-exit on`), and only then
|
|
418
|
+
// closes the pane: a record written after the session is gone is lost when that
|
|
419
|
+
// session was the server's last, because a server with no session exits
|
|
420
|
+
// (`exit-empty`) before a hook's run-shell has started. The session-closed hook
|
|
421
|
+
// writes `closed` for a session closed from outside, where no pane dies; the
|
|
422
|
+
// host writes that line itself for the closes it performs.
|
|
423
|
+
// The hook reads the pane's state in one `display`, so the status and the signal
|
|
424
|
+
// are of one moment: a pane that is gone or not dead by then leaves no line.
|
|
425
|
+
// Its screen is kept only when it failed, and only from a capture that worked.
|
|
385
426
|
// The pane-died hook acts on the pane that died, named by `#{hook_pane}`, and on
|
|
386
427
|
// nothing else: when its session is already gone (a close kills the session, then
|
|
387
428
|
// its processes), tmux resolves an untargeted `kill-pane` and `#{session_name}` to
|
|
@@ -389,8 +430,8 @@ async function declareSessionColorEnvironmentAsync(socket) {
|
|
|
389
430
|
// Targets are not expanded as formats, so the shell that run-shell starts resolves
|
|
390
431
|
// the pane and does nothing when it no longer belongs to a p_* session.
|
|
391
432
|
const PANE_EXIT_LOG = '"${SUPERCODE_HOME:-$HOME/.config/supercode}/pane-exits.log"';
|
|
392
|
-
const PANE_EXIT_CAPTURE = 'case "$s" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac; d="${SUPERCODE_HOME:-$HOME/.config/supercode}/pane-exits"; umask 077; mkdir -p "$d"; t capture-pane -p -e -S -200 -t "$p" 2>/dev/null
|
|
393
|
-
const PANE_DIED_HOOK = "run-shell 'p=\"#{hook_pane}\"; t() { tmux -S \"#{socket_path}\" \"$@\"; };
|
|
433
|
+
const PANE_EXIT_CAPTURE = 'case "$s" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac; d="${SUPERCODE_HOME:-$HOME/.config/supercode}/pane-exits"; umask 077; mkdir -p "$d"; if t capture-pane -p -e -S -200 -t "$p" > "$d/$s.screen.part" 2>/dev/null; then tail -c 32768 "$d/$s.screen.part" > "$d/$s.screen"; fi; rm -f "$d/$s.screen.part"';
|
|
434
|
+
const PANE_DIED_HOOK = "run-shell 'p=\"#{hook_pane}\"; t() { tmux -S \"#{socket_path}\" \"$@\"; }; r=$(t display -p -t \"$p\" \"##{pane_dead}|##{pane_dead_status}|##{pane_dead_signal}|##{session_name}\" 2>/dev/null) || exit 0; case \"$r\" in 1\\|*) ;; *) exit 0 ;; esac; st=$(printf %s \"$r\" | cut -d\"|\" -f2); sg=$(printf %s \"$r\" | cut -d\"|\" -f3); s=$(printf %s \"$r\" | cut -d\"|\" -f4-); case \"$s\" in p_*) ;; *) exit 0 ;; esac; if [ \"$st\" = 0 ] && [ -z \"$sg\" ]; then printf \"%s %s closed\\n\" \"$(date +%FT%T)\" \"$s\" >> " + PANE_EXIT_LOG + "; else printf \"%s %s died status=%s signal=%s\\n\" \"$(date +%FT%T)\" \"$s\" \"$st\" \"$sg\" >> " + PANE_EXIT_LOG + "; (" + PANE_EXIT_CAPTURE + "); fi; t kill-pane -t \"$p\"'";
|
|
394
435
|
const SESSION_CLOSED_HOOK = "if-shell -F '#{m:p_*,#{hook_session_name}}' { run-shell 'printf \"%s %s closed\\n\" \"$(date +%FT%T)\" \"#{hook_session_name}\" >> " + PANE_EXIT_LOG + "' }";
|
|
395
436
|
function sessionServerOptionArgs() {
|
|
396
437
|
return [
|
|
@@ -510,8 +551,10 @@ export async function waitForPanesReady({ expectedPanes, socket, target, timeout
|
|
|
510
551
|
const startedAt = Date.now();
|
|
511
552
|
while (Date.now() - startedAt < timeoutMs) {
|
|
512
553
|
const panes = await inspectPanesAsync(target, socket);
|
|
554
|
+
// A pane whose program has already ended is not waited for: it will not become ready, and its end is its
|
|
555
|
+
// caller's answer.
|
|
513
556
|
const ready = panes.length === expectedPanes
|
|
514
|
-
&& panes.every((pane) => pane.
|
|
557
|
+
&& panes.every((pane) => pane.id);
|
|
515
558
|
if (ready) {
|
|
516
559
|
return panes;
|
|
517
560
|
}
|