@volter/supercode-terminal 0.2.52 → 0.2.54

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/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
@@ -1,6 +1,6 @@
1
1
  import { execFile, spawn, spawnSync } from 'node:child_process';
2
2
  import { randomBytes, randomUUID } from 'node:crypto';
3
- import { appendFileSync, cpSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { connect as netConnect } from 'node:net';
@@ -514,11 +514,15 @@ for (const event of ['end', 'close', 'error']) process.stdin.on(event, sweep);
514
514
  * signal=…` when it failed), under SUPERCODE_HOME or ~/.config/supercode: the machine daemon tells a clean finish from
515
515
  * a failure by it, and a ConPTY pane that ended cleanly is otherwise gone without a trace.
516
516
  */
517
- function recordPaneExit(contextKey, exitCode, signal) {
517
+ function recordPaneExit(contextKey, exitCode, signal, screen = null) {
518
518
  if (!contextKey) return;
519
519
  const at = new Date().toISOString().slice(0, 19);
520
520
  const line = exitCode === 0 && !signal ? `${at} ${contextKey} closed\n` : `${at} ${contextKey} died status=${exitCode ?? ''} signal=${signal || ''}\n`;
521
521
  try { appendFileSync(join(process.env.SUPERCODE_HOME || join(homedir(), '.config', 'supercode'), 'pane-exits.log'), line); } catch { /* no log is no worse than before */ }
522
+ if ((exitCode !== 0 || signal) && typeof screen === 'string' && /^p_[A-Za-z0-9_-]+$/.test(contextKey)) {
523
+ try { const dir=join(process.env.SUPERCODE_HOME || join(homedir(), '.config', 'supercode'),'pane-exits');mkdirSync(dir,{recursive:true,mode:0o700});writeFileSync(join(dir,`${contextKey}.screen`),Buffer.from(screen).subarray(-32768),{mode:0o600}); }
524
+ catch { /* The receipt explicitly reports unavailable output. */ }
525
+ }
522
526
  }
523
527
 
524
528
  export class PtyTerminalHost {
@@ -597,7 +601,7 @@ export class PtyTerminalHost {
597
601
  // server) outlives it, as it outlives a tmux pane. What stayed in the pane's console ends with the console.
598
602
  // Only closing a pane, or its daemon dying, ends a whole tree.
599
603
  releasePane(child);
600
- recordPaneExit(entry.contextKey, exitCode, signal);
604
+ recordPaneExit(entry.contextKey, exitCode, signal, entry.serializer.serialize({scrollback:200}));
601
605
  this.#tellExit(entry);
602
606
  this.#writeWatchList();
603
607
  if (exitCode === 0) this.#forget(entry);
@@ -1734,12 +1738,13 @@ export function composerHoldsDraft(kind, screen) {
1734
1738
  /** Read only a bounded, live Claude/Codex composer, never a prompt in scrollback.
1735
1739
  * Unknown layouts and collapsed pastes are deliberately unconfirmed.
1736
1740
  */
1737
- export function composerText(kind, screen) {
1741
+ export function composerText(kind, screen, { allowBusy = false } = {}) {
1738
1742
  if (kind !== 'claude-code' && kind !== 'codex') return null;
1739
1743
  const lines = String(screen ?? '').split('\n').map(styledCells);
1740
1744
  const plain = lines.map((cells) => cells.map((cell) => cell.char).join(''));
1745
+ if (!allowBusy && composerBusy(screen)) return null;
1741
1746
  const rule = /^\s*[─━]{4,}\s*$/u;
1742
- 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;
1743
1748
  const caret = kind === 'codex' ? /^\s*›(?:\s|$)/u : /^\s*[❯>](?:\s|$)/u;
1744
1749
  for (let row = plain.length - 1; row >= 0; row -= 1) {
1745
1750
  if (!caret.test(plain[row])) continue;
@@ -1786,44 +1791,87 @@ function composerMatches(value, wanted) {
1786
1791
  return offsets.has(wanted.length);
1787
1792
  }
1788
1793
 
1789
- /** Paste once, then submit only text read back from the live composer.
1790
- * False means unconfirmed, and can leave a draft: callers must not blindly resend.
1791
- */
1792
- export async function deliverComposerInput(host, sessionId, kind, text, { deadlineMs = Date.now() + 10_000, signal } = {}) {
1793
- const read = async () => composerText(kind, await host.capture(sessionId, { preserveEscapes: true }));
1794
- const expired = () => signal?.aborted || Date.now() >= deadlineMs;
1795
- const failed = (reason) => ({ delivered: false, reason });
1796
- if (expired()) return failed('deadline');
1797
- const before = await read();
1798
- if (before !== '') return failed(before === null ? 'no-composer' : 'draft');
1799
- const wanted = String(text).trim();
1800
- if (!wanted || /[\x00-\x08\x0b-\x1f\x7f]/u.test(text)) return failed('unsupported-text');
1801
- if (expired()) return failed('deadline');
1802
- const delivery = await host.sendInput(sessionId, `${text}\n`, { submitMode: 'none', deadlineMs });
1803
- // A dropped paste must never be followed by Enter. Give the editor time to render it.
1804
- let pasted = false;
1805
- 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 composerBusy(screen) {
1797
+ const bottom=String(screen??'').split('\n').map(line=>styledCells(line).map(cell=>cell.char).join('')).filter(line=>line.trim()).slice(-8).join('\n');
1798
+ return /\b(?:esc to (?:interrupt|cancel)|tab to queue (?:a )?message)\b/iu.test(bottom);
1799
+ }
1800
+
1801
+ /** Undo only text this input wrote, including when a turn started meanwhile.
1802
+ * Never Enter, Escape, or Ctrl-C: restoration must not submit or stop work. */
1803
+ export async function restoreComposerInput(host, sessionId, kind, text) {
1804
+ const read=async()=>composerText(kind,await host.capture(sessionId,{preserveEscapes:true}),{allowBusy:true});
1805
+ let remaining=await read();
1806
+ if(remaining==='')return {restored:true};
1807
+ if(!composerMatches(remaining,String(text).trim()))return {restored:false,reason:'composer-changed-or-unreadable'};
1808
+ const lines=remaining.split('\n').length+2;
1809
+ for(let line=0;line<lines;line++) {
1810
+ if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
1811
+ await host.sendInput(sessionId,'\u0005\u0015',{submitMode:'none'});
1806
1812
  await sleep(100);
1807
- const current = await read();
1808
- if (composerMatches(current, wanted)) { pasted = true; break; }
1809
- if (current !== '') return failed('paste-unconfirmed');
1810
- }
1811
- if (!pasted) return failed('paste-unconfirmed');
1812
- for (let attempt = 0; attempt < 3; attempt += 1) {
1813
- // Re-read immediately before each Enter, including the first one.
1814
- if (expired()) return failed('deadline');
1815
- if (!composerMatches(await read(), wanted)) return failed('composer-changed');
1816
- if (expired()) return failed('deadline');
1817
- // A keystream Enter has no hidden delay or retry between the read and the write.
1818
- await host.sendInput(sessionId, '\r', { submitMode: 'single', deadlineMs });
1819
- for (let poll = 0; poll < 10 && !expired(); poll += 1) {
1813
+ let after=await read();
1814
+ if(after==='')return {restored:true};
1815
+ if(after===null||!remaining.includes(after))return {restored:false,reason:'composer-changed',remaining};
1816
+ remaining=after;
1817
+ if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
1818
+ await host.sendInput(sessionId,'\u007f',{submitMode:'none'});
1819
+ await sleep(100);after=await read();
1820
+ if(after==='')return {restored:true};
1821
+ if(after===null||!remaining.replace(/\n/g,'').includes(after.replace(/\n/g,'')))return {restored:false,reason:'composer-changed',remaining};
1822
+ remaining=after;
1823
+ }
1824
+ return {restored:false,reason:'restore-unconfirmed',remaining};
1825
+ }
1826
+
1827
+ /** Paste only into a stable idle composer. A failed paste is restored; an
1828
+ * unreadable/concurrently changed composer returns an explicit repair obligation. */
1829
+ export async function deliverComposerInput(host, sessionId, kind, text, { deadlineMs = Date.now() + 10_000, signal } = {}) {
1830
+ const capture=()=>host.capture(sessionId,{preserveEscapes:true});
1831
+ const read=async()=>composerText(kind,await capture(),{allowBusy:true});
1832
+ const expired=()=>signal?.aborted||Date.now()>=deadlineMs;
1833
+ let touched=false;
1834
+ const failed=async reason=>{
1835
+ if(!touched)return {delivered:false,reason};
1836
+ let restored;
1837
+ try{restored=await restoreComposerInput(host,sessionId,kind,text);}catch(error){restored={restored:false,reason:error.message};}
1838
+ return {delivered:false,reason,restored:restored.restored,cleanup_required:!restored.restored,cleanup_reason:restored.reason,cleanup_remaining:restored.remaining};
1839
+ };
1840
+ if(expired())return failed('deadline');
1841
+ const wanted=String(text).trim();
1842
+ if(!wanted||/[\x00-\x08\x0b-\x1f\x7f]/u.test(text))return failed('unsupported-text');
1843
+ for(let check=0;check<2;check++) {
1844
+ const screen=await capture(),before=composerText(kind,screen);
1845
+ if(composerBusy(screen))return failed('busy-composer');
1846
+ if(before!=='')return failed(before===null?'no-composer':'draft');
1847
+ if(check===0)await sleep(200);
1848
+ }
1849
+ if(expired())return failed('deadline');
1850
+ try {
1851
+ touched=true;
1852
+ const delivery=await host.sendInput(sessionId,`${text}\n`,{submitMode:'none',deadlineMs});
1853
+ let pasted=false;
1854
+ for(let attempt=0;attempt<10&&!expired();attempt++) {
1820
1855
  await sleep(100);
1821
- const current = await read();
1822
- if (current === '') return { delivered: true, delivery };
1823
- if (!composerMatches(current, wanted)) return failed('submission-unconfirmed');
1856
+ if(composerMatches(await read(),wanted)){pasted=true;break;}
1824
1857
  }
1825
- }
1826
- return failed('submit-unconfirmed');
1858
+ if(!pasted)return failed('paste-unconfirmed');
1859
+ for(let attempt=0;attempt<3;attempt++) {
1860
+ if(expired())return failed('deadline');
1861
+ const screen=await capture();
1862
+ if(composerBusy(screen))return failed('busy-composer');
1863
+ if(!composerMatches(composerText(kind,screen),wanted))return failed('composer-changed');
1864
+ if(expired())return failed('deadline');
1865
+ await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs});
1866
+ for(let poll=0;poll<10&&!expired();poll++) {
1867
+ await sleep(100);
1868
+ const current=await read();
1869
+ if(current==='')return {delivered:true,delivery};
1870
+ if(!composerMatches(current,wanted))return failed('submission-unconfirmed');
1871
+ }
1872
+ }
1873
+ return failed('submit-unconfirmed');
1874
+ }catch(error){return failed(error?.message??String(error));}
1827
1875
  }
1828
1876
 
1829
1877
  /** 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.52",
3
+ "version": "0.2.54",
4
4
  "type": "module",
5
5
  "description": "Optional leak-safe terminal and tmux host adapter for Volter Harness frontends",
6
6
  "exports": {
package/substrate/tmux.js CHANGED
@@ -343,7 +343,8 @@ async function declareSessionColorEnvironmentAsync(socket) {
343
343
  // Targets are not expanded as formats, so the shell that run-shell starts resolves
344
344
  // the pane and does nothing when it no longer belongs to a p_* session.
345
345
  const PANE_EXIT_LOG = '"${SUPERCODE_HOME:-$HOME/.config/supercode}/pane-exits.log"';
346
- const PANE_DIED_HOOK = "run-shell 'p=\"#{hook_pane}\"; t() { tmux -S \"#{socket_path}\" \"$@\"; }; s=$(t display -p -t \"$p\" \"##{session_name}\" 2>/dev/null) || exit 0; case \"$s\" in p_*) ;; *) exit 0 ;; esac; printf \"%s %s died status=%s signal=%s\\n\" \"$(date +%FT%T)\" \"$s\" \"$(t display -p -t \"$p\" \"##{pane_dead_status}\")\" \"$(t display -p -t \"$p\" \"##{pane_dead_signal}\")\" >> " + PANE_EXIT_LOG + "; t kill-pane -t \"$p\"'";
346
+ const PANE_EXIT_CAPTURE = 'case "$s" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac; d="${SUPERCODE_HOME:-$HOME/.config/supercode}/pane-exits"; umask 077; mkdir -p "$d"; t capture-pane -p -e -S -200 -t "$p" 2>/dev/null | tail -c 32768 > "$d/$s.screen"';
347
+ const PANE_DIED_HOOK = "run-shell 'p=\"#{hook_pane}\"; t() { tmux -S \"#{socket_path}\" \"$@\"; }; s=$(t display -p -t \"$p\" \"##{session_name}\" 2>/dev/null) || exit 0; case \"$s\" in p_*) ;; *) exit 0 ;; esac; printf \"%s %s died status=%s signal=%s\\n\" \"$(date +%FT%T)\" \"$s\" \"$(t display -p -t \"$p\" \"##{pane_dead_status}\")\" \"$(t display -p -t \"$p\" \"##{pane_dead_signal}\")\" >> " + PANE_EXIT_LOG + "; " + PANE_EXIT_CAPTURE + "; t kill-pane -t \"$p\"'";
347
348
  const SESSION_CLOSED_HOOK = "if-shell -F '#{m:p_*,#{hook_session_name}}' { run-shell 'printf \"%s %s closed\\n\" \"$(date +%FT%T)\" \"#{hook_session_name}\" >> " + PANE_EXIT_LOG + "' }";
348
349
  function sessionServerOptionArgs() {
349
350
  return [