@volter/supercode-terminal 0.2.45 → 0.2.46

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
@@ -56,7 +56,7 @@ export class TmuxTerminalHost {
56
56
  sendInput(
57
57
  sessionId: string,
58
58
  data: string,
59
- options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' },
59
+ options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' },
60
60
  ): Promise<TerminalInputDelivery>;
61
61
  /** Persist an opaque application context association on a session owned by this host. */
62
62
  bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
@@ -81,7 +81,7 @@ export class PaneServerHost {
81
81
  listSessions(): Promise<TerminalSession[]>;
82
82
  createSession(input: { command: TmuxCommand; contextKey?: string; cwd?: string; env?: Record<string, string>; name?: string }): Promise<TerminalSession>;
83
83
  capture(sessionId: string, options?: { lines?: number; preserveEscapes?: boolean }): Promise<string>;
84
- sendInput(sessionId: string, data: string, options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' }): Promise<TerminalInputDelivery>;
84
+ sendInput(sessionId: string, data: string, options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' }): Promise<TerminalInputDelivery>;
85
85
  bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
86
86
  renameWindow(sessionId: string, title?: string | null): Promise<string>;
87
87
  issueAttachment(sessionId: string, options?: { mode?: 'observe' | 'control' }): Promise<TerminalAttachmentGrant>;
@@ -111,7 +111,7 @@ export class PtyTerminalHost {
111
111
  sendInput(
112
112
  sessionId: string,
113
113
  data: string,
114
- options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' },
114
+ options?: { deadlineMs?: number; submitMode?: 'retry' | 'single' | 'none' },
115
115
  ): Promise<TerminalInputDelivery>;
116
116
  bindContext(sessionId: string, contextKey: string): Promise<TerminalSession>;
117
117
  /** A ConPTY pane has no window to rename; returns its label. */
@@ -250,3 +250,8 @@ export type {
250
250
  } from './substrate/session-lifecycle.js';
251
251
 
252
252
  export type { TerminalInputDelivery, TmuxCommand, TmuxWindowSize };
253
+
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 }>;
package/index.mjs CHANGED
@@ -437,9 +437,8 @@ export class TmuxTerminalHost {
437
437
  }
438
438
  }
439
439
 
440
- // The submit cadence tmux.js keeps for a submitted line: one Enter after the paste settles, or three spaced ones.
440
+ // Raw terminal writes submit once. Only a composer readback can authorize a retry.
441
441
  const SINGLE_SUBMIT_DELAY_MS = 500;
442
- const SUBMITTED_LINE_DELAYS_MS = [500, 1500, 3500];
443
442
  const PTY_DEFAULT_SIZE = Object.freeze({ cols: 80, rows: 24 });
444
443
  const PTY_SCROLLBACK = 5_000;
445
444
  // How long one read of the Windows process table answers every pane's activeCommand: the read costs a
@@ -650,7 +649,7 @@ export class PtyTerminalHost {
650
649
  this.#live(entry.id).child.write(bytes);
651
650
  };
652
651
  const submit = async () => {
653
- for (const delayMs of options.submitMode === 'single' ? [SINGLE_SUBMIT_DELAY_MS] : SUBMITTED_LINE_DELAYS_MS) {
652
+ for (const delayMs of options.submitMode === 'none' ? [] : [SINGLE_SUBMIT_DELAY_MS]) {
654
653
  await sleep(delayMs);
655
654
  write('\r');
656
655
  }
@@ -663,7 +662,7 @@ export class PtyTerminalHost {
663
662
  await submit();
664
663
  return 'submitted-paste';
665
664
  }
666
- await submit();
665
+ if (options.submitMode !== 'none') write('\r');
667
666
  return 'submitted-line';
668
667
  }
669
668
  // tmux sends a newline in a keystream as Enter.
@@ -1547,16 +1546,15 @@ export class SupercodeTerminalController {
1547
1546
  const task = (async () => {
1548
1547
  const deadline = Date.now() + 5 * 60_000;
1549
1548
  while (!abort.signal.aborted && Date.now() < deadline) {
1550
- const content = this.#host.capture(sessionId, { lines: 80 });
1549
+ const content = await this.#host.capture(sessionId, { preserveEscapes: true });
1551
1550
  if (composerReadyForInput(harness, content) === true) {
1552
- if (harness === 'codex') {
1553
- await this.#submitCodexInitialInput(sessionId, input, abort.signal);
1554
- } else {
1555
- await this.#host.sendInput(sessionId, input, {
1556
- deadlineMs: Date.now() + 15_000,
1557
- submitMode: 'retry',
1558
- });
1551
+ if (harness !== 'claude-code' && harness !== 'codex') {
1552
+ await this.#host.sendInput(sessionId, input, { submitMode: 'single', deadlineMs: Date.now() + 10_000 });
1553
+ return;
1559
1554
  }
1555
+ const result = await deliverComposerInput(this.#host, sessionId, harness,
1556
+ input.replace(/(?:\r\n|\n|\r)$/u, ''), { signal: abort.signal });
1557
+ if (!result.delivered) throw new Error(result.reason);
1560
1558
  return;
1561
1559
  }
1562
1560
  await new Promise((resolve) => setTimeout(resolve, 250));
@@ -1574,28 +1572,6 @@ export class SupercodeTerminalController {
1574
1572
  this.#pendingInitialInputs.set(sessionId, { abort, task });
1575
1573
  }
1576
1574
 
1577
- async #submitCodexInitialInput(sessionId, input, signal) {
1578
- await this.#host.sendInput(sessionId, input, {
1579
- deadlineMs: Date.now() + 15_000,
1580
- submitMode: 'single',
1581
- });
1582
- const deadline = Date.now() + 60_000;
1583
- let sawPrompt = false;
1584
- while (!signal.aborted && Date.now() < deadline) {
1585
- await new Promise((resolve) => setTimeout(resolve, 500));
1586
- const state = codexInitialInputState(this.#host.capture(sessionId, { lines: 100 }), input);
1587
- sawPrompt ||= state.promptVisible;
1588
- if (state.turnStarted || (sawPrompt && state.emptyComposerVisible)) return;
1589
- await this.#host.sendInput(sessionId, '\n', {
1590
- deadlineMs: Date.now() + 15_000,
1591
- submitMode: 'retry',
1592
- });
1593
- }
1594
- if (!signal.aborted) {
1595
- throw new Error('codex accepted the initial terminal text but did not start the turn');
1596
- }
1597
- }
1598
-
1599
1575
  #sessionName(harness) {
1600
1576
  const label = harness === 'claude-code' ? 'claude' : harness;
1601
1577
  return `${this.#sessionNamePrefix}-${label}-${randomUUID().slice(0, 8)}`;
@@ -1708,6 +1684,7 @@ function composerLine(screen, caret) {
1708
1684
  */
1709
1685
  export function composerReadyForInput(kind, screen) {
1710
1686
  if (!Object.hasOwn(COMPOSER_READY, kind)) return null;
1687
+ if (kind === 'claude-code' || kind === 'codex') return composerText(kind, screen) === '';
1711
1688
  return COMPOSER_READY[kind](String(screen ?? ''));
1712
1689
  }
1713
1690
 
@@ -1724,6 +1701,10 @@ export function composerReadyForInput(kind, screen) {
1724
1701
  * The caret is the kind's own, the one `composerReadyForInput` reads.
1725
1702
  */
1726
1703
  export function composerHoldsDraft(kind, screen) {
1704
+ if (kind === 'claude-code' || kind === 'codex') {
1705
+ const text = composerText(kind, screen);
1706
+ return text === null ? null : text.length > 0;
1707
+ }
1727
1708
  const carets = COMPOSER_CARETS[kind];
1728
1709
  if (!carets) return null;
1729
1710
  const lines = String(screen ?? '').split('\n');
@@ -1747,6 +1728,72 @@ export function composerHoldsDraft(kind, screen) {
1747
1728
  return null;
1748
1729
  }
1749
1730
 
1731
+ /** Read only a bounded, live Claude/Codex composer, never a prompt in scrollback.
1732
+ * Unknown layouts and collapsed pastes are deliberately unconfirmed.
1733
+ */
1734
+ export function composerText(kind, screen) {
1735
+ if (kind !== 'claude-code' && kind !== 'codex') return null;
1736
+ const lines = String(screen ?? '').split('\n').map(styledCells);
1737
+ const plain = lines.map((cells) => cells.map((cell) => cell.char).join(''));
1738
+ const rule = /^\s*[─━]{4,}\s*$/u;
1739
+ const caret = kind === 'codex' ? /^\s*›(?:\s|$)/u : /^\s*[❯>](?:\s|$)/u;
1740
+ for (let row = plain.length - 1; row >= 0; row -= 1) {
1741
+ if (!caret.test(plain[row])) continue;
1742
+ let end = row + 1;
1743
+ while (end < plain.length && !rule.test(plain[end]) &&
1744
+ !/^\s*(?:\? for shortcuts|.*\bcontext left\b)/u.test(plain[end])) end += 1;
1745
+ // Claude encloses the editor in rules; Codex ends it at its shortcut/context footer.
1746
+ if (end === plain.length) return null;
1747
+ if (kind === 'claude-code' && (row === 0 || !rule.test(plain[row - 1]))) return null;
1748
+ const first = plain[row].match(caret)[0].length;
1749
+ const value = lines.slice(row, end).map((cells, index) => cells
1750
+ .slice(index === 0 || /^\s*$/u.test(plain[row + index].slice(0, first)) ? first : 0).filter((cell) => !cell.placeholder)
1751
+ .map((cell) => cell.char).join('').trimEnd()).join('\n').trim();
1752
+ return value;
1753
+ }
1754
+ return null;
1755
+ }
1756
+
1757
+ /** Paste once, then submit only text read back from the live composer.
1758
+ * False means unconfirmed, and can leave a draft: callers must not blindly resend.
1759
+ */
1760
+ export async function deliverComposerInput(host, sessionId, kind, text, { deadlineMs = Date.now() + 10_000, signal } = {}) {
1761
+ const read = async () => composerText(kind, await host.capture(sessionId, { preserveEscapes: true }));
1762
+ const expired = () => signal?.aborted || Date.now() >= deadlineMs;
1763
+ const failed = (reason) => ({ delivered: false, reason });
1764
+ if (expired()) return failed('deadline');
1765
+ const before = await read();
1766
+ if (before !== '') return failed(before === null ? 'no-composer' : 'draft');
1767
+ const wanted = String(text).trim();
1768
+ if (!wanted || /[\x00-\x08\x0b-\x1f\x7f]/u.test(text)) return failed('unsupported-text');
1769
+ if (expired()) return failed('deadline');
1770
+ const delivery = await host.sendInput(sessionId, `${text}\n`, { submitMode: 'none', deadlineMs });
1771
+ // A dropped paste must never be followed by Enter. Give the editor time to render it.
1772
+ let pasted = false;
1773
+ for (let attempt = 0; attempt < 10 && !expired(); attempt += 1) {
1774
+ await sleep(100);
1775
+ const current = await read();
1776
+ if (current === wanted) { pasted = true; break; }
1777
+ if (current !== '') return failed('paste-unconfirmed');
1778
+ }
1779
+ if (!pasted) return failed('paste-unconfirmed');
1780
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1781
+ // Re-read immediately before each Enter, including the first one.
1782
+ if (expired()) return failed('deadline');
1783
+ if (await read() !== wanted) return failed('composer-changed');
1784
+ if (expired()) return failed('deadline');
1785
+ // A keystream Enter has no hidden delay or retry between the read and the write.
1786
+ await host.sendInput(sessionId, '\r', { submitMode: 'single', deadlineMs });
1787
+ for (let poll = 0; poll < 10 && !expired(); poll += 1) {
1788
+ await sleep(100);
1789
+ const current = await read();
1790
+ if (current === '') return { delivered: true, delivery };
1791
+ if (current !== wanted) return failed('submission-unconfirmed');
1792
+ }
1793
+ }
1794
+ return failed('submit-unconfirmed');
1795
+ }
1796
+
1750
1797
  /** The carets each kind draws at the start of its composer line (Claude on Windows draws `>`). */
1751
1798
  const COMPOSER_CARETS = Object.freeze({ 'claude-code': ['❯', '>'], codex: ['›'], gemini: ['>'], grok: ['❯'] });
1752
1799
 
@@ -1782,19 +1829,6 @@ function styledCells(line) {
1782
1829
  return cells;
1783
1830
  }
1784
1831
 
1785
- function codexInitialInputState(content, input) {
1786
- const firstLine = input.replace(/(?:\r\n|\n|\r)$/u, '').split(/\r?\n/u, 1)[0] ?? '';
1787
- const marker = `› ${firstLine.slice(0, 24)}`;
1788
- const promptAt = marker.length > 2 ? content.lastIndexOf(marker) : -1;
1789
- const emptyComposerAt = content.lastIndexOf('› Ask Codex to do anything');
1790
- const tail = promptAt >= 0 ? content.slice(promptAt + marker.length) : '';
1791
- return {
1792
- emptyComposerVisible: emptyComposerAt > promptAt,
1793
- promptVisible: promptAt >= 0,
1794
- turnStarted: promptAt >= 0 && /(?:^|\n)\s*[•◦✦⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s/u.test(tail),
1795
- };
1796
- }
1797
-
1798
1832
  function validSessionPrefix(value) {
1799
1833
  if (!/^[A-Za-z0-9_-]{1,32}$/.test(value)) {
1800
1834
  throw new TerminalHostError(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-terminal",
3
- "version": "0.2.45",
3
+ "version": "0.2.46",
4
4
  "type": "module",
5
5
  "description": "Optional leak-safe terminal and tmux host adapter for Volter Harness frontends",
6
6
  "exports": {
@@ -128,11 +128,12 @@ export declare function sendInterruptAsync({ socket, target }: {
128
128
  target: string;
129
129
  socket?: string;
130
130
  }): Promise<void>;
131
+ /** `none` pastes without Enter. Legacy `retry` submits once; retries require composer evidence. */
131
132
  export declare function sendInputAsync({ data, deadlineMs, socket, submitMode, target }: {
132
133
  target: string;
133
134
  data: string;
134
135
  deadlineMs?: number;
135
- submitMode?: "retry" | "single";
136
+ submitMode?: "retry" | "single" | "none";
136
137
  socket?: string;
137
138
  }): Promise<TerminalInputDelivery>;
138
139
  export declare function attachSession({ name, socket, window }: {
package/substrate/tmux.js CHANGED
@@ -748,6 +748,7 @@ export async function sendInterruptAsync({ socket, target }) {
748
748
  }
749
749
  await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Escape"]));
750
750
  }
751
+ /** `none` pastes without Enter. Legacy `retry` submits once; retries require composer evidence. */
751
752
  export async function sendInputAsync({ data, deadlineMs, socket, submitMode = "retry", target }) {
752
753
  assertTmux();
753
754
  if (!target) {
@@ -768,7 +769,8 @@ export async function sendInputAsync({ data, deadlineMs, socket, submitMode = "r
768
769
  await sendSubmittedPasteAsync({ deadlineMs, socket, submitMode, target, text: submittedText });
769
770
  return "submitted-paste";
770
771
  }
771
- await sendSubmitKeysAsync({ deadlineMs, socket, submitMode, target });
772
+ if (submitMode !== "none")
773
+ await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
772
774
  return "submitted-line";
773
775
  }
774
776
  for (const token of tokenizeTerminalInput(data)) {
@@ -790,7 +792,6 @@ export function attachSession({ name, socket, window }) {
790
792
  spawnInherited("tmux", tmuxArgs(socket, ["attach-session", "-t", target]));
791
793
  }
792
794
  const SINGLE_SUBMIT_DELAY_MS = 500;
793
- const SUBMITTED_LINE_DELAYS_MS = [500, 1500, 3500];
794
795
  async function sleepAsync(ms) {
795
796
  await new Promise((resolve) => setTimeout(resolve, ms));
796
797
  }
@@ -834,15 +835,11 @@ async function sendSubmittedPasteAsync({ deadlineMs, socket, submitMode, target,
834
835
  await sendSubmitKeysAsync({ deadlineMs, socket, submitMode, target });
835
836
  }
836
837
  async function sendSubmitKeysAsync({ deadlineMs, socket, submitMode, target }) {
837
- if (submitMode === "single") {
838
- await sleepAsync(SINGLE_SUBMIT_DELAY_MS);
839
- await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
838
+ // Retrying Enter requires a composer readback; raw terminal writes cannot do that.
839
+ if (submitMode === "none")
840
840
  return;
841
- }
842
- for (const delayMs of SUBMITTED_LINE_DELAYS_MS) {
843
- await sleepAsync(delayMs);
844
- await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
845
- }
841
+ await sleepAsync(SINGLE_SUBMIT_DELAY_MS);
842
+ await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
846
843
  }
847
844
  function terminalInputRunOptions(deadlineMs) {
848
845
  if (deadlineMs === undefined)