@volter/supercode-terminal 0.2.45 → 0.2.47
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 +8 -3
- package/index.mjs +113 -48
- package/package.json +1 -1
- package/substrate/tmux.d.ts +2 -1
- package/substrate/tmux.js +7 -10
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
|
-
//
|
|
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 === '
|
|
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
|
-
|
|
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.
|
|
@@ -1546,17 +1545,19 @@ export class SupercodeTerminalController {
|
|
|
1546
1545
|
const abort = new AbortController();
|
|
1547
1546
|
const task = (async () => {
|
|
1548
1547
|
const deadline = Date.now() + 5 * 60_000;
|
|
1548
|
+
let readySince = null;
|
|
1549
1549
|
while (!abort.signal.aborted && Date.now() < deadline) {
|
|
1550
|
-
const content = this.#host.capture(sessionId, {
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
await this.#host.sendInput(sessionId, input, {
|
|
1556
|
-
|
|
1557
|
-
submitMode: 'retry',
|
|
1558
|
-
});
|
|
1550
|
+
const content = await this.#host.capture(sessionId, { preserveEscapes: true });
|
|
1551
|
+
const ready = composerReadyForInput(harness, content) === true;
|
|
1552
|
+
readySince = ready ? (readySince ?? Date.now()) : null;
|
|
1553
|
+
if (ready && Date.now() - readySince >= 1_000) {
|
|
1554
|
+
if (harness !== 'claude-code' && harness !== 'codex') {
|
|
1555
|
+
await this.#host.sendInput(sessionId, input, { submitMode: 'single', deadlineMs: Date.now() + 10_000 });
|
|
1556
|
+
return;
|
|
1559
1557
|
}
|
|
1558
|
+
const result = await deliverComposerInput(this.#host, sessionId, harness,
|
|
1559
|
+
input.replace(/(?:\r\n|\n|\r)$/u, ''), { signal: abort.signal });
|
|
1560
|
+
if (!result.delivered) throw new Error(result.reason);
|
|
1560
1561
|
return;
|
|
1561
1562
|
}
|
|
1562
1563
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
@@ -1574,28 +1575,6 @@ export class SupercodeTerminalController {
|
|
|
1574
1575
|
this.#pendingInitialInputs.set(sessionId, { abort, task });
|
|
1575
1576
|
}
|
|
1576
1577
|
|
|
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
1578
|
#sessionName(harness) {
|
|
1600
1579
|
const label = harness === 'claude-code' ? 'claude' : harness;
|
|
1601
1580
|
return `${this.#sessionNamePrefix}-${label}-${randomUUID().slice(0, 8)}`;
|
|
@@ -1708,6 +1687,7 @@ function composerLine(screen, caret) {
|
|
|
1708
1687
|
*/
|
|
1709
1688
|
export function composerReadyForInput(kind, screen) {
|
|
1710
1689
|
if (!Object.hasOwn(COMPOSER_READY, kind)) return null;
|
|
1690
|
+
if (kind === 'claude-code' || kind === 'codex') return composerText(kind, screen) === '';
|
|
1711
1691
|
return COMPOSER_READY[kind](String(screen ?? ''));
|
|
1712
1692
|
}
|
|
1713
1693
|
|
|
@@ -1724,6 +1704,10 @@ export function composerReadyForInput(kind, screen) {
|
|
|
1724
1704
|
* The caret is the kind's own, the one `composerReadyForInput` reads.
|
|
1725
1705
|
*/
|
|
1726
1706
|
export function composerHoldsDraft(kind, screen) {
|
|
1707
|
+
if (kind === 'claude-code' || kind === 'codex') {
|
|
1708
|
+
const text = composerText(kind, screen);
|
|
1709
|
+
return text === null ? null : text.length > 0;
|
|
1710
|
+
}
|
|
1727
1711
|
const carets = COMPOSER_CARETS[kind];
|
|
1728
1712
|
if (!carets) return null;
|
|
1729
1713
|
const lines = String(screen ?? '').split('\n');
|
|
@@ -1747,6 +1731,100 @@ export function composerHoldsDraft(kind, screen) {
|
|
|
1747
1731
|
return null;
|
|
1748
1732
|
}
|
|
1749
1733
|
|
|
1734
|
+
/** Read only a bounded, live Claude/Codex composer, never a prompt in scrollback.
|
|
1735
|
+
* Unknown layouts and collapsed pastes are deliberately unconfirmed.
|
|
1736
|
+
*/
|
|
1737
|
+
export function composerText(kind, screen) {
|
|
1738
|
+
if (kind !== 'claude-code' && kind !== 'codex') return null;
|
|
1739
|
+
const lines = String(screen ?? '').split('\n').map(styledCells);
|
|
1740
|
+
const plain = lines.map((cells) => cells.map((cell) => cell.char).join(''));
|
|
1741
|
+
const rule = /^\s*[─━]{4,}\s*$/u;
|
|
1742
|
+
const footer = /^\s*(?:\? for shortcuts\b|[^\n]+ · (?:~[\\/]|[\\/]|[A-Z]:[\\/])[^\n]+$|.*← for agents\s*$|.*\bcontext(?:\s+\d+%)?\s+left\b)/iu;
|
|
1743
|
+
const caret = kind === 'codex' ? /^\s*›(?:\s|$)/u : /^\s*[❯>](?:\s|$)/u;
|
|
1744
|
+
for (let row = plain.length - 1; row >= 0; row -= 1) {
|
|
1745
|
+
if (!caret.test(plain[row])) continue;
|
|
1746
|
+
let end = row + 1;
|
|
1747
|
+
while (end < plain.length && !(kind === 'codex' ? footer.test(plain[end]) : rule.test(plain[end]))) end += 1;
|
|
1748
|
+
// Claude encloses the editor in rules; Codex ends it at its shortcut/context footer.
|
|
1749
|
+
if (end === plain.length) return null;
|
|
1750
|
+
// Startup can draw a caret before loading finishes or a modal opens. A rule
|
|
1751
|
+
// alone is not Codex's ready editor; require its footer at the live bottom.
|
|
1752
|
+
if (kind === 'codex' && (plain.slice(end + 1).some((line) => line.trim()) ||
|
|
1753
|
+
plain.some((line) => /\bmodel:\s+loading\b/iu.test(line)))) return null;
|
|
1754
|
+
if (kind === 'claude-code' && (row === 0 || !rule.test(plain[row - 1]))) return null;
|
|
1755
|
+
const first = plain[row].match(caret)[0].length;
|
|
1756
|
+
const value = lines.slice(row, end).map((cells, index) => cells
|
|
1757
|
+
.slice(index === 0 || /^\s*$/u.test(plain[row + index].slice(0, first)) ? first : 0).filter((cell) => !cell.placeholder)
|
|
1758
|
+
.map((cell) => cell.char).join('').trimEnd()).join('\n').trim();
|
|
1759
|
+
return value;
|
|
1760
|
+
}
|
|
1761
|
+
return null;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/** Match screen rows against the original text. A visual row boundary can be
|
|
1765
|
+
* a hard newline, a word wrap (one space), or a wrap inside a word. Never erase
|
|
1766
|
+
* whitespace within rows: a changed or partially pasted draft stays unconfirmed.
|
|
1767
|
+
*/
|
|
1768
|
+
function composerMatches(value, wanted) {
|
|
1769
|
+
if (value === null) return false;
|
|
1770
|
+
let offsets = new Set([0]);
|
|
1771
|
+
const rows = value.split('\n');
|
|
1772
|
+
for (let row = 0; row < rows.length; row += 1) {
|
|
1773
|
+
const next = new Set();
|
|
1774
|
+
for (const offset of offsets) {
|
|
1775
|
+
if (!wanted.startsWith(rows[row], offset)) continue;
|
|
1776
|
+
const end = offset + rows[row].length;
|
|
1777
|
+
if (row === rows.length - 1) next.add(end);
|
|
1778
|
+
else {
|
|
1779
|
+
next.add(end);
|
|
1780
|
+
if (wanted[end] === ' ' || wanted[end] === '\n') next.add(end + 1);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
offsets = next;
|
|
1784
|
+
}
|
|
1785
|
+
return offsets.has(wanted.length);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/** Paste once, then submit only text read back from the live composer.
|
|
1789
|
+
* False means unconfirmed, and can leave a draft: callers must not blindly resend.
|
|
1790
|
+
*/
|
|
1791
|
+
export async function deliverComposerInput(host, sessionId, kind, text, { deadlineMs = Date.now() + 10_000, signal } = {}) {
|
|
1792
|
+
const read = async () => composerText(kind, await host.capture(sessionId, { preserveEscapes: true }));
|
|
1793
|
+
const expired = () => signal?.aborted || Date.now() >= deadlineMs;
|
|
1794
|
+
const failed = (reason) => ({ delivered: false, reason });
|
|
1795
|
+
if (expired()) return failed('deadline');
|
|
1796
|
+
const before = await read();
|
|
1797
|
+
if (before !== '') return failed(before === null ? 'no-composer' : 'draft');
|
|
1798
|
+
const wanted = String(text).trim();
|
|
1799
|
+
if (!wanted || /[\x00-\x08\x0b-\x1f\x7f]/u.test(text)) return failed('unsupported-text');
|
|
1800
|
+
if (expired()) return failed('deadline');
|
|
1801
|
+
const delivery = await host.sendInput(sessionId, `${text}\n`, { submitMode: 'none', deadlineMs });
|
|
1802
|
+
// A dropped paste must never be followed by Enter. Give the editor time to render it.
|
|
1803
|
+
let pasted = false;
|
|
1804
|
+
for (let attempt = 0; attempt < 10 && !expired(); attempt += 1) {
|
|
1805
|
+
await sleep(100);
|
|
1806
|
+
const current = await read();
|
|
1807
|
+
if (composerMatches(current, wanted)) { pasted = true; break; }
|
|
1808
|
+
if (current !== '') return failed('paste-unconfirmed');
|
|
1809
|
+
}
|
|
1810
|
+
if (!pasted) return failed('paste-unconfirmed');
|
|
1811
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1812
|
+
// Re-read immediately before each Enter, including the first one.
|
|
1813
|
+
if (expired()) return failed('deadline');
|
|
1814
|
+
if (!composerMatches(await read(), wanted)) return failed('composer-changed');
|
|
1815
|
+
if (expired()) return failed('deadline');
|
|
1816
|
+
// A keystream Enter has no hidden delay or retry between the read and the write.
|
|
1817
|
+
await host.sendInput(sessionId, '\r', { submitMode: 'single', deadlineMs });
|
|
1818
|
+
for (let poll = 0; poll < 10 && !expired(); poll += 1) {
|
|
1819
|
+
await sleep(100);
|
|
1820
|
+
const current = await read();
|
|
1821
|
+
if (current === '') return { delivered: true, delivery };
|
|
1822
|
+
if (!composerMatches(current, wanted)) return failed('submission-unconfirmed');
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
return failed('submit-unconfirmed');
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1750
1828
|
/** The carets each kind draws at the start of its composer line (Claude on Windows draws `>`). */
|
|
1751
1829
|
const COMPOSER_CARETS = Object.freeze({ 'claude-code': ['❯', '>'], codex: ['›'], gemini: ['>'], grok: ['❯'] });
|
|
1752
1830
|
|
|
@@ -1782,19 +1860,6 @@ function styledCells(line) {
|
|
|
1782
1860
|
return cells;
|
|
1783
1861
|
}
|
|
1784
1862
|
|
|
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
1863
|
function validSessionPrefix(value) {
|
|
1799
1864
|
if (!/^[A-Za-z0-9_-]{1,32}$/.test(value)) {
|
|
1800
1865
|
throw new TerminalHostError(
|
package/package.json
CHANGED
package/substrate/tmux.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
838
|
-
|
|
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
|
-
|
|
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)
|