@volter/supercode-terminal 0.2.67 → 0.2.69
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 +1 -0
- package/index.d.ts +20 -4
- package/index.mjs +139 -20
- package/package.json +1 -1
- package/substrate/pane-server.mjs +1 -1
package/CAPABILITIES.md
CHANGED
|
@@ -23,6 +23,7 @@ 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. Older pane servers refuse this door; Windows ownership restart remains unmeasured. |
|
|
26
27
|
| Paste and bracketed paste | UI | verified | Multiline or large pastes require confirmation; history is memory-only. Advanced transforms and slow paste are missing. |
|
|
27
28
|
| 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. |
|
|
28
29
|
| 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/index.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ export type TerminalSession = Readonly<{
|
|
|
18
18
|
/** Opaque application context association; never a native harness or tmux handle. */
|
|
19
19
|
contextKey: string | null;
|
|
20
20
|
cwd: string | null;
|
|
21
|
+
/** The program has ended; a clean one-shot can finish before createSession returns. */
|
|
22
|
+
dead?: boolean;
|
|
23
|
+
/** Observed program exit status when dead, otherwise null. */
|
|
24
|
+
exitCode?: number | null;
|
|
21
25
|
id: string;
|
|
22
26
|
label: string;
|
|
23
27
|
owned: boolean;
|
|
@@ -56,9 +60,13 @@ export class TmuxTerminalHost {
|
|
|
56
60
|
sendInput(
|
|
57
61
|
sessionId: string,
|
|
58
62
|
data: string,
|
|
59
|
-
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' },
|
|
63
|
+
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none'; inputOwner?: string },
|
|
60
64
|
): Promise<TerminalInputDelivery>;
|
|
61
65
|
/** Persist an opaque application context association on a session owned by this host. */
|
|
66
|
+
/** Exclusive input ownership, retained by the pane host across client restart. */
|
|
67
|
+
acquireInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
68
|
+
ownsInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
69
|
+
releaseInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
62
70
|
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
63
71
|
issueAttachment(sessionId: string, options?: { mode?: 'observe' | 'control' }): TerminalAttachmentGrant;
|
|
64
72
|
attachSocket(
|
|
@@ -81,7 +89,11 @@ export class PaneServerHost {
|
|
|
81
89
|
listSessions(): Promise<TerminalSession[]>;
|
|
82
90
|
createSession(input: { command: TmuxCommand; contextKey?: string; cwd?: string; env?: Record<string, string>; name?: string }): Promise<TerminalSession>;
|
|
83
91
|
capture(sessionId: string, options?: { lines?: number; preserveEscapes?: boolean }): Promise<string>;
|
|
84
|
-
sendInput(sessionId: string, data: string, options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' }): Promise<TerminalInputDelivery>;
|
|
92
|
+
sendInput(sessionId: string, data: string, options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none'; inputOwner?: string }): Promise<TerminalInputDelivery>;
|
|
93
|
+
/** Exclusive input ownership, retained by the pane host across client restart. */
|
|
94
|
+
acquireInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
95
|
+
ownsInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
96
|
+
releaseInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
85
97
|
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
86
98
|
renameWindow(sessionId: string, title?: string | null): Promise<string>;
|
|
87
99
|
issueAttachment(sessionId: string, options?: { mode?: 'observe' | 'control' }): Promise<TerminalAttachmentGrant>;
|
|
@@ -111,8 +123,12 @@ export class PtyTerminalHost {
|
|
|
111
123
|
sendInput(
|
|
112
124
|
sessionId: string,
|
|
113
125
|
data: string,
|
|
114
|
-
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' },
|
|
126
|
+
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none'; inputOwner?: string },
|
|
115
127
|
): Promise<TerminalInputDelivery>;
|
|
128
|
+
/** Exclusive input ownership, retained by the pane host across client restart. */
|
|
129
|
+
acquireInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
130
|
+
ownsInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
131
|
+
releaseInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
116
132
|
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
117
133
|
/** A ConPTY pane has no window to rename; returns its label. */
|
|
118
134
|
renameWindow(sessionId: string, title?: string | null): string;
|
|
@@ -253,6 +269,6 @@ export type { TerminalInputDelivery, TmuxCommand, TmuxWindowSize };
|
|
|
253
269
|
|
|
254
270
|
/** The bounded live composer text, or null for an unrecognized screen. */
|
|
255
271
|
export function composerText(kind: string, screen: string | undefined): string | null;
|
|
256
|
-
export function restoreComposerInput(host: TmuxTerminalHost | PtyTerminalHost | PaneServerHost, sessionId: string, kind: string, text: string, options?: { pasted?: string | null }): Promise<{ restored: boolean; reason?: string; remaining?: string }>;
|
|
272
|
+
export function restoreComposerInput(host: TmuxTerminalHost | PtyTerminalHost | PaneServerHost, sessionId: string, kind: string, text: string, options?: { pasted?: string | null; inputOwner?: string }): Promise<{ restored: boolean; reason?: string; remaining?: string }>;
|
|
257
273
|
/** Failed input is restored or returns an explicit cleanup obligation; never blindly resend. */
|
|
258
274
|
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 }>;
|
package/index.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { openLocalTerminal as defaultOpenLocalTerminal } from './substrate/local
|
|
|
15
15
|
const DEFAULT_ATTACHMENT_TTL_MS = 30_000;
|
|
16
16
|
const DEFAULT_CONTEXT_OPTION = '@supercode-context';
|
|
17
17
|
const DEFAULT_OWNER_OPTION = '@supercode-owner';
|
|
18
|
+
const INPUT_OWNER_OPTION = '@supercode-input-owner';
|
|
18
19
|
const SESSION_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
19
20
|
// A pane should feel like the terminal it is shown in, not like tmux: no status
|
|
20
21
|
// bar, no prefix key taking keystrokes from the program, and no mouse capture,
|
|
@@ -24,6 +25,20 @@ const SESSION_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
24
25
|
// keep their own settings.
|
|
25
26
|
const TERMINAL_LIKE_SESSION = Object.freeze({ status: 'off', mouse: 'off', prefix: 'None', prefix2: 'None', 'remain-on-exit': 'failed' });
|
|
26
27
|
|
|
28
|
+
function inputOwner(value) {
|
|
29
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(value)) throw new TypeError('An input owner must be an opaque token.');
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function runTmuxInput(socket, args, input = null) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const child = execFile('tmux', [...(socket ? ['-L', socket] : []), ...args], { encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024 },
|
|
36
|
+
(error, stdout) => error ? reject(error) : resolve(stdout.trim()));
|
|
37
|
+
child.stdin.on('error', () => {});
|
|
38
|
+
child.stdin.end(input);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
// The four native harness kinds this package can host interactively, mapped to
|
|
28
43
|
// the vendor CLI a tmux pane runs. These are the plain interactive TUIs, not the
|
|
29
44
|
// registry's stdio/ACP launches (crates/harness/src/support.rs), whose protocol
|
|
@@ -287,6 +302,8 @@ export class TmuxTerminalHost {
|
|
|
287
302
|
if (!this.#canControl(entry.name)) {
|
|
288
303
|
throw new TerminalHostError('input_not_allowed', 'This host does not own that tmux session.');
|
|
289
304
|
}
|
|
305
|
+
if (options.inputOwner) return this.#sendOwnedInput(entry.name, data, options);
|
|
306
|
+
if (await this.#inputOwned(entry.name)) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
290
307
|
return await this.#tmux.sendInputAsync({
|
|
291
308
|
data,
|
|
292
309
|
deadlineMs: options.deadlineMs,
|
|
@@ -296,6 +313,58 @@ export class TmuxTerminalHost {
|
|
|
296
313
|
});
|
|
297
314
|
}
|
|
298
315
|
|
|
316
|
+
async #inputOwned(name, owner = null) {
|
|
317
|
+
const state = await runTmuxInput(this.#socket, ['display-message', '-p', '-t', `${name}:0.0`, `#{${INPUT_OWNER_OPTION}}:#{pane_input_off}`]);
|
|
318
|
+
return owner == null ? state.split(':')[0] !== '' : state === `${owner}:1`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async acquireInput(sessionId, owner) {
|
|
322
|
+
inputOwner(owner);
|
|
323
|
+
const entry = this.#entry(sessionId);
|
|
324
|
+
if (!this.#canControl(entry.name)) throw new TerminalHostError('input_not_allowed', 'This host does not own that pane.');
|
|
325
|
+
if (await this.#inputOwned(entry.name, owner)) return true;
|
|
326
|
+
const target = `${entry.name}:0.0`;
|
|
327
|
+
const condition = `#{&&:#{==:#{${INPUT_OWNER_OPTION}},},#{==:#{pane_input_off},0}}`;
|
|
328
|
+
const command = `set-option -p -t ${target} ${INPUT_OWNER_OPTION} ${owner} ; select-pane -d -t ${target} ; display-message -p acquired`;
|
|
329
|
+
return await runTmuxInput(this.#socket, ['if-shell', '-F', '-t', target, condition, command, 'display-message -p busy']) === 'acquired';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
ownsInput(sessionId, owner) { return this.#inputOwned(this.#entry(sessionId).name, inputOwner(owner)); }
|
|
333
|
+
|
|
334
|
+
async releaseInput(sessionId, owner) {
|
|
335
|
+
inputOwner(owner);
|
|
336
|
+
const target = `${this.#entry(sessionId).name}:0.0`;
|
|
337
|
+
const condition = `#{==:#{${INPUT_OWNER_OPTION}},${owner}}`;
|
|
338
|
+
const command = `set-option -pu -t ${target} ${INPUT_OWNER_OPTION} ; select-pane -e -t ${target} ; display-message -p released`;
|
|
339
|
+
return await runTmuxInput(this.#socket, ['if-shell', '-F', '-t', target, condition, command, 'display-message -p other']) === 'released';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async #sendOwnedInput(name, data, options) {
|
|
343
|
+
const owner = inputOwner(options.inputOwner), target = `${name}:0.0`;
|
|
344
|
+
const write = async command => {
|
|
345
|
+
if (options.deadlineMs != null && Date.now() >= options.deadlineMs) throw new TerminalHostError('input_expired', 'Terminal input expired before delivery.');
|
|
346
|
+
const condition = `#{&&:#{==:#{${INPUT_OWNER_OPTION}},${owner}},#{==:#{pane_input_off},1}}`;
|
|
347
|
+
// The command queue does not yield between enable, write and disable:
|
|
348
|
+
// attached clients cannot replace a paste while it waits for its badge.
|
|
349
|
+
const guarded = `select-pane -e -t ${target} ; ${command} ; select-pane -d -t ${target} ; display-message -p sent`;
|
|
350
|
+
if (await runTmuxInput(this.#socket, ['if-shell', '-F', '-t', target, condition, guarded, 'display-message -p other']) !== 'sent')
|
|
351
|
+
throw new TerminalHostError('input_owned', 'Input ownership changed; no input was sent.');
|
|
352
|
+
};
|
|
353
|
+
const submitted = defaultTmux.submittedTextFromInput(data);
|
|
354
|
+
if (submitted != null && submitted !== '') {
|
|
355
|
+
const buffer = `supercode-input-${randomUUID()}`;
|
|
356
|
+
await runTmuxInput(this.#socket, ['load-buffer', '-b', buffer, '-'], submitted);
|
|
357
|
+
try { await write(`paste-buffer -d -p -b ${buffer} -t ${target}`); }
|
|
358
|
+
finally { await runTmuxInput(this.#socket, ['delete-buffer', '-b', buffer]).catch(() => {}); }
|
|
359
|
+
if (options.submitMode !== 'none') { await sleep(SINGLE_SUBMIT_DELAY_MS); await write(`send-keys -t ${target} Enter`); }
|
|
360
|
+
return 'submitted-paste';
|
|
361
|
+
}
|
|
362
|
+
if (submitted === '' && options.submitMode === 'none') return 'submitted-line';
|
|
363
|
+
const bytes = Buffer.from(data.replace(/\n/gu, '\r'));
|
|
364
|
+
if (bytes.length) await write(`send-keys -H -t ${target} ${[...bytes].map(byte => byte.toString(16)).join(' ')}`);
|
|
365
|
+
return submitted === '' ? 'submitted-line' : 'keystream';
|
|
366
|
+
}
|
|
367
|
+
|
|
299
368
|
async bindContext(sessionId, contextKey) {
|
|
300
369
|
assertContextKey(contextKey);
|
|
301
370
|
const entry = this.#entry(sessionId);
|
|
@@ -339,11 +408,10 @@ export class TmuxTerminalHost {
|
|
|
339
408
|
}
|
|
340
409
|
const attach = this.#attachSocket ?? (await import('./substrate/tmux-stream.js')).attachTmuxTerminalSocket;
|
|
341
410
|
const attachment = await attach({
|
|
342
|
-
authorizeInput:
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
},
|
|
411
|
+
authorizeInput: async () => {
|
|
412
|
+
if (grant.mode !== 'control') throw new TerminalHostError('input_not_allowed', 'This is a read-only terminal attachment.');
|
|
413
|
+
if (await this.#inputOwned(grant.name)) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
414
|
+
},
|
|
347
415
|
errorLabel: 'supercode',
|
|
348
416
|
readOnly: grant.mode === 'observe',
|
|
349
417
|
...(grant.mode === 'control' && size.cols ? { cols: size.cols } : {}),
|
|
@@ -630,6 +698,10 @@ export class PtyTerminalHost {
|
|
|
630
698
|
// Only closing a pane, or its daemon dying, ends a whole tree.
|
|
631
699
|
releasePane(child);
|
|
632
700
|
recordPaneExit(entry.contextKey, exitCode, signal, entry.serializer.serialize({scrollback:200}));
|
|
701
|
+
// A one-shot may finish while createSession reads the process table.
|
|
702
|
+
// Keep its observed completion for that call before forgetting the pane;
|
|
703
|
+
// a successful run is still successful when there is nothing to attach.
|
|
704
|
+
if (exitCode === 0 && !signal) entry.finished = this.#describe(entry, null);
|
|
633
705
|
this.#tellExit(entry);
|
|
634
706
|
this.#writeWatchList();
|
|
635
707
|
if (exitCode === 0) this.#forget(entry);
|
|
@@ -647,6 +719,7 @@ export class PtyTerminalHost {
|
|
|
647
719
|
// after it happens (it flushes the output first), so a root missing from the table is waited on.
|
|
648
720
|
else if (table) await Promise.race([settled, sleep(3_000)]);
|
|
649
721
|
if (!this.#sessions.has(id)) {
|
|
722
|
+
if (entry.finished) return entry.finished;
|
|
650
723
|
throw new TerminalHostError('session_not_found', `${input.command.program} exited before its pane could be attached.`);
|
|
651
724
|
}
|
|
652
725
|
this.#writeWatchList();
|
|
@@ -674,10 +747,12 @@ export class PtyTerminalHost {
|
|
|
674
747
|
|
|
675
748
|
async sendInput(sessionId, data, options = {}) {
|
|
676
749
|
const entry = this.#live(sessionId);
|
|
750
|
+
if ((entry.inputOwner ?? null) !== (options.inputOwner ?? null)) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
677
751
|
if (typeof data !== 'string') throw new Error('Input data must be a string.');
|
|
678
752
|
const deadline = options.deadlineMs;
|
|
679
753
|
const write = (bytes) => {
|
|
680
754
|
if (deadline !== undefined && deadline - Date.now() <= 0) throw new Error('Terminal input expired before delivery.');
|
|
755
|
+
if ((entry.inputOwner ?? null) !== (options.inputOwner ?? null)) throw new TerminalHostError('input_owned', 'Input ownership changed; no input was sent.');
|
|
681
756
|
this.#live(entry.id).child.write(bytes);
|
|
682
757
|
};
|
|
683
758
|
const submit = async () => {
|
|
@@ -702,6 +777,18 @@ export class PtyTerminalHost {
|
|
|
702
777
|
return 'keystream';
|
|
703
778
|
}
|
|
704
779
|
|
|
780
|
+
acquireInput(sessionId, owner) {
|
|
781
|
+
const entry = this.#live(sessionId); inputOwner(owner);
|
|
782
|
+
if (entry.inputOwner && entry.inputOwner !== owner) return false;
|
|
783
|
+
entry.inputOwner = owner; return true;
|
|
784
|
+
}
|
|
785
|
+
ownsInput(sessionId, owner) { return this.#live(sessionId).inputOwner === inputOwner(owner); }
|
|
786
|
+
releaseInput(sessionId, owner) {
|
|
787
|
+
const entry = this.#live(sessionId); inputOwner(owner);
|
|
788
|
+
if (entry.inputOwner !== owner) return false;
|
|
789
|
+
entry.inputOwner = null; return true;
|
|
790
|
+
}
|
|
791
|
+
|
|
705
792
|
async bindContext(sessionId, contextKey) {
|
|
706
793
|
assertContextKey(contextKey);
|
|
707
794
|
this.#entry(sessionId).contextKey = contextKey;
|
|
@@ -777,6 +864,7 @@ export class PtyTerminalHost {
|
|
|
777
864
|
socket.close(1008, 'terminal access ended');
|
|
778
865
|
return;
|
|
779
866
|
}
|
|
867
|
+
if (entry.inputOwner) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
780
868
|
if (this.#sessions.has(entry.id) && !entry.exit) entry.child.write(message.data);
|
|
781
869
|
} catch (error) {
|
|
782
870
|
refuse(error);
|
|
@@ -1020,6 +1108,9 @@ export class PaneServerHost {
|
|
|
1020
1108
|
createSession(input) { return this.#request('createSession', [input]); }
|
|
1021
1109
|
capture(sessionId, options = {}) { return this.#request('capture', [sessionId, options]); }
|
|
1022
1110
|
sendInput(sessionId, data, options = {}) { return this.#request('sendInput', [sessionId, data, options]); }
|
|
1111
|
+
acquireInput(sessionId, owner) { return this.#request('acquireInput', [sessionId, owner]); }
|
|
1112
|
+
ownsInput(sessionId, owner) { return this.#request('ownsInput', [sessionId, owner]); }
|
|
1113
|
+
releaseInput(sessionId, owner) { return this.#request('releaseInput', [sessionId, owner]); }
|
|
1023
1114
|
closeSession(sessionId) { return this.#request('closeSession', [sessionId]); }
|
|
1024
1115
|
renameWindow(sessionId, title) { return this.#request('renameWindow', [sessionId, title]); }
|
|
1025
1116
|
issueAttachment(sessionId, options = {}) { return this.#request('issueAttachment', [sessionId, options]); }
|
|
@@ -1851,23 +1942,24 @@ function composerMatches(value, wanted) {
|
|
|
1851
1942
|
return offsets.has(wanted.length);
|
|
1852
1943
|
}
|
|
1853
1944
|
|
|
1854
|
-
/** Claude collapses a
|
|
1855
|
-
*
|
|
1945
|
+
/** Claude collapses a long single-line paste to `[Pasted text #N]` and a multi-line paste to
|
|
1946
|
+
* `[Pasted text #N +K lines]`. The placeholder (as shown) when it can stand for `text`: K is the pasted text's line breaks (the
|
|
1856
1947
|
* trimmed text's, or the sent payload's with its closing newline). Which paste it is, #N, only the paste itself
|
|
1857
1948
|
* tells: a caller holds the placeholder its own paste produced and accepts no other. */
|
|
1858
1949
|
function pastePlaceholder(composer, text) {
|
|
1859
1950
|
const shown=String(composer??'').trim();
|
|
1860
|
-
const placeholder=/^\[Pasted text #\d+ \+(\d+) lines?\]$/u.exec(shown);
|
|
1951
|
+
const placeholder=/^\[Pasted text #\d+(?: \+(\d+) lines?)?\]$/u.exec(shown);
|
|
1861
1952
|
if(!placeholder)return null;
|
|
1862
1953
|
const breaks=value=>(value.match(/\n/gu)??[]).length;
|
|
1863
|
-
return [breaks(String(text).trim())
|
|
1954
|
+
return (placeholder[1]===undefined?breaks(String(text).trim())===0:
|
|
1955
|
+
[breaks(String(text).trim()),breaks(`${text}\n`)].includes(Number(placeholder[1])))?shown:null;
|
|
1864
1956
|
}
|
|
1865
1957
|
|
|
1866
1958
|
/** Undo only text this input wrote, including when a turn started meanwhile.
|
|
1867
1959
|
* Never Enter, Escape, or Ctrl-C: restoration must not submit or stop work.
|
|
1868
1960
|
* `pasted`: the `[Pasted text #N …]` placeholder this input's own paste produced. Only that one is cleared: another
|
|
1869
1961
|
* of the same size is a person's own paste. */
|
|
1870
|
-
export async function restoreComposerInput(host, sessionId, kind, text, { pasted = null } = {}) {
|
|
1962
|
+
export async function restoreComposerInput(host, sessionId, kind, text, { pasted = null, inputOwner: owner = null } = {}) {
|
|
1871
1963
|
const read=async()=>composerText(kind,await host.capture(sessionId,{preserveEscapes:true}));
|
|
1872
1964
|
let remaining=await read();
|
|
1873
1965
|
if(remaining==='')return {restored:true};
|
|
@@ -1875,14 +1967,14 @@ export async function restoreComposerInput(host, sessionId, kind, text, { pasted
|
|
|
1875
1967
|
const lines=remaining.split('\n').length+2;
|
|
1876
1968
|
for(let line=0;line<lines;line++) {
|
|
1877
1969
|
if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
|
|
1878
|
-
await host.sendInput(sessionId,'\u0005\u0015',{submitMode:'none'});
|
|
1970
|
+
await host.sendInput(sessionId,'\u0005\u0015',{submitMode:'none',...(owner ? { inputOwner: owner } : {})});
|
|
1879
1971
|
await sleep(100);
|
|
1880
1972
|
let after=await read();
|
|
1881
1973
|
if(after==='')return {restored:true};
|
|
1882
1974
|
if(after===null||!remaining.includes(after))return {restored:false,reason:'composer-changed',remaining};
|
|
1883
1975
|
remaining=after;
|
|
1884
1976
|
if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
|
|
1885
|
-
await host.sendInput(sessionId,'\u007f',{submitMode:'none'});
|
|
1977
|
+
await host.sendInput(sessionId,'\u007f',{submitMode:'none',...(owner ? { inputOwner: owner } : {})});
|
|
1886
1978
|
await sleep(100);after=await read();
|
|
1887
1979
|
if(after==='')return {restored:true};
|
|
1888
1980
|
if(after===null||!remaining.replace(/\n/g,'').includes(after.replace(/\n/g,'')))return {restored:false,reason:'composer-changed',remaining};
|
|
@@ -1894,7 +1986,21 @@ export async function restoreComposerInput(host, sessionId, kind, text, { pasted
|
|
|
1894
1986
|
/** Paste only into a stable idle composer. A failed paste is restored; an
|
|
1895
1987
|
* unreadable/concurrently changed composer returns an explicit repair obligation. */
|
|
1896
1988
|
export async function deliverComposerInput(host, sessionId, kind, text, { deadlineMs = Date.now() + 10_000, signal } = {}) {
|
|
1897
|
-
|
|
1989
|
+
if (kind !== 'claude-code') return deliverOwnedComposerInput(host, sessionId, kind, text, { deadlineMs, signal });
|
|
1990
|
+
const owner = randomUUID();
|
|
1991
|
+
try {
|
|
1992
|
+
if (!host.acquireInput || !await host.acquireInput(sessionId, owner)) return { delivered: false, reason: 'input-ownership-unavailable; no input pasted' };
|
|
1993
|
+
return await deliverOwnedComposerInput(host, sessionId, kind, text, { deadlineMs, signal, owner });
|
|
1994
|
+
} catch (error) { return { delivered: false, reason: error?.message ?? String(error) }; }
|
|
1995
|
+
finally { await Promise.resolve(host.releaseInput?.(sessionId, owner)).catch(() => {}); }
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
async function deliverOwnedComposerInput(host, sessionId, kind, text, { deadlineMs, signal, owner = null }) {
|
|
1999
|
+
const capture=async()=>{
|
|
2000
|
+
if (owner && !await host.ownsInput(sessionId, owner)) throw new TerminalHostError('input_owned', 'Input ownership changed; the composer is preserved.');
|
|
2001
|
+
return host.capture(sessionId,{preserveEscapes:true});
|
|
2002
|
+
};
|
|
2003
|
+
const inputOptions = owner ? { inputOwner: owner } : {};
|
|
1898
2004
|
const read=async()=>composerText(kind,await capture());
|
|
1899
2005
|
const expired=()=>signal?.aborted||Date.now()>=deadlineMs;
|
|
1900
2006
|
let touched=false;
|
|
@@ -1904,7 +2010,7 @@ export async function deliverComposerInput(host, sessionId, kind, text, { deadli
|
|
|
1904
2010
|
const failed=async (reason, evidence={})=>{
|
|
1905
2011
|
if(!touched)return {delivered:false,reason,...evidence};
|
|
1906
2012
|
let restored;
|
|
1907
|
-
try{restored=await restoreComposerInput(host,sessionId,kind,text,{pasted});}catch(error){restored={restored:false,reason:error.message};}
|
|
2013
|
+
try{restored=await restoreComposerInput(host,sessionId,kind,text,{pasted,...inputOptions});}catch(error){restored={restored:false,reason:error.message};}
|
|
1908
2014
|
return {delivered:false,reason,...evidence,restored:restored.restored,cleanup_required:!restored.restored,cleanup_reason:restored.reason,cleanup_remaining:restored.remaining};
|
|
1909
2015
|
};
|
|
1910
2016
|
if(expired())return failed('deadline');
|
|
@@ -1924,22 +2030,35 @@ export async function deliverComposerInput(host, sessionId, kind, text, { deadli
|
|
|
1924
2030
|
if(expired())return failed('deadline');
|
|
1925
2031
|
try {
|
|
1926
2032
|
touched=true;
|
|
1927
|
-
const delivery=await host.sendInput(sessionId,`${text}\n`,{submitMode:'none',deadlineMs});
|
|
1928
|
-
let
|
|
1929
|
-
|
|
2033
|
+
const delivery=await host.sendInput(sessionId,`${text}\n`,{submitMode:'none',deadlineMs,...inputOptions});
|
|
2034
|
+
let pasteVisible=false;
|
|
2035
|
+
while(!expired()) {
|
|
1930
2036
|
await sleep(100);
|
|
1931
|
-
if(holds(await read())){
|
|
2037
|
+
if(holds(await read())){pasteVisible=true;break;}
|
|
2038
|
+
}
|
|
2039
|
+
// A final paint at the deadline still belongs to this paste. Submit it
|
|
2040
|
+
// once; its caller must confirm the native user-message receipt.
|
|
2041
|
+
if(expired()) {
|
|
2042
|
+
if(!signal?.aborted&&holds(await read())) {
|
|
2043
|
+
await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs:Date.now()+1000,...inputOptions});
|
|
2044
|
+
return {delivered:false,reason:'native-transcript-confirmation-required',delivery};
|
|
2045
|
+
}
|
|
2046
|
+
return failed('paste-unconfirmed');
|
|
1932
2047
|
}
|
|
1933
|
-
if(!
|
|
2048
|
+
if(!pasteVisible)return failed('paste-unconfirmed');
|
|
1934
2049
|
for(let attempt=0;attempt<3;attempt++) {
|
|
1935
2050
|
if(expired())return failed('deadline');
|
|
1936
2051
|
const screen=await capture();
|
|
1937
2052
|
if(!holds(composerText(kind,screen)))return failed('composer-changed');
|
|
1938
2053
|
if(expired())return failed('deadline');
|
|
1939
|
-
await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs});
|
|
2054
|
+
await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs,...inputOptions});
|
|
1940
2055
|
for(let poll=0;poll<10&&!expired();poll++) {
|
|
1941
2056
|
await sleep(100);
|
|
1942
2057
|
const current=await read();
|
|
2058
|
+
// This helper reports its transport submit to the standalone terminal
|
|
2059
|
+
// controller. Teams-owned Claude panes use the retained native-receipt
|
|
2060
|
+
// queue; changing this SDK result would mark every controller launch
|
|
2061
|
+
// failed even when its input was submitted.
|
|
1943
2062
|
if(current==='')return kind === 'claude-code' ? {delivered:true,delivery,path:'composer_submit'} : {delivered:false,reason:'native-transcript-confirmation-required',delivery};
|
|
1944
2063
|
if(!holds(current))return failed('submission-unconfirmed');
|
|
1945
2064
|
}
|
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', 'createSession', 'capture', 'sendInput', 'closeSession', 'renameWindow', 'issueAttachment', 'bindContext']);
|
|
35
|
+
const OPS = new Set(['listSessions', 'createSession', 'capture', 'sendInput', 'acquireInput', 'ownsInput', 'releaseInput', 'closeSession', 'renameWindow', 'issueAttachment', 'bindContext']);
|
|
36
36
|
|
|
37
37
|
function scheduleIdleExit() {
|
|
38
38
|
clearTimeout(idleTimer);
|