/** * PtySessionManager — interactive PTY sessions for mobile relay clients. * * Each session wraps a single node-pty process running an agent (claude, * gemini, codex, opencode) or a plain shell. Sessions persist across phone * disconnects: the PTY keeps running, output is fed through a headless * xterm emulator that mirrors the live screen state, and a reconnecting * phone gets a serialized snapshot (escape sequences that recreate the * exact grid + alt-screen + cursor + scrollback) instead of a raw byte * tail. * * Why headless emulation: full-screen TUIs (opencode, htop, vim) draw * with absolute cursor moves and overpaint regions. Replaying the last * 64 KiB of bytes gives the phone an incoherent stream that often starts * mid-frame and mid-escape — symptom is a half-painted UI on reattach. * Feeding the same bytes through a headless terminal and serializing the * resulting grid solves that for any TUI, the same way tmux/mosh do. * * Distinct from AiTaskManager (which models one-shot tasks); this manager * is built for long-lived, bidirectional terminal streams. */ export type AgentKind = 'shell' | 'claude' | 'gemini' | 'codex' | 'opencode' | 'custom'; export interface SpawnOptions { agent: AgentKind; cwd?: string; cols?: number; rows?: number; /** Optional initial prompt sent as stdin once the PTY is ready. */ initialInput?: string; /** Optional extra args appended to the agent command (e.g. ['--model', 'claude-opus-4-7']). */ extraArgs?: string[]; /** Optional explicit session id (e.g. provided by the relay client so phone-chosen UUIDs match the routing tag). */ id?: string; /** Optional human-readable label so users can `loopsy attach ` instead of memorising a UUID. */ name?: string; /** * For `agent: 'custom'`: the binary to spawn. The relay-client * resolves a phone-supplied customCommandId against the daemon's * trusted list and copies the result here, so the user never sends * raw argv across the wire. */ command?: string; } export interface SessionInfo { id: string; agent: AgentKind; cwd: string; cols: number; rows: number; pid?: number; /** Optional user-supplied label. Unique across alive sessions when present (enforced at spawn). */ name?: string; createdAt: number; lastActivityAt: number; alive: boolean; exitCode?: number; exitSignal?: string; /** How many local/relay clients are currently subscribed to this PTY's output. */ attachedClientCount: number; } export interface PtySessionManagerConfig { /** Lines of scrollback retained per session. Default 1000 (xterm default). */ scrollbackLines?: number; /** Idle seconds with no listeners before killing the PTY. Default 1 hour. */ idleTimeoutSec?: number; /** Optional environment merged into the PTY env. */ env?: NodeJS.ProcessEnv; } export declare class PtySessionManager { private sessions; private scrollbackLines; private idleTimeoutMs; private extraEnv; constructor(cfg?: PtySessionManagerConfig); /** Spawn a new session and return its id. */ spawn(opts: SpawnOptions): string; write(id: string, data: Buffer | string): boolean; resize(id: string, cols: number, rows: number): boolean; signal(id: string, signal?: NodeJS.Signals): boolean; /** Hard close: kill the PTY and forget the session. */ close(id: string, signal?: NodeJS.Signals): void; /** * Hand a reconnecting listener a snapshot of the current screen state * (as ANSI escape sequences) and start streaming new PTY output to it. * The phone's xterm.js consumes the snapshot like any other byte * stream — alt-screen, cursor, attributes, and visible scrollback are * recreated faithfully because the daemon kept a parallel headless * terminal in sync with every PTY write. */ attach(id: string, onData: (data: Buffer) => void): { detach(): void; replay: Buffer; } | null; /** Detach a listener; if last listener leaves, start the idle timer. */ detach(id: string, onData: (data: Buffer) => void): void; onExit(id: string, cb: (info: { exitCode: number; signal?: string; }) => void): () => void; list(): SessionInfo[]; get(id: string): SessionInfo | null; /** * Resolve a user-supplied identifier to a session id. Accepts: * - a full UUID * - a unique id prefix (so `loopsy attach 4fa7b7` works against the * 8-char preview shown by `loopsy list`) * - a name that uniquely matches a single alive session * Returns null when nothing matches or the input is ambiguous; the * caller surfaces a friendly "no such session" error. */ resolve(idOrName: string): string | null; private toPublic; /** Tear down everything (called on daemon shutdown). */ shutdown(): void; /** * CSO #15: resolve agent commands to absolute paths once, cached. Letting * `node-pty` look up `claude` via `$PATH` at spawn time means an attacker * who can influence the daemon's environment (env-injection in spawned * children, shell rc tampering) can shadow the real binary. Resolving at * startup with `command -v` pins the path to whatever was on disk when * the daemon launched. */ private static readonly _absPathCache; /** * List the agents this daemon can actually launch — `shell` is always * available; the AI agents (claude, gemini, codex) only count if the * binary resolves on PATH at the time we ask. Result is cached per agent * inside `_absPathCache` (the same cache resolveCommand uses). * * Phones use this to grey out unavailable agents in the picker so a * reviewer (or anyone) doesn't pick `claude`, hit a black terminal that * shuts down because /usr/local/bin/claude isn't installed on this host, * and then have to back out manually. */ static availableAgents(): AgentKind[]; private resolveCommand; } //# sourceMappingURL=pty-session-manager.d.ts.map