/** * `agent-relay drive ` — interactive read-write take-over client. * * Attaches to a running agent, flips it into `manual_flush` inbound delivery mode so the * broker parks new relay messages in a per-worker queue, and forwards your * keystrokes to the worker's PTY. You can drain the queue on demand with * `Ctrl+G` and detach with `Ctrl+B D` (or `Ctrl+C` as a safety alias). * Detaching restores the worker's previous inbound delivery mode and leaves the * agent running under the broker — `drive` never kills the worker. * * Sequence of operations on attach: * * 1. Discover broker connection (CLI flag → env → connection.json). * 2. `GET /api/spawned/{name}/delivery-mode` → remember the previous mode. * 3. `PUT /api/spawned/{name}/delivery-mode` → switch to `manual_flush`. * 4. `captureAndRenderSnapshot` → repaint the agent's current screen. * 5. `GET /api/spawned/{name}/pending` → seed the status-line counter. * 6. Open `/ws`, subscribe to events for this worker. * 7. Open the SDK PTY input stream, then switch local stdin to raw * mode and forward bytes through that stream. * * On detach (clean or abnormal), best-effort `PUT .../delivery-mode` restores the * previous mode so the queue doesn't fill up indefinitely. */ import { Buffer } from 'node:buffer'; import type { InboundDeliveryMode, PtyInputStreamOptions, PtyInputWriteResult } from '@agent-relay/sdk'; import { Command } from 'commander'; import WebSocket from 'ws'; import { captureAndRenderSnapshot, type AttachSnapshotConnection, type AttachSnapshotDeps } from '../lib/attach.js'; import { type BrokerConnection } from '../lib/broker-connection.js'; type ExitFn = (code: number) => never; /** Wire string for the broker's `InboundDeliveryMode` enum. */ export type { InboundDeliveryMode }; /** Minimal WebSocket surface we depend on — same shape as `view`'s. */ export interface DriveWebSocket { on(event: 'open', listener: () => void): unknown; on(event: 'message', listener: (data: WebSocket.RawData) => void): unknown; on(event: 'close', listener: (code: number, reason: Buffer) => void): unknown; on(event: 'error', listener: (err: Error) => void): unknown; close(code?: number, reason?: string): void; } export type DriveWebSocketFactory = (url: string, headers: Record) => DriveWebSocket; export interface DriveSignalRegistrar { (signal: NodeJS.Signals, handler: () => void | Promise): void; } /** Stdin surface — tests provide a fake that never touches the real TTY. */ export interface DriveStdin { setRawMode?: (mode: boolean) => unknown; isTTY?: boolean; resume(): unknown; pause(): unknown; on(event: 'data', listener: (chunk: Buffer) => void): unknown; off?(event: 'data', listener: (chunk: Buffer) => void): unknown; removeListener?(event: 'data', listener: (chunk: Buffer) => void): unknown; } /** * Local terminal-size source. Wraps `process.stdout` in production so * the resize wiring reads the user's actual terminal dimensions and * gets a SIGWINCH-equivalent `'resize'` event for free. Tests inject a * controllable fake. */ export interface DriveTerminal { /** Current `(rows, cols)`. Returns `null` when stdout is not a TTY, * in which case resize forwarding is skipped entirely. */ getSize(): { rows: number; cols: number; } | null; /** Subscribe to local-terminal resize events. Returns an unsubscribe * function the client calls during teardown. */ onResize(handler: () => void): () => void; } export interface CliPtyInputStream { waitUntilOpen(): Promise; send(data: string): Promise; close(code?: number, reason?: string): void; } export interface DriveDependencies { /** Reads `/connection.json` and returns parsed JSON, or null. */ readConnectionFile: (stateDir: string) => unknown; /** Project paths helper — used to pick the default state dir. */ getDefaultStateDir: () => string; /** Environment variables (so tests can inject). */ env: NodeJS.ProcessEnv; /** Factory for the WebSocket — overridden in tests with a mock. */ createWebSocket: DriveWebSocketFactory; /** Where the PTY chunks get written. Defaults to `process.stdout.write`. */ writeChunk: (chunk: string) => void; /** Signal registration (so tests can drive SIGINT without killing the test). */ onSignal: DriveSignalRegistrar; log: (...args: unknown[]) => void; error: (...args: unknown[]) => void; exit: ExitFn; /** HTTP client used for mode/pending/flush/resize calls. Defaults to global `fetch`. */ fetch: typeof globalThis.fetch; /** Override for the snapshot-on-attach helper (tests substitute a stub). */ captureAndRenderSnapshot: (connection: AttachSnapshotConnection, name: string, deps: AttachSnapshotDeps) => ReturnType; /** Stdin handle — defaults to `process.stdin`. */ stdin: DriveStdin; /** Local terminal size source — defaults to `process.stdout`. */ terminal: DriveTerminal; /** Opens the SDK PTY input stream used for raw human keystrokes. */ openInputStream: (connection: BrokerConnection, name: string, options?: PtyInputStreamOptions) => CliPtyInputStream; } /** ----- HTTP helpers ----- */ /** `GET /api/spawned/{name}/delivery-mode` → `'manual_flush' | 'auto_inject'` or `null` on failure. */ export declare function getInboundDeliveryMode(connection: BrokerConnection, name: string, fetchFn: typeof globalThis.fetch): Promise; /** Outcome of a `PUT /api/spawned/{name}/delivery-mode` call. */ export interface SetInboundDeliveryModeResult { ok: boolean; status: number; /** Server-reported number of pending messages drained on a `manual_flush→auto_inject` flip. */ flushed?: number; /** Human-readable error message when `ok` is false. */ message?: string; } export declare function setInboundDeliveryMode(connection: BrokerConnection, name: string, mode: InboundDeliveryMode, fetchFn: typeof globalThis.fetch): Promise; /** `GET /api/spawned/{name}/pending` → count, or `0` on failure (best-effort). */ export declare function getPendingCount(connection: BrokerConnection, name: string, fetchFn: typeof globalThis.fetch): Promise; /** `POST /api/spawned/{name}/flush` → server returns `{ flushed: N }`. */ export declare function flushPending(connection: BrokerConnection, name: string, fetchFn: typeof globalThis.fetch): Promise<{ ok: boolean; flushed?: number; message?: string; }>; /** `POST /api/input/{name}` body `{ data: "" }`. */ export declare function sendInput(connection: BrokerConnection, name: string, data: string, fetchFn: typeof globalThis.fetch): Promise<{ ok: boolean; message?: string; }>; /** Open the SDK-backed raw PTY input stream for interactive CLI sessions. */ export declare function openPtyInputStream(connection: BrokerConnection, name: string, fetchFn: typeof globalThis.fetch, options?: PtyInputStreamOptions): CliPtyInputStream; /** * `POST /api/resize/{name}` body `{ rows, cols }`. Forwards the * driver's local terminal dimensions so the agent's PTY (and any TUI * running in it) sees the size the human is actually looking at. * Called once on attach and again on every local-terminal resize. */ export declare function resizeWorker(connection: BrokerConnection, name: string, rows: number, cols: number, fetchFn: typeof globalThis.fetch): Promise<{ ok: boolean; message?: string; }>; /** Discriminated union of the broker events `drive` cares about. */ export type DriveWsEvent = { kind: 'worker_stream'; chunk: string; } | { kind: 'delivery_queued'; } | { kind: 'agent_pending_drained'; count?: number; } | { kind: 'other'; }; /** * Inspect a single WebSocket frame and classify it relative to the agent * we're driving. Non-matching / malformed frames return `{ kind: 'other' }` * so the caller can ignore them cheaply. * * Exported for unit testing the filter in isolation. */ export declare function classifyWsEvent(rawMessage: string, name: string): DriveWsEvent; /** ----- Keybind state machine ----- */ /** Outcome of feeding one chunk to the keybind parser. */ export interface KeybindOutcome { /** Bytes that should be forwarded to the agent (may be empty). */ forward: Buffer; /** Local actions the client should perform, in order. */ actions: KeybindAction[]; } export type KeybindAction = 'flush' | 'detach' | 'toggle_help'; /** * Stateful parser that recognises the `Ctrl+B ` two-byte prefix * sequence, plus the single-byte safety keybinds (`Ctrl+G` flush, * `Ctrl+C` detach). * * The parser is intentionally tiny — no readline, no keypress — because * the keybinds are all ASCII control characters, and pulling in a * keypress parser would just add a dependency for no real benefit. * * Semantics: * - `Ctrl+G` (0x07) → emit `flush`, never forwarded. * - `Ctrl+C` (0x03) → emit `detach`, never forwarded. * - `Ctrl+B` (0x02) → swallow, arm the prefix state. * Next byte (within the same chunk OR a subsequent chunk): * - 'd' / 'D' / 0x04 (Ctrl+D) → emit `detach`. * - '?' → emit `toggle_help`. * - anything else → forward the original `Ctrl+B` byte * followed by the new byte, so the * agent isn't deprived if the user * hit Ctrl+B by accident. * * Multiple keybinds in one chunk are handled in order; bytes between * them are forwarded normally. */ export declare class KeybindParser { private pendingPrefix; /** Process one chunk; returns bytes to forward + actions to take. */ feed(chunk: Buffer): KeybindOutcome; /** Reset the parser (e.g. before tearing down). */ reset(): void; } /** ----- Status line rendering ----- */ /** * Render the bottom-of-terminal status line for `drive`. Uses ANSI * save-cursor / restore-cursor so the agent's output isn't disturbed. * * Exported for unit testing — `runDriveSession` calls it on every * pending-count change. */ export declare function renderStatusLine(opts: { name: string; mode: InboundDeliveryMode; pending: number; showHelp: boolean; /** Terminal rows — defaults to 24 if unknown. The status line lands on row N. */ rows?: number; }): string; /** * Open a `drive` session. Resolves with the exit code the CLI should * propagate. Cleans up its own stdin raw-mode and best-effort restores * the worker's previous inbound delivery mode on any exit path. */ export declare function runDriveSession(agentName: string, options: { brokerUrl?: string; apiKey?: string; stateDir?: string; }, deps: DriveDependencies): Promise; /** Register `agent-relay drive ` on the supplied commander program. */ export declare function registerDriveCommands(program: Command, overrides?: Partial): void; //# sourceMappingURL=drive.d.ts.map