@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 +8 -3
- package/index.mjs +81 -47
- package/package.json +1 -1
- package/substrate/tmux.d.ts +2 -1
- package/substrate/tmux.js +16 -16
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.
|
|
@@ -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, {
|
|
1549
|
+
const content = await this.#host.capture(sessionId, { preserveEscapes: true });
|
|
1551
1550
|
if (composerReadyForInput(harness, content) === true) {
|
|
1552
|
-
if (harness
|
|
1553
|
-
await this.#
|
|
1554
|
-
|
|
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
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
|
@@ -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
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
835
|
-
|
|
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
|
-
|
|
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)
|