@volter/supercode-terminal 0.2.53 → 0.2.55

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.
Files changed (3) hide show
  1. package/index.d.ts +4 -3
  2. package/index.mjs +86 -37
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -252,6 +252,7 @@ export type {
252
252
  export type { TerminalInputDelivery, TmuxCommand, TmuxWindowSize };
253
253
 
254
254
  /** The bounded live composer text, or null for an unrecognized screen. */
255
- export function composerText(kind: string, screen: string | undefined): string | null;
256
- /** False means unconfirmed and may leave a draft; never blindly resend. */
257
- export function deliverComposerInput(host: TmuxTerminalHost | PtyTerminalHost | PaneServerHost, sessionId: string, kind: string, text: string, options?: { deadlineMs?: number; signal?: AbortSignal }): Promise<{ delivered: boolean; reason?: string; delivery?: TerminalInputDelivery }>;
255
+ export function composerText(kind: string, screen: string | undefined, options?: { allowBusy?: boolean }): string | null;
256
+ export function restoreComposerInput(host: TmuxTerminalHost | PtyTerminalHost | PaneServerHost, sessionId: string, kind: string, text: string): Promise<{ restored: boolean; reason?: string; remaining?: string }>;
257
+ /** Failed input is restored or returns an explicit cleanup obligation; never blindly resend. */
258
+ 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
@@ -1738,12 +1738,13 @@ export function composerHoldsDraft(kind, screen) {
1738
1738
  /** Read only a bounded, live Claude/Codex composer, never a prompt in scrollback.
1739
1739
  * Unknown layouts and collapsed pastes are deliberately unconfirmed.
1740
1740
  */
1741
- export function composerText(kind, screen) {
1741
+ export function composerText(kind, screen, { allowBusy = false } = {}) {
1742
1742
  if (kind !== 'claude-code' && kind !== 'codex') return null;
1743
1743
  const lines = String(screen ?? '').split('\n').map(styledCells);
1744
1744
  const plain = lines.map((cells) => cells.map((cell) => cell.char).join(''));
1745
+ if (!allowBusy && composerBusy(screen)) return null;
1745
1746
  const rule = /^\s*[─━]{4,}\s*$/u;
1746
- const footer = /^\s*(?:\? for shortcuts\b|⚠\s+\d+\s+warnings?\s+·\s+f2 to view\s*$|[^\n]+ · (?:~(?:[\\/]|(?=\s|$))|[\\/]|[A-Z]:[\\/])[^\n]*$|.*← for agents\s*$|.*\bcontext(?:\s+\d+%)?\s+left\b)/iu;
1747
+ const footer = /^\s*(?:.*\btab to queue (?:a )?message\b.*$|\? for shortcuts\b|⚠\s+\d+\s+warnings?\s+·\s+f2 to view\s*$|[^\n]+ · (?:~(?:[\\/]|(?=\s|$))|[\\/]|[A-Z]:[\\/])[^\n]*$|.*← for agents\s*$|.*\bcontext(?:\s+\d+%)?\s+left\b)/iu;
1747
1748
  const caret = kind === 'codex' ? /^\s*›(?:\s|$)/u : /^\s*[❯>](?:\s|$)/u;
1748
1749
  for (let row = plain.length - 1; row >= 0; row -= 1) {
1749
1750
  if (!caret.test(plain[row])) continue;
@@ -1790,44 +1791,92 @@ function composerMatches(value, wanted) {
1790
1791
  return offsets.has(wanted.length);
1791
1792
  }
1792
1793
 
1793
- /** Paste once, then submit only text read back from the live composer.
1794
- * False means unconfirmed, and can leave a draft: callers must not blindly resend.
1795
- */
1796
- export async function deliverComposerInput(host, sessionId, kind, text, { deadlineMs = Date.now() + 10_000, signal } = {}) {
1797
- const read = async () => composerText(kind, await host.capture(sessionId, { preserveEscapes: true }));
1798
- const expired = () => signal?.aborted || Date.now() >= deadlineMs;
1799
- const failed = (reason) => ({ delivered: false, reason });
1800
- if (expired()) return failed('deadline');
1801
- const before = await read();
1802
- if (before !== '') return failed(before === null ? 'no-composer' : 'draft');
1803
- const wanted = String(text).trim();
1804
- if (!wanted || /[\x00-\x08\x0b-\x1f\x7f]/u.test(text)) return failed('unsupported-text');
1805
- if (expired()) return failed('deadline');
1806
- const delivery = await host.sendInput(sessionId, `${text}\n`, { submitMode: 'none', deadlineMs });
1807
- // A dropped paste must never be followed by Enter. Give the editor time to render it.
1808
- let pasted = false;
1809
- for (let attempt = 0; attempt < 10 && !expired(); attempt += 1) {
1794
+ /** Busy evidence must come from the live UI bottom, not a fleet label or a
1795
+ * quoted phrase in the transcript. */
1796
+ function composerBusyEvidence(screen) {
1797
+ const bottom=String(screen??'').split('\n').map(line=>styledCells(line).map(cell=>cell.char).join('')).filter(line=>line.trim()).slice(-8);
1798
+ for (const row of bottom) {
1799
+ const match=/\b(?:esc to (?:interrupt|cancel)|tab to queue (?:a )?message)\b/iu.exec(row);
1800
+ if (match) return { indicator: match[0], row: row.trim().slice(0, 240) };
1801
+ }
1802
+ return null;
1803
+ }
1804
+ function composerBusy(screen) { return composerBusyEvidence(screen) !== null; }
1805
+
1806
+ /** Undo only text this input wrote, including when a turn started meanwhile.
1807
+ * Never Enter, Escape, or Ctrl-C: restoration must not submit or stop work. */
1808
+ export async function restoreComposerInput(host, sessionId, kind, text) {
1809
+ const read=async()=>composerText(kind,await host.capture(sessionId,{preserveEscapes:true}),{allowBusy:true});
1810
+ let remaining=await read();
1811
+ if(remaining==='')return {restored:true};
1812
+ if(!composerMatches(remaining,String(text).trim()))return {restored:false,reason:'composer-changed-or-unreadable'};
1813
+ const lines=remaining.split('\n').length+2;
1814
+ for(let line=0;line<lines;line++) {
1815
+ if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
1816
+ await host.sendInput(sessionId,'\u0005\u0015',{submitMode:'none'});
1810
1817
  await sleep(100);
1811
- const current = await read();
1812
- if (composerMatches(current, wanted)) { pasted = true; break; }
1813
- if (current !== '') return failed('paste-unconfirmed');
1814
- }
1815
- if (!pasted) return failed('paste-unconfirmed');
1816
- for (let attempt = 0; attempt < 3; attempt += 1) {
1817
- // Re-read immediately before each Enter, including the first one.
1818
- if (expired()) return failed('deadline');
1819
- if (!composerMatches(await read(), wanted)) return failed('composer-changed');
1820
- if (expired()) return failed('deadline');
1821
- // A keystream Enter has no hidden delay or retry between the read and the write.
1822
- await host.sendInput(sessionId, '\r', { submitMode: 'single', deadlineMs });
1823
- for (let poll = 0; poll < 10 && !expired(); poll += 1) {
1818
+ let after=await read();
1819
+ if(after==='')return {restored:true};
1820
+ if(after===null||!remaining.includes(after))return {restored:false,reason:'composer-changed',remaining};
1821
+ remaining=after;
1822
+ if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
1823
+ await host.sendInput(sessionId,'\u007f',{submitMode:'none'});
1824
+ await sleep(100);after=await read();
1825
+ if(after==='')return {restored:true};
1826
+ if(after===null||!remaining.replace(/\n/g,'').includes(after.replace(/\n/g,'')))return {restored:false,reason:'composer-changed',remaining};
1827
+ remaining=after;
1828
+ }
1829
+ return {restored:false,reason:'restore-unconfirmed',remaining};
1830
+ }
1831
+
1832
+ /** Paste only into a stable idle composer. A failed paste is restored; an
1833
+ * unreadable/concurrently changed composer returns an explicit repair obligation. */
1834
+ export async function deliverComposerInput(host, sessionId, kind, text, { deadlineMs = Date.now() + 10_000, signal } = {}) {
1835
+ const capture=()=>host.capture(sessionId,{preserveEscapes:true});
1836
+ const read=async()=>composerText(kind,await capture(),{allowBusy:true});
1837
+ const expired=()=>signal?.aborted||Date.now()>=deadlineMs;
1838
+ let touched=false;
1839
+ const failed=async (reason, evidence={})=>{
1840
+ if(!touched)return {delivered:false,reason,...evidence};
1841
+ let restored;
1842
+ try{restored=await restoreComposerInput(host,sessionId,kind,text);}catch(error){restored={restored:false,reason:error.message};}
1843
+ return {delivered:false,reason,...evidence,restored:restored.restored,cleanup_required:!restored.restored,cleanup_reason:restored.reason,cleanup_remaining:restored.remaining};
1844
+ };
1845
+ if(expired())return failed('deadline');
1846
+ const wanted=String(text).trim();
1847
+ if(!wanted||/[\x00-\x08\x0b-\x1f\x7f]/u.test(text))return failed('unsupported-text');
1848
+ for(let check=0;check<2;check++) {
1849
+ const screen=await capture(),before=composerText(kind,screen);
1850
+ if(composerBusy(screen))return failed('busy-composer',{phase:'before-paste',busy:composerBusyEvidence(screen)});
1851
+ if(before!=='')return failed(before===null?'no-composer':'draft');
1852
+ if(check===0)await sleep(200);
1853
+ }
1854
+ if(expired())return failed('deadline');
1855
+ try {
1856
+ touched=true;
1857
+ const delivery=await host.sendInput(sessionId,`${text}\n`,{submitMode:'none',deadlineMs});
1858
+ let pasted=false;
1859
+ for(let attempt=0;attempt<10&&!expired();attempt++) {
1824
1860
  await sleep(100);
1825
- const current = await read();
1826
- if (current === '') return { delivered: true, delivery };
1827
- if (!composerMatches(current, wanted)) return failed('submission-unconfirmed');
1861
+ if(composerMatches(await read(),wanted)){pasted=true;break;}
1828
1862
  }
1829
- }
1830
- return failed('submit-unconfirmed');
1863
+ if(!pasted)return failed('paste-unconfirmed');
1864
+ for(let attempt=0;attempt<3;attempt++) {
1865
+ if(expired())return failed('deadline');
1866
+ const screen=await capture();
1867
+ if(composerBusy(screen))return failed('busy-composer',{phase:'before-submit',busy:composerBusyEvidence(screen)});
1868
+ if(!composerMatches(composerText(kind,screen),wanted))return failed('composer-changed');
1869
+ if(expired())return failed('deadline');
1870
+ await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs});
1871
+ for(let poll=0;poll<10&&!expired();poll++) {
1872
+ await sleep(100);
1873
+ const current=await read();
1874
+ if(current==='')return {delivered:true,delivery};
1875
+ if(!composerMatches(current,wanted))return failed('submission-unconfirmed');
1876
+ }
1877
+ }
1878
+ return failed('submit-unconfirmed');
1879
+ }catch(error){return failed(error?.message??String(error));}
1831
1880
  }
1832
1881
 
1833
1882
  /** The carets each kind draws at the start of its composer line (Claude on Windows draws `>`). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-terminal",
3
- "version": "0.2.53",
3
+ "version": "0.2.55",
4
4
  "type": "module",
5
5
  "description": "Optional leak-safe terminal and tmux host adapter for Volter Harness frontends",
6
6
  "exports": {