@volter/supercode-terminal 0.2.44 → 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.44",
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
@@ -344,12 +344,15 @@ function sessionServerOptionArgs() {
344
344
  return [
345
345
  "set-option", "-g", "history-limit", "50000", ";",
346
346
  "set-option", "-g", "mouse", "on", ";",
347
- // The relay clients (tmux-stream attaches as screen-256color; a person's own
348
- // terminal attaches as something else) draw into the viewer's normal screen,
349
- // so lines that scroll off land in the viewer's own scrollback, and they
350
- // pass clipboard, focus and extended keys through. Indexed, so re-applying
351
- // on every create replaces one entry instead of appending.
352
- "set-option", "-s", "terminal-overrides[90]", "screen-256color:smcup@:rmcup@", ";",
347
+ // Every client draws into the viewer's normal screen, so lines that scroll
348
+ // off land in the viewer's own scrollback and the wheel scrolls it; on the
349
+ // alternate screen a terminal turns the wheel into up/down arrows. That is
350
+ // every client, not only the relay's screen-256color: a person's own
351
+ // terminal, or a window a daemon opened, attaches as whatever TERM it sets.
352
+ // The relay clients also pass clipboard, focus and extended keys through.
353
+ // Indexed, so re-applying on every create replaces one entry instead of
354
+ // appending.
355
+ "set-option", "-s", "terminal-overrides[90]", "*:smcup@:rmcup@", ";",
353
356
  "set-option", "-s", "terminal-features[90]", "screen-256color:clipboard:focus:extkeys:title", ";",
354
357
  // Harness TUIs (Claude Code) track focus; tmux forwards focus in/out only with this on.
355
358
  "set-option", "-s", "focus-events", "on", ";",
@@ -745,6 +748,7 @@ export async function sendInterruptAsync({ socket, target }) {
745
748
  }
746
749
  await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Escape"]));
747
750
  }
751
+ /** `none` pastes without Enter. Legacy `retry` submits once; retries require composer evidence. */
748
752
  export async function sendInputAsync({ data, deadlineMs, socket, submitMode = "retry", target }) {
749
753
  assertTmux();
750
754
  if (!target) {
@@ -765,7 +769,8 @@ export async function sendInputAsync({ data, deadlineMs, socket, submitMode = "r
765
769
  await sendSubmittedPasteAsync({ deadlineMs, socket, submitMode, target, text: submittedText });
766
770
  return "submitted-paste";
767
771
  }
768
- await sendSubmitKeysAsync({ deadlineMs, socket, submitMode, target });
772
+ if (submitMode !== "none")
773
+ await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
769
774
  return "submitted-line";
770
775
  }
771
776
  for (const token of tokenizeTerminalInput(data)) {
@@ -787,7 +792,6 @@ export function attachSession({ name, socket, window }) {
787
792
  spawnInherited("tmux", tmuxArgs(socket, ["attach-session", "-t", target]));
788
793
  }
789
794
  const SINGLE_SUBMIT_DELAY_MS = 500;
790
- const SUBMITTED_LINE_DELAYS_MS = [500, 1500, 3500];
791
795
  async function sleepAsync(ms) {
792
796
  await new Promise((resolve) => setTimeout(resolve, ms));
793
797
  }
@@ -831,15 +835,11 @@ async function sendSubmittedPasteAsync({ deadlineMs, socket, submitMode, target,
831
835
  await sendSubmitKeysAsync({ deadlineMs, socket, submitMode, target });
832
836
  }
833
837
  async function sendSubmitKeysAsync({ deadlineMs, socket, submitMode, target }) {
834
- if (submitMode === "single") {
835
- await sleepAsync(SINGLE_SUBMIT_DELAY_MS);
836
- 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")
837
840
  return;
838
- }
839
- for (const delayMs of SUBMITTED_LINE_DELAYS_MS) {
840
- await sleepAsync(delayMs);
841
- await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
842
- }
841
+ await sleepAsync(SINGLE_SUBMIT_DELAY_MS);
842
+ await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
843
843
  }
844
844
  function terminalInputRunOptions(deadlineMs) {
845
845
  if (deadlineMs === undefined)