@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 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: grant.mode === 'control'
343
- ? undefined
344
- : () => {
345
- throw new TerminalHostError('input_not_allowed', 'This is a read-only terminal attachment.');
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 multi-line paste to `[Pasted text #N +K lines]`, and its composer then shows that placeholder,
1855
- * never the text. The placeholder (as shown) when it can stand for `text`: K is the pasted text's line breaks (the
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()),breaks(`${text}\n`)].includes(Number(placeholder[1]))?shown:null;
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
- const capture=()=>host.capture(sessionId,{preserveEscapes:true});
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 pasted=false;
1929
- for(let attempt=0;attempt<10&&!expired();attempt++) {
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())){pasted=true;break;}
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(!pasted)return failed('paste-unconfirmed');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-terminal",
3
- "version": "0.2.67",
3
+ "version": "0.2.69",
4
4
  "type": "module",
5
5
  "description": "Optional leak-safe terminal and tmux host adapter for Volter Harness frontends",
6
6
  "exports": {
@@ -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);