@volter/supercode-terminal 0.2.74 → 0.3.0
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 +47 -290
- package/package.json +1 -1
- package/substrate/pane-server.mjs +1 -1
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,
|
|
@@ -26,11 +25,6 @@ const SESSION_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
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
27
|
|
|
29
|
-
function inputOwner(value) {
|
|
30
|
-
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(value)) throw new TypeError('An input owner must be an opaque token.');
|
|
31
|
-
return value;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
28
|
function runTmuxInput(socket, args, input = null) {
|
|
35
29
|
return new Promise((resolve, reject) => {
|
|
36
30
|
const child = execFile('tmux', [...(socket ? ['-L', socket] : []), ...args], { encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024 },
|
|
@@ -319,8 +313,6 @@ export class TmuxTerminalHost {
|
|
|
319
313
|
if (!this.#canControl(entry.name)) {
|
|
320
314
|
throw new TerminalHostError('input_not_allowed', 'This host does not own that tmux session.');
|
|
321
315
|
}
|
|
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
316
|
return await this.#tmux.sendInputAsync({
|
|
325
317
|
data,
|
|
326
318
|
deadlineMs: options.deadlineMs,
|
|
@@ -330,58 +322,6 @@ export class TmuxTerminalHost {
|
|
|
330
322
|
});
|
|
331
323
|
}
|
|
332
324
|
|
|
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
325
|
async bindContext(sessionId, contextKey) {
|
|
386
326
|
assertContextKey(contextKey);
|
|
387
327
|
const entry = this.#entry(sessionId);
|
|
@@ -427,7 +367,6 @@ export class TmuxTerminalHost {
|
|
|
427
367
|
const attachment = await attach({
|
|
428
368
|
authorizeInput: async () => {
|
|
429
369
|
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
370
|
},
|
|
432
371
|
errorLabel: 'supercode',
|
|
433
372
|
readOnly: grant.mode === 'observe',
|
|
@@ -484,6 +423,25 @@ export class TmuxTerminalHost {
|
|
|
484
423
|
}
|
|
485
424
|
}
|
|
486
425
|
|
|
426
|
+
/** Every pane this host controls takes its keyboard and keeps no pane-scoped option of supercode's (what supercode
|
|
427
|
+
* keeps of a pane is its session's options). A daemon calls it once as it starts; answers the panes it changed. */
|
|
428
|
+
async openPanes() {
|
|
429
|
+
const changed = [];
|
|
430
|
+
for (const name of this.#sessionNames()) {
|
|
431
|
+
if (!this.#canControl(name)) continue;
|
|
432
|
+
const target = `${name}:0.0`;
|
|
433
|
+
try {
|
|
434
|
+
const off = await runTmuxInput(this.#socket, ['display-message', '-p', '-t', target, '#{pane_input_off}']) === '1';
|
|
435
|
+
const options = (await runTmuxInput(this.#socket, ['show-options', '-p', '-t', target])).split('\n')
|
|
436
|
+
.map((line) => line.split(' ')[0]).filter((key) => key.startsWith('@supercode-'));
|
|
437
|
+
if (off) await runTmuxInput(this.#socket, ['select-pane', '-e', '-t', target]);
|
|
438
|
+
for (const key of options) await runTmuxInput(this.#socket, ['set-option', '-pu', '-t', target, key]);
|
|
439
|
+
if (off || options.length) changed.push(name);
|
|
440
|
+
} catch { /* the pane ended meanwhile */ }
|
|
441
|
+
}
|
|
442
|
+
return changed;
|
|
443
|
+
}
|
|
444
|
+
|
|
487
445
|
dispose() {
|
|
488
446
|
for (const attachment of [...this.#liveAttachments]) attachment.dispose();
|
|
489
447
|
this.#attachments.clear();
|
|
@@ -769,12 +727,10 @@ export class PtyTerminalHost {
|
|
|
769
727
|
|
|
770
728
|
async sendInput(sessionId, data, options = {}) {
|
|
771
729
|
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
730
|
if (typeof data !== 'string') throw new Error('Input data must be a string.');
|
|
774
731
|
const deadline = options.deadlineMs;
|
|
775
732
|
const write = (bytes) => {
|
|
776
733
|
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
734
|
this.#live(entry.id).child.write(bytes);
|
|
779
735
|
};
|
|
780
736
|
const submit = async () => {
|
|
@@ -799,18 +755,6 @@ export class PtyTerminalHost {
|
|
|
799
755
|
return 'keystream';
|
|
800
756
|
}
|
|
801
757
|
|
|
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
758
|
async bindContext(sessionId, contextKey) {
|
|
815
759
|
assertContextKey(contextKey);
|
|
816
760
|
this.#entry(sessionId).contextKey = contextKey;
|
|
@@ -886,7 +830,6 @@ export class PtyTerminalHost {
|
|
|
886
830
|
socket.close(1008, 'terminal access ended');
|
|
887
831
|
return;
|
|
888
832
|
}
|
|
889
|
-
if (entry.inputOwner) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
890
833
|
if (this.#sessions.has(entry.id) && !entry.exit) entry.child.write(message.data);
|
|
891
834
|
} catch (error) {
|
|
892
835
|
refuse(error);
|
|
@@ -1128,12 +1071,9 @@ export class PaneServerHost {
|
|
|
1128
1071
|
|
|
1129
1072
|
listSessions() { return this.#request('listSessions', []); }
|
|
1130
1073
|
hasLiveContext(contextKey) { assertContextKey(contextKey); return this.#request('hasLiveContext', [contextKey]); }
|
|
1131
|
-
createSession(input) { return this.#request('createSession', [input]
|
|
1074
|
+
createSession(input) { return this.#request('createSession', [input]); }
|
|
1132
1075
|
capture(sessionId, options = {}) { return this.#request('capture', [sessionId, options]); }
|
|
1133
1076
|
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
1077
|
closeSession(sessionId) { return this.#request('closeSession', [sessionId]); }
|
|
1138
1078
|
renameWindow(sessionId, title) { return this.#request('renameWindow', [sessionId, title]); }
|
|
1139
1079
|
issueAttachment(sessionId, options = {}) { return this.#request('issueAttachment', [sessionId, options]); }
|
|
@@ -1187,26 +1127,7 @@ export class PaneServerHost {
|
|
|
1187
1127
|
for (let attempt = 0; ; attempt += 1) {
|
|
1188
1128
|
const connection = await this.#connect();
|
|
1189
1129
|
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 });
|
|
1130
|
+
return await this.#call(connection, op, args, extra);
|
|
1210
1131
|
} catch (error) {
|
|
1211
1132
|
if (error?.code !== 'pane_server_restarting' || attempt >= 20) throw error;
|
|
1212
1133
|
await sleep(250);
|
|
@@ -1839,7 +1760,7 @@ function nativeAgentSessionId(harness, sessionId) {
|
|
|
1839
1760
|
const COMPOSER_READY = Object.freeze({
|
|
1840
1761
|
// codex's placeholder text IS the empty-composer state, which is exactly the
|
|
1841
1762
|
// question here: not "is there a caret" but "is it waiting for a first
|
|
1842
|
-
// message".
|
|
1763
|
+
// message".
|
|
1843
1764
|
codex: (screen) => screen.includes('› Ask Codex to do anything'),
|
|
1844
1765
|
// Claude draws `❯`, or on Windows `>` and a no-break space (captured from a ConPTY pane on volter-desktop).
|
|
1845
1766
|
'claude-code': (screen) => composerLine(screen, /^[❯>](?:\s|$)/u),
|
|
@@ -1887,52 +1808,11 @@ const ANSI_SEQUENCE = /\u001b\[[0-?]*[ -\/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0
|
|
|
1887
1808
|
*/
|
|
1888
1809
|
export function composerReadyForInput(kind, screen) {
|
|
1889
1810
|
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.
|
|
1811
|
+
// `true` means a composer is drawn, whatever it holds.
|
|
1812
|
+
if (kind === 'claude-code' || kind === 'codex') return composerText(kind, screen) !== null;
|
|
1893
1813
|
return COMPOSER_READY[kind](String(screen ?? ''));
|
|
1894
1814
|
}
|
|
1895
1815
|
|
|
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
1816
|
/** Read only a bounded, live Claude/Codex composer, never a prompt in scrollback.
|
|
1937
1817
|
* Unknown layouts and collapsed pastes are deliberately unconfirmed.
|
|
1938
1818
|
*/
|
|
@@ -1942,7 +1822,11 @@ export function composerText(kind, screen) {
|
|
|
1942
1822
|
const plain = lines.map((cells) => cells.map((cell) => cell.char).join(''));
|
|
1943
1823
|
// A session started with `--name` labels the rule above its editor (`──── name ─`); the label does not end the rule.
|
|
1944
1824
|
const rule = /^\s*[─━]{4,}(?:[^─━]+[─━]+)?\s*$/u;
|
|
1945
|
-
|
|
1825
|
+
// Codex's status row is `model · cwd` plus whatever else it has to say (a goal's state, a warning count). On a row
|
|
1826
|
+
// too narrow for all of it Codex cuts the cwd down to its ellipsis (`· … Goal stalled (/goal resume)`, `· ~… Goal
|
|
1827
|
+
// paused (/goal resume)`, both read from 80-column panes on yuerans, 2026-10-10), and a row whose cwd was required
|
|
1828
|
+
// to open with a path root read as no footer: the idle composer above it read as absent.
|
|
1829
|
+
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;
|
|
1946
1830
|
const caret = kind === 'codex' ? /^\s*›(?:\s|$)/u : /^\s*[❯>](?:\s|$)/u;
|
|
1947
1831
|
for (let row = plain.length - 1; row >= 0; row -= 1) {
|
|
1948
1832
|
if (!caret.test(plain[row])) continue;
|
|
@@ -1969,158 +1853,31 @@ export function composerText(kind, screen) {
|
|
|
1969
1853
|
return null;
|
|
1970
1854
|
}
|
|
1971
1855
|
|
|
1972
|
-
/**
|
|
1973
|
-
*
|
|
1974
|
-
* whitespace within rows: a changed or partially pasted draft stays unconfirmed.
|
|
1975
|
-
*/
|
|
1976
|
-
function composerMatches(value, wanted) {
|
|
1977
|
-
if (value === null) return false;
|
|
1978
|
-
let offsets = new Set([0]);
|
|
1979
|
-
const rows = value.split('\n');
|
|
1980
|
-
for (let row = 0; row < rows.length; row += 1) {
|
|
1981
|
-
const next = new Set();
|
|
1982
|
-
for (const offset of offsets) {
|
|
1983
|
-
if (!wanted.startsWith(rows[row], offset)) continue;
|
|
1984
|
-
const end = offset + rows[row].length;
|
|
1985
|
-
if (row === rows.length - 1) next.add(end);
|
|
1986
|
-
else {
|
|
1987
|
-
next.add(end);
|
|
1988
|
-
if (wanted[end] === ' ' || wanted[end] === '\n') next.add(end + 1);
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
offsets = next;
|
|
1992
|
-
}
|
|
1993
|
-
return offsets.has(wanted.length);
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
/** Claude collapses a long single-line paste to `[Pasted text #N]` and a multi-line paste to
|
|
1997
|
-
* `[Pasted text #N +K lines]`. The placeholder (as shown) when it can stand for `text`: K is the pasted text's line breaks (the
|
|
1998
|
-
* trimmed text's, or the sent payload's with its closing newline). Which paste it is, #N, only the paste itself
|
|
1999
|
-
* tells: a caller holds the placeholder its own paste produced and accepts no other. */
|
|
2000
|
-
function pastePlaceholder(composer, text) {
|
|
2001
|
-
const shown=String(composer??'').trim();
|
|
2002
|
-
const placeholder=/^\[Pasted text #\d+(?: \+(\d+) lines?)?\]$/u.exec(shown);
|
|
2003
|
-
if(!placeholder)return null;
|
|
2004
|
-
const breaks=value=>(value.match(/\n/gu)??[]).length;
|
|
2005
|
-
return (placeholder[1]===undefined?breaks(String(text).trim())===0:
|
|
2006
|
-
[breaks(String(text).trim()),breaks(`${text}\n`)].includes(Number(placeholder[1])))?shown:null;
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
/** Undo only text this input wrote, including when a turn started meanwhile.
|
|
2010
|
-
* Never Enter, Escape, or Ctrl-C: restoration must not submit or stop work.
|
|
2011
|
-
* `pasted`: the `[Pasted text #N …]` placeholder this input's own paste produced. Only that one is cleared: another
|
|
2012
|
-
* of the same size is a person's own paste. */
|
|
2013
|
-
export async function restoreComposerInput(host, sessionId, kind, text, { pasted = null, inputOwner: owner = null } = {}) {
|
|
2014
|
-
const read=async()=>composerText(kind,await host.capture(sessionId,{preserveEscapes:true}));
|
|
2015
|
-
let remaining=await read();
|
|
2016
|
-
if(remaining==='')return {restored:true};
|
|
2017
|
-
if(!composerMatches(remaining,String(text).trim())&&!(pasted&&remaining.trim()===pasted))return {restored:false,reason:'composer-changed-or-unreadable'};
|
|
2018
|
-
const lines=remaining.split('\n').length+2;
|
|
2019
|
-
for(let line=0;line<lines;line++) {
|
|
2020
|
-
if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
|
|
2021
|
-
await host.sendInput(sessionId,'\u0005\u0015',{submitMode:'none',...(owner ? { inputOwner: owner } : {})});
|
|
2022
|
-
await sleep(100);
|
|
2023
|
-
let after=await read();
|
|
2024
|
-
if(after==='')return {restored:true};
|
|
2025
|
-
if(after===null||!remaining.includes(after))return {restored:false,reason:'composer-changed',remaining};
|
|
2026
|
-
remaining=after;
|
|
2027
|
-
if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
|
|
2028
|
-
await host.sendInput(sessionId,'\u007f',{submitMode:'none',...(owner ? { inputOwner: owner } : {})});
|
|
2029
|
-
await sleep(100);after=await read();
|
|
2030
|
-
if(after==='')return {restored:true};
|
|
2031
|
-
if(after===null||!remaining.replace(/\n/g,'').includes(after.replace(/\n/g,'')))return {restored:false,reason:'composer-changed',remaining};
|
|
2032
|
-
remaining=after;
|
|
2033
|
-
}
|
|
2034
|
-
return {restored:false,reason:'restore-unconfirmed',remaining};
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
/** Paste only into a stable idle composer. A failed paste is restored; an
|
|
2038
|
-
* unreadable/concurrently changed composer returns an explicit repair obligation. */
|
|
1856
|
+
/** Paste `text` into the pane's composer and submit it, whatever the composer holds. The composer is read afterwards
|
|
1857
|
+
* only to see the paste leave it: an Enter that arrived before the paste was drawn is pressed again. */
|
|
2039
1858
|
export async function deliverComposerInput(host, sessionId, kind, text, { deadlineMs = Date.now() + 10_000, signal } = {}) {
|
|
2040
|
-
|
|
2041
|
-
const
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
return await deliverOwnedComposerInput(host, sessionId, kind, text, { deadlineMs, signal, owner });
|
|
2045
|
-
} catch (error) { return { delivered: false, reason: error?.message ?? String(error) }; }
|
|
2046
|
-
finally { await Promise.resolve(host.releaseInput?.(sessionId, owner)).catch(() => {}); }
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
async function deliverOwnedComposerInput(host, sessionId, kind, text, { deadlineMs, signal, owner = null }) {
|
|
2050
|
-
const capture=async()=>{
|
|
2051
|
-
if (owner && !await host.ownsInput(sessionId, owner)) throw new TerminalHostError('input_owned', 'Input ownership changed; the composer is preserved.');
|
|
2052
|
-
return host.capture(sessionId,{preserveEscapes:true});
|
|
2053
|
-
};
|
|
2054
|
-
const inputOptions = owner ? { inputOwner: owner } : {};
|
|
2055
|
-
const read=async()=>composerText(kind,await capture());
|
|
2056
|
-
const expired=()=>signal?.aborted||Date.now()>=deadlineMs;
|
|
2057
|
-
let touched=false;
|
|
2058
|
-
// The placeholder this paste produced: the first one seen after pasting into a composer read empty twice. From then
|
|
2059
|
-
// on only that one (its #N) is this text, and only that one is restored away.
|
|
2060
|
-
let pasted=null;
|
|
2061
|
-
const failed=async (reason, evidence={})=>{
|
|
2062
|
-
if(!touched)return {delivered:false,reason,...evidence};
|
|
2063
|
-
let restored;
|
|
2064
|
-
try{restored=await restoreComposerInput(host,sessionId,kind,text,{pasted,...inputOptions});}catch(error){restored={restored:false,reason:error.message};}
|
|
2065
|
-
return {delivered:false,reason,...evidence,restored:restored.restored,cleanup_required:!restored.restored,cleanup_reason:restored.reason,cleanup_remaining:restored.remaining};
|
|
2066
|
-
};
|
|
2067
|
-
if(expired())return failed('deadline');
|
|
2068
|
-
const wanted=String(text).trim();
|
|
2069
|
-
const holds=composer=>{
|
|
2070
|
-
if(composerMatches(composer,wanted))return true;
|
|
2071
|
-
const placeholder=pastePlaceholder(composer,text);
|
|
2072
|
-
if(placeholder&&touched&&pasted===null)pasted=placeholder;
|
|
2073
|
-
return placeholder!==null&&placeholder===pasted;
|
|
2074
|
-
};
|
|
2075
|
-
if(!wanted||/[\x00-\x08\x0b-\x1f\x7f]/u.test(text))return failed('unsupported-text');
|
|
2076
|
-
for(let check=0;check<2;check++) {
|
|
2077
|
-
const screen=await capture(),before=composerText(kind,screen);
|
|
2078
|
-
if(before!=='')return failed(before===null?'no-composer':'draft');
|
|
2079
|
-
if(check===0)await sleep(200);
|
|
2080
|
-
}
|
|
2081
|
-
if(expired())return failed('deadline');
|
|
1859
|
+
const read = async () => composerText(kind, await host.capture(sessionId, { preserveEscapes: true }));
|
|
1860
|
+
const expired = () => signal?.aborted || Date.now() >= deadlineMs;
|
|
1861
|
+
if (!String(text).trim() || /[\x00-\x08\x0b-\x1f\x7f]/u.test(text)) return { delivered: false, reason: 'unsupported-text' };
|
|
1862
|
+
if (expired()) return { delivered: false, reason: 'deadline' };
|
|
2082
1863
|
try {
|
|
2083
|
-
|
|
2084
|
-
const delivery=await host.sendInput(sessionId
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
// A final paint at the deadline still belongs to this paste. Submit it
|
|
2091
|
-
// once; its caller must confirm the native user-message receipt.
|
|
2092
|
-
if(expired()) {
|
|
2093
|
-
if(!signal?.aborted&&holds(await read())) {
|
|
2094
|
-
await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs:Date.now()+1000,...inputOptions});
|
|
2095
|
-
return {delivered:false,reason:'native-transcript-confirmation-required',delivery};
|
|
2096
|
-
}
|
|
2097
|
-
return failed('paste-unconfirmed');
|
|
2098
|
-
}
|
|
2099
|
-
if(!pasteVisible)return failed('paste-unconfirmed');
|
|
2100
|
-
for(let attempt=0;attempt<3;attempt++) {
|
|
2101
|
-
if(expired())return failed('deadline');
|
|
2102
|
-
const screen=await capture();
|
|
2103
|
-
if(!holds(composerText(kind,screen)))return failed('composer-changed');
|
|
2104
|
-
if(expired())return failed('deadline');
|
|
2105
|
-
await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs,...inputOptions});
|
|
2106
|
-
for(let poll=0;poll<10&&!expired();poll++) {
|
|
1864
|
+
const before = await read();
|
|
1865
|
+
const delivery = await host.sendInput(sessionId, `${text}\n`, { submitMode: 'none', deadlineMs });
|
|
1866
|
+
// the paste is drawn a moment after it is written: Enter follows its first sighting, or a second without one
|
|
1867
|
+
for (let poll = 0; poll < 10 && !expired() && await read() === before; poll++) await sleep(100);
|
|
1868
|
+
for (let attempt = 0; attempt < 3 && !expired(); attempt++) {
|
|
1869
|
+
await host.sendInput(sessionId, '\r', { submitMode: 'single', deadlineMs });
|
|
1870
|
+
for (let poll = 0; poll < 10 && !expired(); poll++) {
|
|
2107
1871
|
await sleep(100);
|
|
2108
|
-
|
|
2109
|
-
//
|
|
2110
|
-
|
|
2111
|
-
// queue; changing this SDK result would mark every controller launch
|
|
2112
|
-
// failed even when its input was submitted.
|
|
2113
|
-
if(current==='')return kind === 'claude-code' ? {delivered:true,delivery,path:'composer_submit'} : {delivered:false,reason:'native-transcript-confirmation-required',delivery};
|
|
2114
|
-
if(!holds(current))return failed('submission-unconfirmed');
|
|
1872
|
+
// This helper reports its transport submit to the standalone terminal controller. Teams panes use the
|
|
1873
|
+
// retained native-receipt queue, which confirms from the native transcript.
|
|
1874
|
+
if (!await read()) return kind === 'claude-code' ? { delivered: true, delivery, path: 'composer_submit' } : { delivered: false, reason: 'native-transcript-confirmation-required', delivery };
|
|
2115
1875
|
}
|
|
2116
1876
|
}
|
|
2117
|
-
return
|
|
2118
|
-
}catch(error){return
|
|
1877
|
+
return { delivered: false, reason: expired() ? 'deadline' : 'submit-unconfirmed', delivery };
|
|
1878
|
+
} catch (error) { return { delivered: false, reason: error?.message ?? String(error) }; }
|
|
2119
1879
|
}
|
|
2120
1880
|
|
|
2121
|
-
/** The carets each kind draws at the start of its composer line (Claude on Windows draws `>`). */
|
|
2122
|
-
const COMPOSER_CARETS = Object.freeze({ 'claude-code': ['❯', '>'], codex: ['›'], gemini: ['>'], grok: ['❯'] });
|
|
2123
|
-
|
|
2124
1881
|
/**
|
|
2125
1882
|
* The visible characters of one captured line, each marked as placeholder
|
|
2126
1883
|
* 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);
|