/** * Session Command Helper * * Provides high-level terminal command operations using the ISessionBackend abstraction. * This helper bridges the gap between low-level PTY operations and the higher-level * commands that services like AgentRegistrationService need. * * Key mappings from TmuxCommandService: * - sendMessage → write(message + '\r') * - sendKey → write(keyCode) * - sendCtrlC → write('\x03') * - sendEnter → write('\r') * - clearCurrentCommandLine → write('\x03\x15') (Ctrl+C then Ctrl+U) * - capturePane → captureOutput() * * @module session-command-helper */ import type { ISession, ISessionBackend } from './session-backend.interface.js'; import { type TuiInputReading, type TuiInputStage } from './tui-input-guard.js'; /** * Write a message to a file an agent can read (owner-only permissions). * Files older than FILE_DELIVERY_KEEP_MS are pruned on the way. * * @param dir - Directory for delivery files * @param sessionName - The session * @param message - The message * @returns The file path */ export declare function writeDeliveryFile(dir: string, sessionName: string, message: string): string; /** * The one-line pointer pasted instead of a message the runtime collapsed. * * @param message - The message (for its routing tags) * @param file - Where the message was written * @returns A single short line */ export declare function buildFileReferenceLine(message: string, file: string): string; /** * Key code mappings for special keys */ export declare const KEY_CODES: Record; /** * Session Command Helper class * * Provides a unified interface for terminal commands that works with both * PTY and tmux backends through the ISessionBackend abstraction. */ export declare class SessionCommandHelper { private logger; private backend; /** * Per session: the harness's own latest paste into a box it had proven * empty. While the box keeps showing it (the collapsed marker "[Pasted * text #4 +5 lines]" / "[Pasted Content 1234 chars]", or the text itself) * it is ours — whatever message is sent next — so a lost or never-pressed * Enter can be recovered. Written by every paste path; shared across * helper instances. * * - `pending`: pasted, not yet seen in the box (a busy Claude Code renders * a paste seconds late). The first readable box that is not empty must * show the paste's shape, or the record ends; it also ends unseen after * OWN_PASTE_PENDING_MAX_MS. * - `seen`: the box showed `shown`. Every readable box since must show * exactly that; the first that does not ends the record. Unreadable * screens (a dialog) do not count as seen: unseen for * OWN_PASTE_UNSEEN_MAX_MS ends it too. While busy the watcher re-reads * the box, so a long turn keeps it alive. * * Also ended by our one Enter on it, and by session/runtime (re)start. */ private static readonly ownPastes; /** Sessions a sendMessage is typing into right now (the watcher keeps off) */ private static readonly inFlight; /** When each session last had a repaint requested (see readInputBoxSettled) */ private static readonly lastRepaintAt; /** Per session: settled reads in a row that stayed unreadable (see readInputBoxSettled) */ private static readonly unreadableReads; /** Per session: redacted bottom rows of the last screen that stayed unreadable (for owner alerts) */ private static readonly unreadableExcerpts; /** The watcher re-reading boxes that hold a paste of ours */ private static watchTimer; /** Clock for the paste record (tests). */ static now: () => number; /** How long sendMessage waits for a late paste to render (tests shorten it). */ static pasteRenderMaxWaitMs: number; /** * Forget the recorded paste of a session: its runtime is being * (re)started, a shell line is typed, or the session is created/killed — * Claude Code's paste counter restarts at #1, and Codex markers carry only * a character count, so an old record could match the owner's paste. * * @param sessionName - The session */ static forgetOwnPaste(sessionName: string): void; /** * Forget everything about a session's input: its paste record and its * input ledger (session created or killed, runtime relaunched). * * @param sessionName - The session */ static resetSessionInput(sessionName: string): void; /** * Called with the messages our Enter just submitted from the box (a lost * Enter, or a paste that rendered late). Wired to drop queued copies of * them, so a held retry does not deliver them a second time. */ static onOwnPasteSubmitted: ((sessionName: string, messages: string[]) => void) | null; /** * Called once when an idle agent's box has held text that is not ours * and not provably anyone else's for STUCK_INPUT_NOTIFY_MS. Wired to the * blocked-input notice. */ static onStuckInput: ((sessionName: string, info: { inputLength: number; forMs: number; }) => void) | null; /** Per session: since when an idle agent's box held text we could not attribute, and whether it was reported */ private static readonly stuckSince; /** The latest helper, for sessions the watcher only knows from the ledger */ private static lastHelper; /** * Whether a paste of ours is on record for a session (tests, diagnostics). * * @param sessionName - The session * @returns True while the record stands */ static hasOwnPaste(sessionName: string): boolean; /** * Record our paste of `message` into a box proven empty. * * @param sessionName - The session * @param message - What was pasted */ private recordOwnPaste; /** Whether recording a paste starts the timed watcher (tests drive it by hand). */ static autoWatch: boolean; /** Start the watcher (no-op when running). */ private static startWatching; /** Stop the watcher once no paste is on record. */ private static stopWatchingIfIdle; /** * One watcher pass: re-read every box holding a paste of ours (keeping * the "seen continuously" record alive through a long turn, ending it the * moment the box shows anything else), and once the agent is idle with * our paste still sitting there, press Enter on it once. * * @returns Resolves when every session was looked at */ static watchOwnPastes(): Promise; /** Pause between screen samples when checking that a busy screen repaints (tests shorten it). */ static repaintSampleMs: number; /** * Whether the agent in a session is mid-turn. All three must hold: * - a turn signal where the runtime paints it (see readTurnSignals): the * busy bar in the footer below the box, or the spinner line directly * above it — never text in the transcript; * - the screen is repainting: it changes within a few short samples (a * live spinner animates its glyph and counter several times a second); * - the PTY produced output within BUSY_FROZEN_MS: a screen that stopped * repainting is frozen, not busy, and a held message must go out. * * @param sessionName - The session * @returns True when busy */ isAgentBusy(sessionName: string): Promise; /** * Whether the agent in a session is resting: not busy (see * {@link isAgentBusy}) and no meaningful output for a few seconds. * * @param sessionName - The session * @returns True when idle */ isAgentIdle(sessionName: string): Promise; constructor(backend: ISessionBackend); /** * Get a session by name, throwing an error if it doesn't exist. * * @param sessionName - The name of the session to retrieve * @returns The session instance * @throws Error if the session does not exist */ private getSessionOrThrow; /** * Check if a session exists */ sessionExists(sessionName: string): boolean; /** * Get a session by name */ getSession(sessionName: string): ISession | undefined; /** * Send a message to an agent session and submit it with Enter — guarded. * * The harness only submits its own text (2026-10-03 phantom-input * incident: a predicted "owner" message sitting in Claude Code's input * was submitted with a delivery): * * 1. Before typing, the input box must be readable and empty. A leftover * exact copy of this message (an earlier attempt) is cleared; anything * else — someone's half-typed text, an unreadable screen — is left * untouched and nothing is typed. * 2. The message is pasted (bracketed paste) without Enter. * 3. Enter is pressed only when the box holds exactly the message (or the * runtime's collapsed "[Pasted text …]" marker). Otherwise nothing is * submitted and nothing is cleared: text we cannot prove is ours is * never wiped. * * Every paste is recorded (see `ownPastes`): if its Enter is lost, or it * renders only after we stopped looking (a busy Claude Code), it stays * ours while the box keeps showing it, and is submitted once the agent * is idle — by the next delivery or by the watcher. * * A refusal throws `TuiInputGuardError`; agent delivery keeps the message * queued and retries (AgentRegistrationService). Shell command lines * typed before a runtime starts use {@link sendShellLine} instead. * * @param sessionName - The session to send to * @param message - The message to send * @throws Error if session does not exist * @throws TuiInputGuardError when the box is unreadable or not ours */ sendMessage(sessionName: string, message: string): Promise; /** * The body of {@link sendMessage}, run while the watcher keeps off. * * @param sessionName - The session to send to * @param message - The message to send */ private sendMessageGuarded; /** * Where messages delivered by file reference are written (tests replace * it). Default: `/deliveries`. */ static deliveryDir: () => string; /** * Whether a box read right after our paste holds that paste, collapsed by * the runtime into markers we cannot account for line by line * (crewly#1028): nothing but paste markers and pieces of the message, and * no outside input since we pasted it into a box we proved empty — the * input ledger drops our pastes on any outside input, so the paste still * being on record is that proof. An owner's own paste is never this: it * arrives as outside input. * * @param sessionName - The session * @param reading - The box read after our paste * @param message - What we pasted * @returns True when the box is our collapsed paste */ private isOwnUnmatchedCollapse; /** * Clear our collapsed paste and deliver the message as a one-line pointer * to a file holding it: a short single line is never collapsed, so the * box shows exactly what we pasted and the usual exact-match check can * press Enter. Used once per delivery, only after * {@link isOwnUnmatchedCollapse} proved the box ours. * * @param sessionName - The session * @param message - The message * @param current - The box as just read * @throws TuiInputGuardError when the box does not clear or the pointer * does not show as pasted */ private deliverViaFileReference; /** * After a delivery: if the box still holds the collapsed marker of our * own paste, the Enter was lost — press Enter once and look again. Runs * whatever the caller's success checks said: a fast-reply or weak-signal * check can report "delivered" while the marker is still sitting there. * * @param sessionName - The session * @returns `clear` (no marker of ours in the box), `submitted` (Enter took * it), or `stuck` (still there after one Enter — not delivered) */ ensureOwnPasteSubmitted(sessionName: string): Promise<'clear' | 'submitted' | 'stuck'>; /** * Forget recorded paste markers (tests). */ static resetOwnPasteMarkersForTesting(): void; /** * Type a command line into a plain shell and run it (paste + Enter). * * Only for the lines Crewly types into a session's login shell before a * runtime starts there (history off, `cd`, PATH, the launch command). It * does not read an input box: a shell has none. Never use it to talk to * an agent — that is {@link sendMessage}. * * @param sessionName - The session * @param line - The command line */ sendShellLine(sessionName: string, line: string): Promise; /** * Read what an agent's input box really holds, relative to a message. * * Uses the backend's faint-free screen view, so ghost text (Claude Code's * prompt suggestion, placeholders) reads as an empty box. Returns * `unknown` when the backend has no styled capture or no input box of a * known layout is on screen. * * @param sessionName - The session to read * @param message - The harness's message to compare against ('' for none) * @param stage - Why it is read (see classifyTuiInput) * @returns The reading */ readInputBox(sessionName: string, message: string, stage?: TuiInputStage): TuiInputReading; /** * {@link readInputBox}, but an `unknown` reading is not taken at face * value (crewly 1.20.232, 2026-10-05: hundreds of held deliveries): * 1. wait for the terminal parser to drain and read again — a read can * land mid-frame on a loaded machine; * 2. still unknown: ask the runtime to repaint its whole screen (one * column narrower and back, no input sent — Claude Code keeps the box * text) at most once per REPAINT_MIN_INTERVAL_MS, and read again — * Claude Code 2.1.289 sometimes emits a broken UTF-8 byte, and the * cells it leaves are only cleared by a full repaint. * Only re-reads: the reading is classified exactly as by readInputBox, * so text the harness did not write is never read as empty or ours. * When it stays unknown, the bottom rows are logged as a shape (letters * and digits masked) so new screen layouts can be recognised. * * @param sessionName - The session to read * @param message - The harness's message to compare against ('' for none) * @param stage - Why it is read * @returns The reading */ readInputBoxSettled(sessionName: string, message: string, stage: TuiInputStage): Promise; /** * Redacted bottom rows of the last screen of this session that stayed * unreadable, for owner alerts ('' once it reads again). * * @param sessionName - The session * @returns The excerpt */ static unreadableExcerpt(sessionName: string): string; /** * Redacted bottom rows of the screen now (words kept, secrets masked). * * @param sessionName - The session * @returns The excerpt, or '' when the screen cannot be captured */ private unreadableExcerptNow; /** * Settled unreadable reads of a session in a row (tests, diagnostics). * * @param sessionName - The session * @returns The count (0 once a read was readable) */ static unreadableReadCount(sessionName: string): number; /** * One {@link readInputBoxSettled} pass: the ladder's re-reads. * * @param sessionName - The session * @param message - The harness's message to compare against * @param stage - Why it is read * @returns The reading */ private readInputBoxSettledOnce; /** * Enlarge a session whose input box stays unreadable to the recovery * size, wait for the runtime to redraw, and let the parser catch up. * No input is sent. Does nothing for a session already that large (the * small repaint covers it) or a backend that cannot resize. * * @param sessionName - The session * @returns True when the session was enlarged */ private enlargeForRecovery; /** * The bottom rows of a session's input view with every letter and digit * masked as `x`: enough to see the layout (rules, prompt, footer, U+FFFD * cells, stray escape text), never the words. * * @param sessionName - The session * @returns Masked rows, bottom-most last */ private unreadableShape; /** * What the box showed for our recorded paste, while the record stands. * Ends the record when it has gone unseen too long. * * @param sessionName - The session * @returns The text the box showed, or undefined (none seen / no record) */ private ownPasteShown; /** * Keep the paste record in step with a reading: the record stands only * while every readable box shows our paste. A pending paste (not seen * yet) is adopted when the box first shows its shape; an empty box keeps * it pending; anything else ends it. * * @param sessionName - The session * @param reading - The classification just made * @returns The reading, marked `ownPasteMarker` when the box shows our paste */ private applyOwnPaste; /** * Empty the input box — only call it for text proven to be the harness's * own. Sends Ctrl+U then Backspace (clear the line, join the empty line to * the one above), re-reading after each pair, until the box reads empty; * one pair per line plus a margin (verified live on Claude Code, Codex * and Gemini). A box that stops being readable is not "cleared": the * final reading is then `unknown`, and callers treat only `empty` as * success. Never sends Escape (cancels a running Claude Code turn; twice * opens Rewind) or Ctrl+C (twice exits). * * @param sessionName - The session * @param message - The harness's message, for classification * @param current - A reading just taken (saves one re-read) * @returns The final reading (`empty` when cleared) */ clearInputBox(sessionName: string, message: string, current?: TuiInputReading): Promise; /** * Press Enter only when the input box holds exactly the harness's text. * The safe replacement for "press Enter / Tab+Enter to recover a stuck * message": an empty box (which may show a ghost suggestion) or a box * with anyone else's text is never submitted. * * @param sessionName - The session * @param message - The message the harness wrote * @returns The reading; Enter was pressed only when its state is `ours` */ submitIfInputIsOurs(sessionName: string, message: string): Promise; /** * Send a key to a session * * @param sessionName - The session to send to * @param key - The key name (e.g., 'Enter', 'C-c', 'Escape') * @throws Error if session does not exist or key is unknown */ sendKey(sessionName: string, key: string): Promise; /** * Send Ctrl+C to a session */ sendCtrlC(sessionName: string): Promise; /** * Send Enter key to a session */ sendEnter(sessionName: string): Promise; /** * Send Escape key to a session */ sendEscape(sessionName: string): Promise; /** * Detect and dismiss interactive prompts (e.g., plan mode) before message delivery. * Checks the last terminal output for plan mode patterns and sends Escape to exit * if detected. This prevents agents from getting permanently stuck in plan mode. * * @param sessionName - The session to check * @returns True if an interactive prompt was detected and dismissed */ dismissInteractivePromptIfNeeded(sessionName: string): Promise; /** * Clear the current command line in a session * Sends Ctrl+C followed by Ctrl+U to cancel any input and clear the line */ clearCurrentCommandLine(sessionName: string): Promise; /** * Capture terminal output from a session. * * Strips trailing empty lines that result from empty terminal rows below * the actual content. Large terminals (e.g., maximized browser windows) * can have 50+ empty rows below the prompt, which would cause prompt * detection to fail if the capture window is too small. * * @param sessionName - The session to capture from * @param lines - Number of lines to capture (default: 200) * @returns The captured terminal content with trailing empty lines removed */ capturePane(sessionName: string, lines?: number): string; /** * Get raw output history with ANSI escape codes preserved. * * @param sessionName - The session to get history from * @returns Raw output history with ANSI codes */ getRawHistory(sessionName: string): string; /** * List all active sessions */ listSessions(): string[]; /** * Kill a session */ killSession(sessionName: string): Promise; /** * Create a new session * * @param sessionName - Unique name for the session * @param cwd - Working directory for the session * @param options - Additional session options * @returns The created session */ createSession(sessionName: string, cwd: string, options?: { command?: string; args?: string[]; env?: Record; cols?: number; rows?: number; }): Promise; /** * Set an environment variable in a session by typing an `export` command. * * Only for non-secret values: the shell echoes the typed line, so the value * ends up in scrollback, the persistent session log and the terminal-output * API. Secrets (API keys, tokens, passwords) belong in the spawn environment * (`createSession(..., { env })`) and are refused here. * Note: This only affects new commands run in the session. * * @param sessionName - Session to type into * @param key - Variable name * @param value - Variable value (not secret) * @throws Error when `key` names a secret (see utils/secret-env) or the session does not exist */ setEnvironmentVariable(sessionName: string, key: string, value: string): Promise; /** * Resize a session's terminal */ resizeSession(sessionName: string, cols: number, rows: number): void; /** * Get the underlying backend */ getBackend(): ISessionBackend; /** * Subscribe to session output events. * * This method provides direct access to the terminal output stream, * enabling event-driven processing instead of polling. * * @param sessionName - The session to subscribe to * @param callback - Function called on each data event * @returns Unsubscribe function to stop receiving events * @throws Error if session does not exist * * @example * ```typescript * const unsubscribe = helper.subscribeToOutput('my-session', (data) => { * console.log('Received:', data); * }); * // Later: unsubscribe(); * ``` */ subscribeToOutput(sessionName: string, callback: (data: string) => void): () => void; /** * Subscribe to session exit events. * * @param sessionName - The session to subscribe to * @param callback - Function called when session exits with exit code * @returns Unsubscribe function to stop receiving events * @throws Error if session does not exist * * @example * ```typescript * const unsubscribe = helper.subscribeToExit('my-session', (code) => { * console.log('Session exited with code:', code); * }); * ``` */ subscribeToExit(sessionName: string, callback: (code: number) => void): () => void; /** * Wait for a pattern to appear in session output. * * Subscribes to the output stream and resolves when the pattern is found. * Useful for waiting for specific prompts or indicators. * * @param sessionName - The session to monitor * @param pattern - RegExp or string to match against output * @param timeoutMs - Maximum time to wait (default: 30 seconds) * @returns Promise resolving to the accumulated buffer when pattern matches * @throws Error if timeout or session does not exist * * @example * ```typescript * // Wait for Claude prompt * const output = await helper.waitForPattern('agent-1', />\s*$/, 10000); * console.log('Claude is ready:', output); * ``` */ waitForPattern(sessionName: string, pattern: RegExp | string, timeoutMs?: number): Promise; /** * Wait for any of multiple patterns to appear in session output. * * Useful when multiple outcomes are possible (e.g., success or error). * * @param sessionName - The session to monitor * @param patterns - Array of patterns with identifiers * @param timeoutMs - Maximum time to wait * @returns Promise resolving to which pattern matched and the buffer * * @example * ```typescript * const result = await helper.waitForAnyPattern('agent-1', [ * { id: 'prompt', pattern: />\s*$/ }, * { id: 'error', pattern: /error:/i }, * ], 10000); * * if (result.matchedId === 'error') { * console.log('Error occurred:', result.buffer); * } * ``` */ waitForAnyPattern(sessionName: string, patterns: Array<{ id: string; pattern: RegExp | string; }>, timeoutMs?: number): Promise<{ matchedId: string; buffer: string; }>; /** * Write raw data to session without Enter key. * * Useful for sending partial input or special key sequences. * * @param sessionName - The session to write to * @param data - Data to write * @throws Error if session does not exist */ writeRaw(sessionName: string, data: string): void; } /** * Create a SessionCommandHelper instance */ export declare function createSessionCommandHelper(backend: ISessionBackend): SessionCommandHelper; //# sourceMappingURL=session-command-helper.d.ts.map