/** * TUI input guard — reads what is really in an agent runtime's input box. * * Why this exists (2026-10-03 incident): Claude Code paints a faint * "prompt suggestion" in an empty input box — a prediction of the user's * next message, such as "按这个草稿回吧" ("go ahead with this draft"). * Tab accepts it. Crewly pressed Tab before every Claude Code delivery and * Tab+Enter in its stuck-message recovery, so a predicted message was * submitted as if the owner had typed it, and the agent posted on LinkedIn * as the owner. * * The rule enforced with this module: the harness types into an agent's * input only when the box is empty (after clearing it), and presses Enter * only when the box holds exactly the text the harness wrote. * * The box is found per runtime layout, from real captures * (`__fixtures__/tui/`, recorded from Claude Code 2.1.288 and Codex 0.160 * in a PTY through headless xterm): * * - Claude Code: the box sits between two `────` rules; its first line is * `❯` + U+00A0 + text, continuation lines are indented. The transcript * echoes past prompts as `❯ text` too, so only the ruled box counts. * - Antigravity: the same ruled box with a `>` prompt. * - Codex: the composer starts at the bottom-most `›` line at column 0 and * runs to the terminal cursor (Codex keeps it at the end of the text); * blank lines inside a multi-line message belong to it. * - Gemini CLI 0.40.1: the box sits between a `▄▄▄` and a `▀▀▀` line; its * first line is ` > text` (or `*`/`!`), continuation lines indented; the * empty box shows `Type your message or @path/to/file` in solid grey. * - Older Gemini (not verified live): a `╭──╮ │ > text │ ╰──╯` box. * * Ghost text is read as absent: the screen is captured with faint cells * blanked (`captureInputView`). When no known layout is on screen the * reading is `unknown` — the caller falls back to the old path (no Tab, one * Enter after its own paste, never a blind or backup Enter). * * @module services/session/tui-input-guard */ /** * What the input box holds, relative to a message the harness wants to send. * * - `empty`: an input box was found and holds nothing. * - `ours`: it holds the message (or the runtime's collapsed * "[Pasted text …]" marker for it), and nothing else. * - `foreign`: it holds text the harness did not write (alone or mixed * with the message). * - `unknown`: no input box of a known layout is on screen. */ export type TuiInputState = 'empty' | 'ours' | 'foreign' | 'unknown'; /** Which runtime layout the box was read from. */ export type TuiInputLayout = 'claude-code' | 'antigravity' | 'codex' | 'gemini' | 'gemini-legacy'; /** * Result of reading an input box. */ export interface TuiInputReading { /** Classification of the input box contents */ state: TuiInputState; /** The input box text as read (lines joined with '\n'), '' when none */ text: string; /** Layout the box was found in (absent for `unknown`) */ layout?: TuiInputLayout; /** How many screen lines the box text spans (0 for an empty box) */ lineCount: number; /** * Whether the layout was verified against live captures (Claude Code, * Codex). Gemini and Antigravity were not; callers treat their readings * conservatively. */ verified?: boolean; /** * The box holds the collapsed marker of the harness's own earlier paste * (whatever message it was): a delivery whose Enter was lost. */ ownPasteMarker?: boolean; /** With `ownPasteMarker`: the harness messages the box holds, in order */ ownPasteMessages?: string[]; /** With `ownPasteMarker`: which messages those are is a guess (see OwnPasteAttribution.ambiguous) */ ownPasteAmbiguous?: boolean; } /** A captured screen: rows (faint text blanked) and the cursor row. */ export interface TuiInputView { lines: string[]; /** Row of the terminal cursor in `lines`, -1 when unknown */ cursorRow: number; } /** * Why the box is being read: before typing, right after our own paste into * a box proven empty, or later by recovery (the box was not watched since). */ export type TuiInputStage = 'before-write' | 'after-paste' | 'recovery'; /** A box found on screen: its layout and text lines. */ interface FoundBox { layout: TuiInputLayout; lines: string[]; } /** * Whether a line is an input box's horizontal rule (Claude Code / * Antigravity box edge), bare (`────`) or labelled: Claude Code prints the * agent/session name inside the top rule (`──── crewly-orc ─`) on every * live machine. Every rule detector should use this, not `/^─+$/`. * * Tolerates the damage seen live with Claude Code 2.1.289 (2026-10-05): a * few U+FFFD cells where a `─` arrived as broken UTF-8, and, on a labelled * rule, a short run of stale text at the left end that a misplaced repaint * left behind (`(ct────── flopost-pia ─`). Only the rule is tolerated — * what the box holds is read exactly as before. * * @param line - Screen line * @param minChars - Fewest `─` that make a rule * @returns True for a rule */ export declare function isInputBoxRule(line: string, minChars?: number): boolean; /** * Find the input box on a screen. * * @param view - Screen rows (faint blanked) and cursor row * @returns The box, or null when no known layout is visible */ export declare function findTuiInputBox(view: TuiInputView): FoundBox | null; /** * Whether input-box text is a runtime's collapsed marker for a paste * (Claude Code "[Pasted text #1 +4 lines]" / "[Pasted text #2]", Codex * "[Pasted Content 1234 chars]"). Ghost suggestions are natural language * and never take this form. * * @param text - Input box text * @returns True for a lone paste marker */ export declare function isPasteMarker(text: string): boolean; /** What a screen says about a turn in progress, read from its structure. */ export interface TurnScreenSignals { /** A Claude Code / Antigravity input box (two rules) was found */ box: boolean; /** "esc to interrupt" in the footer rows below the box (or, without a box, in the bottom rows) */ busyBar: boolean; /** The spinner line directly above the box's top rule, if any */ spinner: string | null; } /** * Read a screen's turn-in-progress signals from where the runtime paints * them — never from the transcript, which can quote anything (an agent * working on Crewly prints "esc to interrupt"; a reply can end "Understood…"): * - the busy bar only in the footer rows below the box's bottom rule * (at most FOOTER_MAX_LINES); without a box (Codex), only in the bottom * FOOTER_MAX_LINES non-empty rows; * - the spinner only as the single non-empty line directly above the box's * top rule, in Claude Code's spinner shape. * * @param screen - Plain screen text (bottom of the screen) * @returns The signals */ export declare function readTurnSignals(screen: string): TurnScreenSignals; /** * Whether a screen shows a turn in progress (see {@link readTurnSignals}). * Structure only: callers that act on "busy" for long also check that the * screen is repainting (SessionCommandHelper.isAgentBusy). * * @param screen - Plain screen text (bottom of the screen) * @returns True when the busy bar or the spinner line is in place */ export declare function screenShowsTurnInProgress(screen: string): boolean; /** * Whether input-box text is how the runtime shows a paste of `message`: * the message itself (whitespace-insensitive), or a collapsed marker of its * shape — Claude Code "[Pasted text #N +L lines]" where L is the number of * line breaks (a single long line shows as "[Pasted text #N]"), Codex * "[Pasted Content C chars]" where C is its length. Used to recognise a * paste of ours that rendered only after we stopped looking (a busy Claude * Code renders a paste seconds late). * * @param text - The input box text * @param message - The message the harness pasted * @returns True when the text is what that paste looks like */ export declare function pasteShowsAs(text: string, message: string): boolean; /** * Whether input-box text is made up only of the harness's own pastes: * runtime paste markers and/or the pasted texts, possibly several of them * run together ("[Pasted text #2 +5 lines][Pasted text #3 +5 lines]" — a * second paste landed on a first one that rendered late, 1.20.207 Ella). * * Each part must be accounted for, in order, by a distinct paste: * - a marker that the harness saw for its own paste (`shownMarkers`, exact * text: the runtime's counter makes it unique in the session), or * - a marker or text with the shape of one of `pastes` — the harness's * pastes since the last outside input, oldest first. With no outside * input since, nothing else can have put it there; or * - several consecutive markers (and short text pieces) that together are * ONE of `pastes`, which Claude Code showed in pieces (crewly#1028, * "[Pasted text #3 +7 lines][Pasted text #4 +6 lines]"; see * {@link partsShowPaste}). * * @param text - The input box text * @param pastes - Harness paste messages since the last outside input, oldest first * @param shownMarkers - Exact markers the box showed for harness pastes * @returns The messages the box holds, in order, when every part of it is * one of our pastes; null otherwise */ export declare function boxHoldsOnlyOwnPastes(text: string, pastes: readonly string[], shownMarkers?: ReadonlyArray<{ marker: string; message: string; }>): string[] | null; /** Which of our pastes a box holds (see {@link attributeOwnPastes}). */ export interface OwnPasteAttribution { /** The messages the box holds, in order (a best guess when `ambiguous`) */ messages: string[]; /** * A marker was matched by shape alone while another unseen paste of a * different message has the same shape: the box is ours, but which of * those messages it holds is a guess. */ ambiguous: boolean; } /** * {@link boxHoldsOnlyOwnPastes}, also saying whether the match is certain. * * @param text - The input box text * @param pastes - Harness paste messages since the last outside input, oldest first * @param shownMarkers - Exact markers the box showed for harness pastes * @returns The attribution, or null when the box is not only our pastes */ export declare function attributeOwnPastes(text: string, pastes: readonly string[], shownMarkers?: ReadonlyArray<{ marker: string; message: string; }>): OwnPasteAttribution | null; /** One piece of an input box: a runtime paste marker, or the text between markers. */ export interface BoxPart { /** True for a "[Pasted text …]" / "[Pasted Content …]" / "[Image #N]" marker */ marker: boolean; /** True for a Claude Code "[Image #N]" marker (an image path it pulled out of the paste) */ image?: boolean; /** The marker, or the text (whitespace collapsed) */ text: string; } /** * Split input-box text into paste markers and the text between them. * * @param text - Input box text * @returns The parts, in order (empty for a blank box) */ export declare function splitBoxParts(text: string): BoxPart[]; /** * Whether consecutive box parts are how ONE paste of `message` shows when * Claude Code collapsed it in several pieces (crewly#1028, ce-vera on * Claude Code 2.1.288/289: one brief showed as * "[Pasted text #3 +7 lines][Pasted text #4 +6 lines]"). Claude Code takes * a long paste that reaches it in several reads as several pastes; each * piece is collapsed into its own marker, or — when short — shown as text. * * The pieces must add up to the message: * - every text piece is a piece of the message, in order (the first one * starts it, the last one ends it); * - the markers' "+K lines" plus the line breaks inside the text pieces * account for the message's line breaks, short by at most one per piece * (a break on a boundary between two pieces is shown by neither). * * Only Claude Code markers ("[Pasted text #N +K lines]") are matched: a * Codex "[Pasted Content C chars]" marker never splits. * * @param parts - Consecutive box parts (at least two, at least one marker) * @param message - A message the harness pasted * @returns True when the parts are that one paste */ export declare function partsShowPaste(parts: readonly BoxPart[], message: string): boolean; /** * {@link partsShowPaste} for a whole box: the box holds one paste of * `message`, shown in several pieces. * * @param text - The input box text * @param message - The message the harness pasted * @returns True when the box is that paste, split */ export declare function pasteShowsAsSplit(text: string, message: string): boolean; /** * Whether the box holds nothing but runtime paste markers and pieces of * `message` — the shape our own paste takes when Claude Code collapsed it * in pieces we could not account for line by line. On its own this is no * proof: callers also need to know nobody else typed since our paste. * * @param text - The input box text * @param message - The message the harness pasted * @returns True for markers (at least one) plus pieces of the message */ export declare function boxIsOnlyMarkersAndPiecesOf(text: string, message: string): boolean; /** * Classify what an input box holds relative to the message the harness * wants to send (or has just pasted). * * `ours` requires that nothing else is in the box: * - the whole message (whitespace-insensitive), at any stage, or * - only right after our own paste into a box proven empty: a visible part * of it (a long message scrolls inside the box) or a lone paste marker. * During recovery a marker or a fragment is not proof — someone else may * have pasted or typed it. * * @param view - Screen rows (faint blanked) and cursor row * @param message - The message the harness wrote (or will write) * @param stage - Why the box is read * @param ownMarker - The exact "[Pasted text …]" marker seen right after the * harness's own last paste (of any message), when one was recorded * @returns The reading */ export declare function classifyTuiInput(view: TuiInputView, message: string, stage?: TuiInputStage, ownMarker?: string): TuiInputReading; /** * {@link classifyTuiInput}, then the input-ledger step the session helper * applies: a box that is not provably ours on its own is ours when every * part of it is one of the harness's pastes since the last outside input * (several run together, or one shown in several pieces — crewly#1028). * For callers outside the live helper (the release input-guard check), * which are handed the ledger instead of reading it. * * @param view - Screen rows (faint blanked) and cursor row * @param message - The message the harness wrote (or will write) * @param stage - Why the box is read * @param pastes - Harness paste messages since the last outside input, oldest first * @param shown - Exact markers the box showed for harness pastes * @returns The reading */ export declare function classifyWithOwnPastes(view: TuiInputView, message: string, stage: TuiInputStage, pastes?: readonly string[], shown?: ReadonlyArray<{ marker: string; message: string; }>): TuiInputReading; /** * Error raised when the harness refuses to type into, or submit, an input * box because it holds text the harness did not write. */ export declare class TuiInputGuardError extends Error { /** What the input box held when the harness refused */ readonly reading: TuiInputReading; /** Which step refused: before typing, or before pressing Enter */ readonly stage: 'before-write' | 'before-submit'; /** * @param stage - The step that refused * @param reading - What the box held */ constructor(stage: 'before-write' | 'before-submit', reading: TuiInputReading); } /** * Raised when the harness will not paste because an earlier paste of its * own may still land in the box (pasted but not yet seen, or just * submitted): pasting now could put two messages in one box (1.20.207 * Ella). The caller queues the message; it goes out once the box is * settled. */ export declare class TuiPasteHoldError extends Error { /** Why the paste was held */ readonly reason: 'pending-paste' | 'just-submitted'; /** * @param reason - Why the paste was held */ constructor(reason: 'pending-paste' | 'just-submitted'); } export {}; //# sourceMappingURL=tui-input-guard.d.ts.map