import { execaCommandSync, parseCommandString } from "execa"; import { fromWritable } from "from-node-stream"; import { mkdir, readFile, writeFile } from "fs/promises"; import path from "path"; import DIE from "phpdie"; import sflow from "sflow"; import { XtermProxy } from "./xterm-proxy.ts"; import { extractSessionId, getSessionForCwd, storeSessionForCwd, } from "./resume/codexSessionManager.ts"; import pty, { ptyPackage } from "./pty.ts"; import { removeControlCharacters } from "./removeControlCharacters.ts"; import { acquireLock, releaseLock, shouldUseLock } from "./runningLock.ts"; import { logger } from "./logger.ts"; import { createFifoStream } from "./beta/fifo.ts"; import { PidStore } from "./pidStore.ts"; import { sendEnter, sendMessage } from "./core/messaging.ts"; import { initializeLogPaths, setupDebugLogging, saveLogFile, saveDeprecatedLogFile, } from "./core/logging.ts"; import { spawnAgent } from "./core/spawner.ts"; import { AgentContext } from "./core/context.ts"; import { createTerminatorStream } from "./core/streamHelpers.ts"; import { globalAgentRegistry } from "./agentRegistry.ts"; import { notifyWebhook } from "./webhookNotifier.ts"; import { readGlobalPids } from "./globalPidIndex.ts"; export { removeControlCharacters }; export { AgentContext }; export type AgentCliConfig = { // cli install?: | string | { powershell?: string; bash?: string; npm?: string; unix?: string; windows?: string }; // hint user for install command if not installed version?: string; // hint user for version command to check if installed binary?: string; // actual binary name if different from cli, e.g. cursor -> cursor-agent defaultArgs?: string[]; // function to ensure certain args are present help?: string; // documentation/help URL for the CLI bunx?: boolean; // metadata for bunx-based launches systemPrompt?: string; // flag name for system prompt injection system?: string; // system prompt content to inject // status detect, and actions ready?: RegExp[]; // regex matcher for stdin ready, or line index for gemini. Set to empty array [] to disable ready check entirely. fatal?: RegExp[]; // array of regex to match for fatal errors working?: RegExp[]; // regex matcher for working status updateAvailable?: RegExp[]; // regex matcher for update available banners exitCommands?: string[]; // commands to exit the cli gracefully promptArg?: (string & {}) | "first-arg" | "last-arg"; // argument name to pass the prompt, e.g. --prompt, or first-arg for positional arg // handle special format noEOL?: boolean; // if true, do not split lines by \n when handling inputs, e.g. for codex, which uses cursor-move csi code instead of \n to move lines // auto responds enter?: RegExp[]; // array of regex to match for sending Enter enterExclude?: RegExp[]; // array of regex to exclude from auto-enter (even if enter matches) typingRespond?: { [message: string]: RegExp[] }; // type specified message to a specified pattern // crash/resuming-session behaviour restoreArgs?: string[]; // arguments to continue the session when crashed restartWithoutContinueArg?: RegExp[]; // array of regex to match for errors that require restart without continue args }; export type AgentYesConfig = { configDir?: string; // directory to store agent-yes config files, e.g. session store logsDir?: string; // directory to store agent-yes log files clis: { [key: string]: AgentCliConfig }; }; // load user config from agent-yes.config.ts if exists export const config = await import("../agent-yes.config.ts").then((mod) => mod.default || mod); export const CLIS_CONFIG = config.clis as Record< keyof Awaited["clis"], AgentCliConfig >; /** * Main function to run agent-cli with automatic yes/no responses * @param options Configuration options * @param options.continueOnCrash - If true, automatically restart agent-cli when it crashes: * 1. Shows message 'agent-cli crashed, restarting..' * 2. Spawns a new 'agent-cli --continue' process * 3. Re-attaches the new process to the shell stdio (pipes new process stdin/stdout) * 4. If it crashes with "No conversation found to continue", exits the process * @param options.exitOnIdle - Exit when agent-cli is idle. Boolean or timeout in milliseconds, recommended 5000 - 60000, default is false * @param options.cliArgs - Additional arguments to pass to the agent-cli CLI * @param options.removeControlCharactersFromStdout - Remove ANSI control characters from stdout. Defaults to !process.stdout.isTTY * @param options.disableLock - Disable the running lock feature that prevents concurrent agents in the same directory/repo * * @example * ```typescript * import agentYes from 'agent-yes'; * await agentYes({ * prompt: 'help me solve all todos in my codebase', * * // optional * cliArgs: ['--verbose'], // additional args to pass to agent-cli * exitOnIdle: 30000, // exit after 30 seconds of idle * robust: true, // auto restart with --continue if claude crashes, default is true * logFile: 'claude-output.log', // save logs to file * disableLock: false, // disable running lock (default is false) * }); * ``` */ export default async function agentYes({ cli, cliArgs = [], prompt, robust = true, cwd, env, exitOnIdle, logFile, removeControlCharactersFromStdout = false, // = !process.stdout.isTTY, verbose = false, queue = false, install = false, resume = false, useSkills = false, useStdinAppend = false, autoYes = true, idleAction, swarmHint = true, }: { cli: keyof typeof CLIS_CONFIG; cliArgs?: string[]; prompt?: string; robust?: boolean; cwd?: string; env?: Record; exitOnIdle?: number; logFile?: string; removeControlCharactersFromStdout?: boolean; verbose?: boolean; queue?: boolean; install?: boolean; // if true, install the cli tool if not installed, e.g. will run `npm install -g cursor-agent` resume?: boolean; // if true, resume previous session in current cwd if any useSkills?: boolean; // if true, prepend SKILL.md header to the prompt for non-Claude agents useStdinAppend?: boolean; // if true, enable FIFO input stream on Linux, for additional stdin input autoYes?: boolean; // if true, auto-yes is enabled (default), toggle with Ctrl+Y during session idleAction?: string; // if set, type this message when idle instead of exiting swarmHint?: boolean; // if true (default), inject peer discovery hint when other agents are running; --no-swarm-hint to opt out }) { if (!cli) throw new Error(`cli is required`); const conf = CLIS_CONFIG[cli] || DIE(`Unsupported cli tool: ${cli}, current process.argv: ${process.argv.join(" ")}`); // Acquire lock before starting agent (if in git repo or same cwd and lock is not disabled) const workingDir = cwd ?? process.cwd(); if (queue) { if (queue && shouldUseLock(workingDir)) { await acquireLock(workingDir, prompt ?? "Interactive session"); } // Register cleanup handlers for lock release const cleanupLock = async () => { if (queue && shouldUseLock(workingDir)) { await releaseLock().catch(() => null); // Ignore errors during cleanup } }; process.on("exit", () => { if (queue) releaseLock().catch(() => null); }); process.on("SIGINT", async (code) => { await cleanupLock(); process.exit(code); }); process.on("SIGTERM", async (code) => { await cleanupLock(); process.exit(code); }); } // Initialize process registry const pidStore = new PidStore(workingDir); await pidStore.init(); // Track when user sends Ctrl+C to avoid treating intentional exit as crash let userSentCtrlC = false; if (verbose) logger.debug( `[stdin] isTTY: ${process.stdin.isTTY}, setRawMode available: ${!!process.stdin.setRawMode}`, ); process.stdin.setRawMode?.(true); // must be called any stdout/stdin usage if (verbose) logger.debug(`[stdin] Raw mode set, isRaw: ${(process.stdin as any).isRaw}`); // XtermProxy: headless xterm emulator that auto-responds to all terminal // queries (DSR, DA, etc.) so the spawned CLI never blocks waiting for replies. // writeToPty is set after shell spawn (see below). let shellWrite: (data: string) => void = () => {}; const xtermProxy = new XtermProxy({ ...getTerminalDimensions(), writeToPty: (data) => shellWrite(data), }); logger.debug(`Using ${ptyPackage} for pseudo terminal management.`); // Detect if running as sub-agent const isSubAgent = !!process.env.CLAUDE_PPID; if (isSubAgent) logger.info(`[${cli}-yes] Running as sub-agent (CLAUDE_PPID=${process.env.CLAUDE_PPID})`); // Apply CLI specific configurations (moved to CLI_CONFIGURES) const cliConf = (CLIS_CONFIG as Record)[cli] || {}; cliArgs = cliConf.defaultArgs ? [...cliConf.defaultArgs, ...cliArgs] : cliArgs; // If enabled, read SKILL.md header and prepend to the prompt for non-Claude agents try { const workingDir = cwd ?? process.cwd(); if (useSkills && cli !== "claude") { // Find git root to determine search boundary let gitRoot: string | null = null; try { const result = execaCommandSync("git rev-parse --show-toplevel", { cwd: workingDir, reject: false, }); if (result.exitCode === 0) { gitRoot = result.stdout.trim(); } } catch { // Not a git repo, will only check cwd } // Walk up from cwd to git root (or stop at filesystem root) collecting SKILL.md files const skillHeaders: string[] = []; let currentDir = workingDir; const searchLimit = gitRoot || path.parse(currentDir).root; while (true) { const skillPath = path.resolve(currentDir, "SKILL.md"); const md = await readFile(skillPath, "utf8").catch(() => null); if (md) { // Extract header (content before first level-2 heading `## `) const headerMatch = md.match(/^[\s\S]*?(?=\n##\s)/); const headerRaw = (headerMatch ? headerMatch[0] : md).trim(); if (headerRaw) { skillHeaders.push(headerRaw); if (verbose) logger.info(`[skills] Found SKILL.md in ${currentDir} (${headerRaw.length} chars)`); } } // Stop if we've reached git root or filesystem root if (currentDir === searchLimit) break; const parentDir = path.dirname(currentDir); if (parentDir === currentDir) break; // Reached filesystem root currentDir = parentDir; } if (skillHeaders.length > 0) { // Combine all headers (most specific first) const combined = skillHeaders.join("\n\n---\n\n"); const MAX = 2000; // increased limit for multiple skills const header = combined.length > MAX ? combined.slice(0, MAX) + "…" : combined; const prefix = `Use this repository skill as context:\n\n${header}`; prompt = prompt ? `${prefix}\n\n${prompt}` : prefix; if (verbose) logger.info( `[skills] Injected ${skillHeaders.length} SKILL.md header(s) (${header.length} chars total)`, ); } else { if (verbose) logger.info("[skills] No SKILL.md found in directory hierarchy"); } } } catch (error) { // Non-fatal; continue without skills if (verbose) logger.warn("[skills] Failed to inject SKILL.md header:", { error }); } // Inject peer discovery hint when other agents are running if (swarmHint) { try { const peers = await readGlobalPids({ liveOnly: true }); if (peers.length > 0) { const hint = `${peers.length} peer agent${peers.length > 1 ? "s are" : " is"} running. Before asking the user for input on any domain-specific topic (design, testing, architecture, etc.), check for relevant peers first: \`ay ls --json\` (see \`prompt\` field for their role). Ask one: \`ay send \`. Read reply: \`ay tail \`. Do not use interactive forms or user prompts when a peer can answer.`; if (cli === "claude") { cliArgs = ["--append-system-prompt", hint, ...cliArgs]; } // Prepend to prompt for all CLIs (including claude) so it's read before the task prompt = prompt ? `[${hint}]\n\n${prompt}` : hint; } } catch { // Non-fatal } } // Handle --continue flag for codex session restoration if (resume) { if (cli === "codex" && resume) { // Try to get stored session for this directory const storedSessionId = await getSessionForCwd(workingDir); if (storedSessionId) { // Replace or add resume args cliArgs = ["resume", storedSessionId, ...cliArgs]; await logger.debug(`resume|using stored session ID: ${storedSessionId}`); } else { throw new Error( `No stored session found for codex in directory: ${workingDir}, please try without resume option.`, ); } } else if (cli === "claude") { // just add --continue flag for claude cliArgs = ["--continue", ...cliArgs]; await logger.debug(`resume|adding --continue flag for claude`); } else if (cli === "gemini") { // Gemini supports session resume natively via --resume flag // Sessions are project/directory-specific by default (stored in ~/.gemini/tmp//chats/) cliArgs = ["--resume", ...cliArgs]; await logger.debug(`resume|adding --resume flag for gemini`); } else { throw new Error( `Resume option is not supported for cli: ${cli}, make a feature request if you want it. https://github.com/snomiao/agent-yes/issues`, ); } } // If possible pass prompt via cli args, its usually faster than stdin if (prompt && cliConf.promptArg) { if (cliConf.promptArg === "first-arg") { cliArgs = [prompt, ...cliArgs]; prompt = undefined; // clear prompt to avoid sending later } else if (cliConf.promptArg === "last-arg") { cliArgs = [...cliArgs, prompt]; prompt = undefined; // clear prompt to avoid sending later } else if (cliConf.promptArg.startsWith("--")) { cliArgs = [cliConf.promptArg, prompt, ...cliArgs]; prompt = undefined; // clear prompt to avoid sending later } else { logger.warn(`Unknown promptArg format: ${cliConf.promptArg}`); } } // Spawn the agent CLI process const ptyEnv = { ...(env ?? (process.env as Record)) }; ptyEnv.AGENT_YES_PID = String(process.pid); const ptyOptions = { name: "xterm-color", ...getTerminalDimensions(), cwd: cwd ?? process.cwd(), env: ptyEnv, }; let shell = spawnAgent({ cli, cliConf, cliArgs, verbose, install, ptyOptions, }); // Wire up the xterm proxy to write back to the PTY shellWrite = (data: string) => shell.write(data); // Attach data handler IMMEDIATELY after spawn to avoid losing early PTY output. // node-pty emits 'data' events eagerly — if no listener is attached, events are lost. function onData(data: string) { const currentPid = shell.pid; xtermProxy.write(data); globalAgentRegistry.appendStdout(currentPid, data); } shell.onData(onData); // Register process in pidStore (non-blocking - failures should not prevent agent from running) try { await pidStore.registerProcess({ pid: shell.pid, cli, args: cliArgs, prompt, cwd: workingDir }); } catch (error) { logger.warn(`[pidStore] Failed to register process ${shell.pid}:`, error); } notifyWebhook("RUNNING", prompt ?? "", workingDir).catch(() => null); // Initialize log paths (independent of registration) const logPaths = await initializeLogPaths(pidStore, shell.pid); await setupDebugLogging(logPaths.debuggingLogsPath); // Create agent context const ctx = new AgentContext({ shell, pidStore, logPaths, cli, cliConf, verbose, robust, autoYes, }); // Register agent in global registry (non-blocking) try { globalAgentRegistry.register(shell.pid, { pid: shell.pid, context: ctx, cwd: workingDir, cli, prompt, startTime: Date.now(), stdoutBuffer: [], }); } catch (error) { logger.warn(`[agentRegistry] Failed to register agent ${shell.pid}:`, error); } // Show startup mode if not default (i.e., when starting in manual mode) if (!ctx.autoYesEnabled) { process.stderr.write("\x1b[33m[auto-yes: OFF]\x1b[0m Press Ctrl+Y to toggle\n"); } // If ready check is disabled (empty array) or manual mode, mark stdin ready immediately // Manual mode needs immediate stdin so user can respond to trust prompts if ((cliConf.ready && cliConf.ready.length === 0) || !ctx.autoYesEnabled) { ctx.stdinReady.ready(); ctx.stdinFirstReady.ready(); } // force ready after 10s to avoid stuck forever if the ready-word mismatched sleep(10e3).then(() => { if (!ctx.stdinReady.isReady) ctx.stdinReady.ready(); if (!ctx.stdinFirstReady.isReady) ctx.stdinFirstReady.ready(); }); const pendingExitCode = Promise.withResolvers(); shell.onExit(async function onExit({ exitCode }) { const exitedPid = shell.pid; // Capture PID immediately before any shell reassignment // Unregister from agent registry globalAgentRegistry.unregister(exitedPid); ctx.stdinReady.unready(); // start buffer stdin // Exit codes 130 (SIGINT/Ctrl+C) and 143 (SIGTERM) are intentional exits, not crashes // Also check if user sent Ctrl+C recently (within last 2 seconds) const intentionalExit = exitCode === 130 || exitCode === 143 || userSentCtrlC; const agentCrashed = exitCode !== 0 && !intentionalExit; // Handle restart without continue args (e.g., "No conversation found to continue") // logger.debug(``, { shouldRestartWithoutContinue, robust }) if (ctx.shouldRestartWithoutContinue) { // Update status (non-blocking) try { await pidStore.updateStatus(exitedPid, "exited", { exitReason: "restarted", exitCode: exitCode ?? undefined, }); } catch (error) { logger.warn(`[pidStore] Failed to update status for PID ${exitedPid}:`, error); } ctx.shouldRestartWithoutContinue = false; // reset flag ctx.isFatal = false; // reset fatal flag to allow restart // Enforce restart limit with exponential backoff if (ctx.restartCount >= 10) { logger.error(`${cli} reached max restarts (10), giving up.`); return pendingExitCode.resolve(exitCode); } const backoffMs = 1000 * Math.pow(2, ctx.restartCount); logger.info(`Restart ${ctx.restartCount + 1}/10, waiting ${backoffMs}ms before restart...`); await sleep(backoffMs); ctx.restartCount++; // Restart without continue args - use original cliArgs without restoreArgs const cliCommand = cliConf?.binary || cli; let [bin, ...args] = [ ...parseCommandString(cliCommand), ...cliArgs.filter((arg) => !["--continue", "--resume"].includes(arg)), ]; logger.info(`Restarting ${cli} ${JSON.stringify([bin, ...args])}`); const restartPtyOptions = { name: "xterm-color", ...getTerminalDimensions(), cwd: cwd ?? process.cwd(), env: ptyEnv, }; shell = pty.spawn(bin!, args, restartPtyOptions); shellWrite = (data: string) => shell.write(data); // Register process in pidStore (non-blocking) try { await pidStore.registerProcess({ pid: shell.pid, cli, args, prompt, cwd: workingDir }); } catch (error) { logger.warn(`[pidStore] Failed to register restarted process ${shell.pid}:`, error); } // Update context with new shell ctx.shell = shell; // Register new agent in registry (non-blocking) try { globalAgentRegistry.register(shell.pid, { pid: shell.pid, context: ctx, cwd: workingDir, cli, prompt, startTime: Date.now(), stdoutBuffer: [], }); } catch (error) { logger.warn(`[agentRegistry] Failed to register restarted agent ${shell.pid}:`, error); } shell.onData(onData); shell.onExit(onExit); // Re-mark stdin ready for manual mode after restart if ((cliConf.ready && cliConf.ready.length === 0) || !ctx.autoYesEnabled) { ctx.stdinReady.ready(); ctx.stdinFirstReady.ready(); } return; } if (agentCrashed && robust && conf?.restoreArgs) { if (!conf.restoreArgs) { logger.warn( `robust is only supported for ${Object.entries(CLIS_CONFIG) .filter(([_, v]) => v.restoreArgs) .map(([k]) => k) .join(", ")} currently, not ${cli}`, ); return; } if (ctx.isFatal) { // Update status (non-blocking) try { await pidStore.updateStatus(exitedPid, "exited", { exitReason: "fatal", exitCode: exitCode ?? undefined, }); } catch (error) { logger.warn(`[pidStore] Failed to update status for PID ${exitedPid}:`, error); } notifyWebhook("EXIT", `fatal exitCode=${exitCode ?? "?"}`, workingDir).catch(() => null); return pendingExitCode.resolve(exitCode); } // Enforce restart limit with exponential backoff if (ctx.restartCount >= 10) { logger.error(`${cli} reached max restarts (10), giving up.`); notifyWebhook("EXIT", `max-restarts exitCode=${exitCode ?? "?"}`, workingDir).catch( () => null, ); return pendingExitCode.resolve(exitCode); } const backoffMs = 1000 * Math.pow(2, ctx.restartCount); logger.info( `${cli} crashed (exit code: ${exitCode}), restart ${ctx.restartCount + 1}/10 in ${backoffMs}ms...`, ); await sleep(backoffMs); ctx.restartCount++; // Update status (non-blocking) try { await pidStore.updateStatus(exitedPid, "exited", { exitReason: "restarted", exitCode: exitCode ?? undefined, }); } catch (error) { logger.warn(`[pidStore] Failed to update status for PID ${exitedPid}:`, error); } // For codex, try to use stored session ID for this directory let restoreArgs = conf.restoreArgs; if (cli === "codex") { const storedSessionId = await getSessionForCwd(workingDir); if (storedSessionId) { // Use specific session ID instead of --last restoreArgs = ["resume", storedSessionId]; logger.debug(`restore|using stored session ID: ${storedSessionId}`); } else { logger.debug(`restore|no stored session, using default restore args`); } } const restorePtyOptions = { name: "xterm-color", ...getTerminalDimensions(), cwd: cwd ?? process.cwd(), env: ptyEnv, }; shell = pty.spawn(cli, restoreArgs, restorePtyOptions); shellWrite = (data: string) => shell.write(data); // Register process in pidStore (non-blocking) try { await pidStore.registerProcess({ pid: shell.pid, cli, args: restoreArgs, prompt, cwd: workingDir, }); } catch (error) { logger.warn(`[pidStore] Failed to register restored process ${shell.pid}:`, error); } // Update context with new shell ctx.shell = shell; // Register new agent in registry (non-blocking) try { globalAgentRegistry.register(shell.pid, { pid: shell.pid, context: ctx, cwd: workingDir, cli, prompt, startTime: Date.now(), stdoutBuffer: [], }); } catch (error) { logger.warn(`[agentRegistry] Failed to register restored agent ${shell.pid}:`, error); } shell.onData(onData); shell.onExit(onExit); // Re-mark stdin ready for manual mode after restart if ((cliConf.ready && cliConf.ready.length === 0) || !ctx.autoYesEnabled) { ctx.stdinReady.ready(); ctx.stdinFirstReady.ready(); } return; } const exitReason = agentCrashed ? "crash" : "normal"; // Update status (non-blocking) try { await pidStore.updateStatus(exitedPid, "exited", { exitReason, exitCode: exitCode ?? undefined, }); } catch (error) { logger.warn(`[pidStore] Failed to update status for PID ${exitedPid}:`, error); } notifyWebhook("EXIT", `${exitReason} exitCode=${exitCode ?? "?"}`, workingDir).catch( () => null, ); return pendingExitCode.resolve(exitCode); }); // when current tty resized, resize both pty and xterm proxy process.stdout.on("resize", () => { const { cols, rows } = getTerminalDimensions(); shell.resize(cols, rows); xtermProxy.resize(cols, rows); }); const isStillWorkingQ = () => { const rendered = xtermProxy.tail(24).replace(/\s+/g, " "); return conf.working?.some((rgx) => rgx.test(rendered)); }; // Heartbeat for auto-response on rendered terminal output // This catches patterns that appear via CSI positioning instead of newlines let lastHeartbeatRendered = ""; const heartbeatInterval = setInterval(async () => { try { const rendered = removeControlCharacters(xtermProxy.tail(12)); // Skip if output hasn't changed since last heartbeat if (rendered === lastHeartbeatRendered) return; lastHeartbeatRendered = rendered; const lines = rendered.split("\n").filter((line) => line.trim()); for (const line of lines) { // ready matcher: if matched, mark stdin ready if (conf.ready?.some((rx: RegExp) => rx.test(line))) { logger.debug(`heartbeat|ready |${line}`); ctx.stdinReady.ready(); ctx.stdinFirstReady.ready(); } // enter matchers: send Enter when any enter regex matches if (conf.enter?.some((rx: RegExp) => rx.test(line))) { logger.debug(`heartbeat|sendEnter matched|${line}`); await sendEnter(ctx.messageContext, 400); continue; } // typingRespond matcher: if matched, send the specified message const typeingRespondMatched = Object.entries(conf.typingRespond ?? {}).filter( ([_sendString, onThePatterns]) => onThePatterns.some((rx) => rx.test(line)), ); if (typeingRespondMatched.length) { await sflow(typeingRespondMatched) .map( async ([sendString]) => await sendMessage(ctx.messageContext, sendString, { waitForReady: false }), ) .toCount(); continue; } // fatal matchers: set isFatal flag when matched if (conf.fatal?.some((rx: RegExp) => rx.test(line))) { logger.debug(`heartbeat|fatal |${line}`); ctx.isFatal = true; await exitAgent(); break; } // restartWithoutContinueArg matchers: set flag to restart without continue args if (conf.restartWithoutContinueArg?.some((rx: RegExp) => rx.test(line))) { logger.debug(`heartbeat|restart-without-continue|${line}`); ctx.shouldRestartWithoutContinue = true; ctx.isFatal = true; await exitAgent(); break; } // session ID capture for codex if (cli === "codex") { const sessionId = extractSessionId(line); if (sessionId) { logger.debug(`heartbeat|session|captured session ID: ${sessionId}`); await storeSessionForCwd(workingDir, sessionId); } } } } catch (error) { // Silently ignore heartbeat errors to avoid disrupting main flow logger.debug(`heartbeat|error: ${error}`); } }, 800); // Run every 800ms // Clear heartbeat on exit const cleanupHeartbeat = () => clearInterval(heartbeatInterval); shell.onExit(cleanupHeartbeat); if (exitOnIdle) (async () => { while (true) { await ctx.idleWaiter.wait(exitOnIdle); await pidStore.updateStatus(shell.pid, "idle").catch(() => null); if (isStillWorkingQ()) { logger.warn(`[${cli}-yes] ${cli} is idle, but seems still working, not exiting yet`); continue; } if (idleAction) { logger.info(`[${cli}-yes] ${cli} is idle, performing idle action: ${idleAction}`); notifyWebhook("IDLE", `action=${idleAction}`, workingDir).catch(() => null); await sendMessage(ctx.messageContext, idleAction); continue; } logger.info(`[${cli}-yes] ${cli} is idle, exiting...`); notifyWebhook("IDLE", "", workingDir).catch(() => null); await exitAgent(); break; } })(); // Message streaming // Message streaming with stdin and optional FIFO (Linux only) // read stdin stream // CRITICAL FIX: fromReadable() from 'from-node-stream' doesn't work properly with stdin // because it doesn't handle Node.js stream modes correctly. We create a custom ReadableStream // that properly manages stdin's flowing mode and event listeners. const stdinStream = new ReadableStream( { start(controller) { // Set up stdin in flowing mode so 'data' events fire process.stdin.resume(); let closed = false; // Handle data events const dataHandler = (chunk: Buffer) => { try { controller.enqueue(chunk); } catch { // Ignore enqueue errors (stream may be closed) } }; // Handle end/close - both events can fire, so track state const endHandler = () => { if (closed) return; closed = true; try { controller.close(); } catch { // Ignore close errors (already closed) } }; const errorHandler = (err: Error) => { if (closed) return; closed = true; try { controller.error(err); } catch { // Ignore error after close } }; process.stdin.on("data", dataHandler); process.stdin.on("end", endHandler); process.stdin.on("close", endHandler); process.stdin.on("error", errorHandler); }, cancel(_reason) { process.stdin.pause(); }, }, { highWaterMark: 16 }, ); let aborted = false; await sflow(stdinStream) .map((buffer) => { const str = buffer.toString(); // CRITICAL FIX: Handle Ctrl+C directly in map instead of forkTo // The previous implementation used .forkTo() which created a separate stream branch // that wasn't being consumed properly, causing Ctrl+C to never be detected. const CTRL_Z = "\u001A"; const CTRL_C = "\u0003"; // handle CTRL+Z and filter it out (not supported yet) if (!aborted && str === CTRL_Z) { return ""; } // handle CTRL+C when stdin is not ready (agent is loading) if (!aborted && !ctx.stdinReady.isReady && str === CTRL_C) { logger.error("User aborted: SIGINT"); shell.kill("SIGINT"); pendingExitCode.resolve(130); // SIGINT exit code aborted = true; return str; // still pass to agent, but they'll probably be killed } // Track Ctrl+C when stdin is ready (user is interrupting running CLI) if (str === CTRL_C) { userSentCtrlC = true; // Reset flag after 2 seconds in case CLI doesn't exit immediately setTimeout(() => { userSentCtrlC = false; }, 2000); } return str; }) // Detect Ctrl+Y or /auto command to toggle auto-yes mode .map( (() => { let line = ""; const toggleAutoYes = () => { ctx.autoYesEnabled = !ctx.autoYesEnabled; // When switching to manual mode, mark stdin ready so user keystrokes are not blocked if (!ctx.autoYesEnabled) { ctx.stdinReady.ready(); ctx.stdinFirstReady.ready(); } const status = ctx.autoYesEnabled ? "\x1b[32m[auto-yes: ON]\x1b[0m" : "\x1b[33m[auto-yes: OFF]\x1b[0m"; process.stderr.write(`\r${status} (Ctrl+Y to toggle)\n`); }; return (data: string) => { let out = ""; for (const ch of data) { // Ctrl+Y (\x19) toggles auto-yes immediately if (ch === "\x19") { toggleAutoYes(); // Do not forward Ctrl+Y to the PTY continue; } // Handle Enter if (ch === "\r" || ch === "\n") { // Only check for /auto if line is short enough if (line.length <= 20) { const cleanLine = line // oxlint-disable-next-line no-control-regex -- intentional: strip ANSI/control chars .replace(/[\x00-\x1f]|\x1b\[[0-9;]*[A-Za-z]|\[[A-Z]/g, "") .trim(); if (cleanLine === "/auto") { out += "\x15"; // Ctrl+U to clear the /auto text from shell input toggleAutoYes(); line = ""; continue; } } line = ""; out += ch; continue; } // Handle backspace if (ch === "\x7f" || ch === "\b") { if (line.length > 0) line = line.slice(0, -1); out += ch; continue; } // Track only printable ASCII for line, with size limit if (ch >= " " && ch <= "~" && line.length < 50) line += ch; out += ch; } return out; }; })(), ) // Read from IPC stream if available (FIFO on Linux, Named Pipes on Windows) .by(async (s) => { if (!useStdinAppend) return s; const fifoPath = pidStore.getFifoPath(shell.pid); const ipcResult = await createFifoStream(cli, fifoPath); if (!ipcResult) return s; pendingExitCode.promise.finally(async () => await ipcResult[Symbol.asyncDispose]()); process.stderr.write(`\n Append prompts: ${cli}-yes --append-prompt '...'\n\n`); return s.merge(ipcResult.stream); }) .confluenceByConcat() // necessary because .by() above is async // .map((e) => e.replaceAll('\x1a', '')) // remove ctrl+z from user's input, to prevent bug (but this seems bug) // .forEach(e => appendFile('.cache/io.log', "input |" + JSON.stringify(e) + '\n')) // for debugging .onStart(async function promptOnStart() { // send prompt when start logger.debug("Sending prompt message: " + JSON.stringify(prompt)); if (prompt) await sendMessage(ctx.messageContext, prompt); }) // pipe content by shell .by({ writable: new WritableStream({ write: async (data) => { await ctx.stdinReady.wait(); shell.write(data); }, }), readable: xtermProxy.readable, }) .forEach(() => { ctx.idleWaiter.ping(); pidStore.updateStatus(shell.pid, "active").catch(() => null); ctx.nextStdout.ready(); }) .forkTo(async function rawLogger(f) { const rawLogPath = ctx.logPaths.rawLogPath; if (!rawLogPath) return f.run(); // no stream // try stream the raw log for realtime debugging, including control chars, note: it will be a huge file return await mkdir(path.dirname(rawLogPath), { recursive: true }) .then(() => { logger.debug(`[${cli}-yes] raw logs streaming to ${rawLogPath}`); return f .forEach(async (chars) => { await writeFile(rawLogPath, chars, { flag: "a" }).catch(() => null); }) .run(); }) .catch(() => f.run()); }) // handle cursor position requests and render terminal output .by(function consoleResponder(e) { // TODO: wait for cli ready and send prompt if provided // if (cli === "codex" && !process.stdin.isTTY) shell.write(`\u001b[1;1R`); // send cursor position response when stdin is not tty let lastRendered = ""; return ( e // Terminal query responses (DA, DSR, etc.) are handled automatically // by XtermProxy via @xterm/headless — no ad-hoc interception needed. .forEach(async (line, lineIndex) => { // ============ respond on rendered screen const rendered = xtermProxy.tail(24); // Skip processing if output hasn't changed if (rendered === lastRendered) return; lastRendered = rendered; logger.debug(`stdout|${line}`); // ready matcher: if matched, mark stdin ready if (conf.ready?.some((rx: RegExp) => line.match(rx))) { logger.debug(`ready |${line}`); if (cli === "gemini" && lineIndex <= 80) return; // gemini initial noise, only after many lines ctx.stdinReady.ready(); ctx.stdinFirstReady.ready(); } // enter matchers: send Enter when any enter regex matches if (conf.enter?.some((rx: RegExp) => line.match(rx))) { logger.debug(`sendEnter matched|${line}`); return await sendEnter(ctx.messageContext, 400); // wait for idle for a short while and then send Enter } // typingRespond matcher: if matched, send the specified message const typeingRespondMatched = Object.entries(conf.typingRespond ?? {}).filter( ([_sendString, onThePatterns]) => onThePatterns.some((rx) => line.match(rx)), ); const typingResponded = typeingRespondMatched.length && (await sflow(typeingRespondMatched) .map( async ([sendString]) => await sendMessage(ctx.messageContext, sendString, { waitForReady: false }), ) .toCount()); if (typingResponded) return; // fatal matchers: set isFatal flag when matched if (conf.fatal?.some((rx: RegExp) => line.match(rx))) { logger.debug(`fatal |${line}`); ctx.isFatal = true; await exitAgent(); } // restartWithoutContinueArg matchers: set flag to restart without continue args if (conf.restartWithoutContinueArg?.some((rx: RegExp) => line.match(rx))) { logger.debug(`restart-without-continue|${line}`); ctx.shouldRestartWithoutContinue = true; ctx.isFatal = true; // also set fatal to trigger exit await exitAgent(); } // session ID capture for codex if (cli === "codex") { const sessionId = extractSessionId(line); if (sessionId) { logger.debug(`session|captured session ID: ${sessionId}`); await storeSessionForCwd(workingDir, sessionId); } } }) ); }) // auto-response // .forkTo(function autoResponse(e) { // return ( // e // .map((e) => removeControlCharacters(e)) // // .map((e) => e.replaceAll("\r", "")) // remove carriage return // .by((s) => { // if (conf.noEOL) return s; // codex use cursor-move csi code insteadof \n to move lines, so the output have no \n at all, this hack prevents stuck on unended line // return s.lines({ EOL: "NONE" }); // other clis use ink, which is rerendering the block based on \n lines // }) // // Generic auto-response handler driven by CLI_CONFIGURES // .forEach(async (line, lineIndex) => // createAutoResponseHandler(line, lineIndex, { ctx, conf, cli, workingDir, exitAgent }), // ) // .run() // ); // }) .by((s) => (removeControlCharactersFromStdout ? s.map((e) => removeControlCharacters(e)) : s)) // terminate whole stream when shell did exited (already crash-handled) .by(createTerminatorStream(pendingExitCode.promise)) .to(fromWritable(process.stdout)); await saveLogFile(ctx.logPaths.logPath, xtermProxy.render()); // and then get its exitcode const exitCode = await pendingExitCode.promise; logger.info(`[${cli}-yes] ${cli} exited with code ${exitCode}`); // Final pidStore cleanup await pidStore.close(); // Capture final render before disposing xterm proxy const finalRender = xtermProxy.render(); xtermProxy.dispose(); // deprecated logFile option, we have logPath now, but keep for backward compatibility await saveDeprecatedLogFile(logFile, finalRender, verbose); return { exitCode, logs: finalRender }; async function exitAgent() { ctx.robust = false; // disable robust to avoid auto restart // send exit command to the shell, must sleep a bit to avoid claude treat it as pasted input for (const cmd of cliConf.exitCommands ?? ["/exit"]) await sendMessage(ctx.messageContext, cmd); // wait for shell to exit or kill it with a timeout let exited = false; await Promise.race([ pendingExitCode.promise.then(() => (exited = true)), // resolve when shell exits // if shell doesn't exit in 5 seconds, kill it new Promise((resolve) => setTimeout(() => { if (exited) return; // if shell already exited, do nothing shell.kill(); // kill the shell process if it doesn't exit in time resolve(); }, 5000), ), // 5 seconds timeout ]); } function getTerminalDimensions() { if (!process.stdout.isTTY) return { cols: 80, rows: 24 }; // default size when not tty return { // Enforce minimum 20 columns to avoid layout issues cols: Math.max(20, process.stdout.columns), rows: process.stdout.rows, }; } } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); }