@volter/supercode-terminal 0.2.67 → 0.2.68
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 +16 -4
- package/index.mjs +134 -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
|
@@ -56,9 +56,13 @@ export class TmuxTerminalHost {
|
|
|
56
56
|
sendInput(
|
|
57
57
|
sessionId: string,
|
|
58
58
|
data: string,
|
|
59
|
-
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' },
|
|
59
|
+
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none'; inputOwner?: string },
|
|
60
60
|
): Promise<TerminalInputDelivery>;
|
|
61
61
|
/** Persist an opaque application context association on a session owned by this host. */
|
|
62
|
+
/** Exclusive input ownership, retained by the pane host across client restart. */
|
|
63
|
+
acquireInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
64
|
+
ownsInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
65
|
+
releaseInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
62
66
|
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
63
67
|
issueAttachment(sessionId: string, options?: { mode?: 'observe' | 'control' }): TerminalAttachmentGrant;
|
|
64
68
|
attachSocket(
|
|
@@ -81,7 +85,11 @@ export class PaneServerHost {
|
|
|
81
85
|
listSessions(): Promise<TerminalSession[]>;
|
|
82
86
|
createSession(input: { command: TmuxCommand; contextKey?: string; cwd?: string; env?: Record<string, string>; name?: string }): Promise<TerminalSession>;
|
|
83
87
|
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>;
|
|
88
|
+
sendInput(sessionId: string, data: string, options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none'; inputOwner?: string }): Promise<TerminalInputDelivery>;
|
|
89
|
+
/** Exclusive input ownership, retained by the pane host across client restart. */
|
|
90
|
+
acquireInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
91
|
+
ownsInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
92
|
+
releaseInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
85
93
|
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
86
94
|
renameWindow(sessionId: string, title?: string | null): Promise<string>;
|
|
87
95
|
issueAttachment(sessionId: string, options?: { mode?: 'observe' | 'control' }): Promise<TerminalAttachmentGrant>;
|
|
@@ -111,8 +119,12 @@ export class PtyTerminalHost {
|
|
|
111
119
|
sendInput(
|
|
112
120
|
sessionId: string,
|
|
113
121
|
data: string,
|
|
114
|
-
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' },
|
|
122
|
+
options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none'; inputOwner?: string },
|
|
115
123
|
): Promise<TerminalInputDelivery>;
|
|
124
|
+
/** Exclusive input ownership, retained by the pane host across client restart. */
|
|
125
|
+
acquireInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
126
|
+
ownsInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
127
|
+
releaseInput(sessionId: string, owner: string): Promise<boolean> | boolean;
|
|
116
128
|
bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
|
|
117
129
|
/** A ConPTY pane has no window to rename; returns its label. */
|
|
118
130
|
renameWindow(sessionId: string, title?: string | null): string;
|
|
@@ -253,6 +265,6 @@ export type { TerminalInputDelivery, TmuxCommand, TmuxWindowSize };
|
|
|
253
265
|
|
|
254
266
|
/** The bounded live composer text, or null for an unrecognized screen. */
|
|
255
267
|
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 }>;
|
|
268
|
+
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
269
|
/** Failed input is restored or returns an explicit cleanup obligation; never blindly resend. */
|
|
258
270
|
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 } : {}),
|
|
@@ -674,10 +742,12 @@ export class PtyTerminalHost {
|
|
|
674
742
|
|
|
675
743
|
async sendInput(sessionId, data, options = {}) {
|
|
676
744
|
const entry = this.#live(sessionId);
|
|
745
|
+
if ((entry.inputOwner ?? null) !== (options.inputOwner ?? null)) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
677
746
|
if (typeof data !== 'string') throw new Error('Input data must be a string.');
|
|
678
747
|
const deadline = options.deadlineMs;
|
|
679
748
|
const write = (bytes) => {
|
|
680
749
|
if (deadline !== undefined && deadline - Date.now() <= 0) throw new Error('Terminal input expired before delivery.');
|
|
750
|
+
if ((entry.inputOwner ?? null) !== (options.inputOwner ?? null)) throw new TerminalHostError('input_owned', 'Input ownership changed; no input was sent.');
|
|
681
751
|
this.#live(entry.id).child.write(bytes);
|
|
682
752
|
};
|
|
683
753
|
const submit = async () => {
|
|
@@ -702,6 +772,18 @@ export class PtyTerminalHost {
|
|
|
702
772
|
return 'keystream';
|
|
703
773
|
}
|
|
704
774
|
|
|
775
|
+
acquireInput(sessionId, owner) {
|
|
776
|
+
const entry = this.#live(sessionId); inputOwner(owner);
|
|
777
|
+
if (entry.inputOwner && entry.inputOwner !== owner) return false;
|
|
778
|
+
entry.inputOwner = owner; return true;
|
|
779
|
+
}
|
|
780
|
+
ownsInput(sessionId, owner) { return this.#live(sessionId).inputOwner === inputOwner(owner); }
|
|
781
|
+
releaseInput(sessionId, owner) {
|
|
782
|
+
const entry = this.#live(sessionId); inputOwner(owner);
|
|
783
|
+
if (entry.inputOwner !== owner) return false;
|
|
784
|
+
entry.inputOwner = null; return true;
|
|
785
|
+
}
|
|
786
|
+
|
|
705
787
|
async bindContext(sessionId, contextKey) {
|
|
706
788
|
assertContextKey(contextKey);
|
|
707
789
|
this.#entry(sessionId).contextKey = contextKey;
|
|
@@ -777,6 +859,7 @@ export class PtyTerminalHost {
|
|
|
777
859
|
socket.close(1008, 'terminal access ended');
|
|
778
860
|
return;
|
|
779
861
|
}
|
|
862
|
+
if (entry.inputOwner) throw new TerminalHostError('input_owned', 'This pane is confirming another input; no input was sent.');
|
|
780
863
|
if (this.#sessions.has(entry.id) && !entry.exit) entry.child.write(message.data);
|
|
781
864
|
} catch (error) {
|
|
782
865
|
refuse(error);
|
|
@@ -1020,6 +1103,9 @@ export class PaneServerHost {
|
|
|
1020
1103
|
createSession(input) { return this.#request('createSession', [input]); }
|
|
1021
1104
|
capture(sessionId, options = {}) { return this.#request('capture', [sessionId, options]); }
|
|
1022
1105
|
sendInput(sessionId, data, options = {}) { return this.#request('sendInput', [sessionId, data, options]); }
|
|
1106
|
+
acquireInput(sessionId, owner) { return this.#request('acquireInput', [sessionId, owner]); }
|
|
1107
|
+
ownsInput(sessionId, owner) { return this.#request('ownsInput', [sessionId, owner]); }
|
|
1108
|
+
releaseInput(sessionId, owner) { return this.#request('releaseInput', [sessionId, owner]); }
|
|
1023
1109
|
closeSession(sessionId) { return this.#request('closeSession', [sessionId]); }
|
|
1024
1110
|
renameWindow(sessionId, title) { return this.#request('renameWindow', [sessionId, title]); }
|
|
1025
1111
|
issueAttachment(sessionId, options = {}) { return this.#request('issueAttachment', [sessionId, options]); }
|
|
@@ -1851,23 +1937,24 @@ function composerMatches(value, wanted) {
|
|
|
1851
1937
|
return offsets.has(wanted.length);
|
|
1852
1938
|
}
|
|
1853
1939
|
|
|
1854
|
-
/** Claude collapses a
|
|
1855
|
-
*
|
|
1940
|
+
/** Claude collapses a long single-line paste to `[Pasted text #N]` and a multi-line paste to
|
|
1941
|
+
* `[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
1942
|
* trimmed text's, or the sent payload's with its closing newline). Which paste it is, #N, only the paste itself
|
|
1857
1943
|
* tells: a caller holds the placeholder its own paste produced and accepts no other. */
|
|
1858
1944
|
function pastePlaceholder(composer, text) {
|
|
1859
1945
|
const shown=String(composer??'').trim();
|
|
1860
|
-
const placeholder=/^\[Pasted text #\d+ \+(\d+) lines?\]$/u.exec(shown);
|
|
1946
|
+
const placeholder=/^\[Pasted text #\d+(?: \+(\d+) lines?)?\]$/u.exec(shown);
|
|
1861
1947
|
if(!placeholder)return null;
|
|
1862
1948
|
const breaks=value=>(value.match(/\n/gu)??[]).length;
|
|
1863
|
-
return [breaks(String(text).trim())
|
|
1949
|
+
return (placeholder[1]===undefined?breaks(String(text).trim())===0:
|
|
1950
|
+
[breaks(String(text).trim()),breaks(`${text}\n`)].includes(Number(placeholder[1])))?shown:null;
|
|
1864
1951
|
}
|
|
1865
1952
|
|
|
1866
1953
|
/** Undo only text this input wrote, including when a turn started meanwhile.
|
|
1867
1954
|
* Never Enter, Escape, or Ctrl-C: restoration must not submit or stop work.
|
|
1868
1955
|
* `pasted`: the `[Pasted text #N …]` placeholder this input's own paste produced. Only that one is cleared: another
|
|
1869
1956
|
* of the same size is a person's own paste. */
|
|
1870
|
-
export async function restoreComposerInput(host, sessionId, kind, text, { pasted = null } = {}) {
|
|
1957
|
+
export async function restoreComposerInput(host, sessionId, kind, text, { pasted = null, inputOwner: owner = null } = {}) {
|
|
1871
1958
|
const read=async()=>composerText(kind,await host.capture(sessionId,{preserveEscapes:true}));
|
|
1872
1959
|
let remaining=await read();
|
|
1873
1960
|
if(remaining==='')return {restored:true};
|
|
@@ -1875,14 +1962,14 @@ export async function restoreComposerInput(host, sessionId, kind, text, { pasted
|
|
|
1875
1962
|
const lines=remaining.split('\n').length+2;
|
|
1876
1963
|
for(let line=0;line<lines;line++) {
|
|
1877
1964
|
if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
|
|
1878
|
-
await host.sendInput(sessionId,'\u0005\u0015',{submitMode:'none'});
|
|
1965
|
+
await host.sendInput(sessionId,'\u0005\u0015',{submitMode:'none',...(owner ? { inputOwner: owner } : {})});
|
|
1879
1966
|
await sleep(100);
|
|
1880
1967
|
let after=await read();
|
|
1881
1968
|
if(after==='')return {restored:true};
|
|
1882
1969
|
if(after===null||!remaining.includes(after))return {restored:false,reason:'composer-changed',remaining};
|
|
1883
1970
|
remaining=after;
|
|
1884
1971
|
if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
|
|
1885
|
-
await host.sendInput(sessionId,'\u007f',{submitMode:'none'});
|
|
1972
|
+
await host.sendInput(sessionId,'\u007f',{submitMode:'none',...(owner ? { inputOwner: owner } : {})});
|
|
1886
1973
|
await sleep(100);after=await read();
|
|
1887
1974
|
if(after==='')return {restored:true};
|
|
1888
1975
|
if(after===null||!remaining.replace(/\n/g,'').includes(after.replace(/\n/g,'')))return {restored:false,reason:'composer-changed',remaining};
|
|
@@ -1894,7 +1981,21 @@ export async function restoreComposerInput(host, sessionId, kind, text, { pasted
|
|
|
1894
1981
|
/** Paste only into a stable idle composer. A failed paste is restored; an
|
|
1895
1982
|
* unreadable/concurrently changed composer returns an explicit repair obligation. */
|
|
1896
1983
|
export async function deliverComposerInput(host, sessionId, kind, text, { deadlineMs = Date.now() + 10_000, signal } = {}) {
|
|
1897
|
-
|
|
1984
|
+
if (kind !== 'claude-code') return deliverOwnedComposerInput(host, sessionId, kind, text, { deadlineMs, signal });
|
|
1985
|
+
const owner = randomUUID();
|
|
1986
|
+
try {
|
|
1987
|
+
if (!host.acquireInput || !await host.acquireInput(sessionId, owner)) return { delivered: false, reason: 'input-ownership-unavailable; no input pasted' };
|
|
1988
|
+
return await deliverOwnedComposerInput(host, sessionId, kind, text, { deadlineMs, signal, owner });
|
|
1989
|
+
} catch (error) { return { delivered: false, reason: error?.message ?? String(error) }; }
|
|
1990
|
+
finally { await Promise.resolve(host.releaseInput?.(sessionId, owner)).catch(() => {}); }
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
async function deliverOwnedComposerInput(host, sessionId, kind, text, { deadlineMs, signal, owner = null }) {
|
|
1994
|
+
const capture=async()=>{
|
|
1995
|
+
if (owner && !await host.ownsInput(sessionId, owner)) throw new TerminalHostError('input_owned', 'Input ownership changed; the composer is preserved.');
|
|
1996
|
+
return host.capture(sessionId,{preserveEscapes:true});
|
|
1997
|
+
};
|
|
1998
|
+
const inputOptions = owner ? { inputOwner: owner } : {};
|
|
1898
1999
|
const read=async()=>composerText(kind,await capture());
|
|
1899
2000
|
const expired=()=>signal?.aborted||Date.now()>=deadlineMs;
|
|
1900
2001
|
let touched=false;
|
|
@@ -1904,7 +2005,7 @@ export async function deliverComposerInput(host, sessionId, kind, text, { deadli
|
|
|
1904
2005
|
const failed=async (reason, evidence={})=>{
|
|
1905
2006
|
if(!touched)return {delivered:false,reason,...evidence};
|
|
1906
2007
|
let restored;
|
|
1907
|
-
try{restored=await restoreComposerInput(host,sessionId,kind,text,{pasted});}catch(error){restored={restored:false,reason:error.message};}
|
|
2008
|
+
try{restored=await restoreComposerInput(host,sessionId,kind,text,{pasted,...inputOptions});}catch(error){restored={restored:false,reason:error.message};}
|
|
1908
2009
|
return {delivered:false,reason,...evidence,restored:restored.restored,cleanup_required:!restored.restored,cleanup_reason:restored.reason,cleanup_remaining:restored.remaining};
|
|
1909
2010
|
};
|
|
1910
2011
|
if(expired())return failed('deadline');
|
|
@@ -1924,22 +2025,35 @@ export async function deliverComposerInput(host, sessionId, kind, text, { deadli
|
|
|
1924
2025
|
if(expired())return failed('deadline');
|
|
1925
2026
|
try {
|
|
1926
2027
|
touched=true;
|
|
1927
|
-
const delivery=await host.sendInput(sessionId,`${text}\n`,{submitMode:'none',deadlineMs});
|
|
1928
|
-
let
|
|
1929
|
-
|
|
2028
|
+
const delivery=await host.sendInput(sessionId,`${text}\n`,{submitMode:'none',deadlineMs,...inputOptions});
|
|
2029
|
+
let pasteVisible=false;
|
|
2030
|
+
while(!expired()) {
|
|
1930
2031
|
await sleep(100);
|
|
1931
|
-
if(holds(await read())){
|
|
2032
|
+
if(holds(await read())){pasteVisible=true;break;}
|
|
2033
|
+
}
|
|
2034
|
+
// A final paint at the deadline still belongs to this paste. Submit it
|
|
2035
|
+
// once; its caller must confirm the native user-message receipt.
|
|
2036
|
+
if(expired()) {
|
|
2037
|
+
if(!signal?.aborted&&holds(await read())) {
|
|
2038
|
+
await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs:Date.now()+1000,...inputOptions});
|
|
2039
|
+
return {delivered:false,reason:'native-transcript-confirmation-required',delivery};
|
|
2040
|
+
}
|
|
2041
|
+
return failed('paste-unconfirmed');
|
|
1932
2042
|
}
|
|
1933
|
-
if(!
|
|
2043
|
+
if(!pasteVisible)return failed('paste-unconfirmed');
|
|
1934
2044
|
for(let attempt=0;attempt<3;attempt++) {
|
|
1935
2045
|
if(expired())return failed('deadline');
|
|
1936
2046
|
const screen=await capture();
|
|
1937
2047
|
if(!holds(composerText(kind,screen)))return failed('composer-changed');
|
|
1938
2048
|
if(expired())return failed('deadline');
|
|
1939
|
-
await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs});
|
|
2049
|
+
await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs,...inputOptions});
|
|
1940
2050
|
for(let poll=0;poll<10&&!expired();poll++) {
|
|
1941
2051
|
await sleep(100);
|
|
1942
2052
|
const current=await read();
|
|
2053
|
+
// This helper reports its transport submit to the standalone terminal
|
|
2054
|
+
// controller. Teams-owned Claude panes use the retained native-receipt
|
|
2055
|
+
// queue; changing this SDK result would mark every controller launch
|
|
2056
|
+
// failed even when its input was submitted.
|
|
1943
2057
|
if(current==='')return kind === 'claude-code' ? {delivered:true,delivery,path:'composer_submit'} : {delivered:false,reason:'native-transcript-confirmation-required',delivery};
|
|
1944
2058
|
if(!holds(current))return failed('submission-unconfirmed');
|
|
1945
2059
|
}
|
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);
|