@volter/supercode-terminal 0.2.66 → 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 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: 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 } : {}),
@@ -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 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
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()),breaks(`${text}\n`)].includes(Number(placeholder[1]))?shown:null;
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
- const capture=()=>host.capture(sessionId,{preserveEscapes:true});
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 pasted=false;
1929
- for(let attempt=0;attempt<10&&!expired();attempt++) {
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())){pasted=true;break;}
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(!pasted)return failed('paste-unconfirmed');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-terminal",
3
- "version": "0.2.66",
3
+ "version": "0.2.68",
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);
package/substrate/tmux.js CHANGED
@@ -63,7 +63,7 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
63
63
  // it. When an agent's session started the server (or is the launcher), that includes the session's own harness
64
64
  // identity, so a new pane would resolve as that session. A pane's identity is only what its own session sets:
65
65
  // every identity variable the server or the launcher holds and the session does not set is unset in the pane.
66
- const identity = /^(CLAUDECODE|CLAUDE_CODE_SESSION_ID|CLAUDE_CODE_ENTRYPOINT|CODEX_THREAD_ID|CODEX_SESSION_ID|SUPERCODE_CALLER|SUPERCODE_CALLER_NAME|SUPERCODE_AGENT_[A-Z_]+)$/;
66
+ const identity = /^(CLAUDECODE|CLAUDE_CODE_SESSION_ID|CLAUDE_CODE_ENTRYPOINT|CODEX_THREAD_ID|CODEX_SESSION_ID|SUPERCODE_CALLER|SUPERCODE_CALLER_NAME|SUPERCODE_LAUNCHED_BY|SUPERCODE_AGENT_[A-Z_]+)$/;
67
67
  const serverNames = await runAsync("tmux", tmuxArgs(socket, ["show-environment", "-g"]))
68
68
  .then((out) => out.split("\n").map((line) => /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(line)?.[1]).filter((name) => Boolean(name)))
69
69
  .catch(() => []); // no server yet: the one this creates starts from this launcher's environment