@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 +4 -3
- package/index.mjs +88 -40
- package/package.json +1 -1
- package/substrate/tmux.js +2 -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
|
-
|
|
257
|
-
|
|
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*(
|
|
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
|
-
/**
|
|
1790
|
-
*
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
const
|
|
1800
|
-
|
|
1801
|
-
if
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
let
|
|
1805
|
-
|
|
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
|
-
|
|
1808
|
-
if
|
|
1809
|
-
if
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
if
|
|
1815
|
-
if
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
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 [
|