agent-yes 1.31.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/index.ts ADDED
@@ -0,0 +1,783 @@
1
+ import { execaCommandSync, parseCommandString } from "execa";
2
+ import { fromReadable, fromWritable } from "from-node-stream";
3
+ import { mkdir, readFile, writeFile } from "fs/promises";
4
+ import path from "path";
5
+ import DIE from "phpdie";
6
+ import sflow from "sflow";
7
+ import { TerminalTextRender } from "terminal-render";
8
+ import { catcher } from "./catcher.ts";
9
+ import {
10
+ extractSessionId,
11
+ getSessionForCwd,
12
+ storeSessionForCwd,
13
+ } from "./resume/codexSessionManager.ts";
14
+ import { IdleWaiter } from "./idleWaiter.ts";
15
+ import pty, { ptyPackage } from "./pty.ts";
16
+ import { ReadyManager } from "./ReadyManager.ts";
17
+ import { removeControlCharacters } from "./removeControlCharacters.ts";
18
+ import { acquireLock, releaseLock, shouldUseLock } from "./runningLock.ts";
19
+ import { logger } from "./logger.ts";
20
+ import { createFifoStream } from "./beta/fifo.ts";
21
+ import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
22
+ import winston from "winston";
23
+ import { mapObject, pipe } from "rambda";
24
+
25
+ export { removeControlCharacters };
26
+
27
+ export type AgentCliConfig = {
28
+ // cli
29
+ install?: string; // hint user for install command if not installed
30
+ version?: string; // hint user for version command to check if installed
31
+ binary?: string; // actual binary name if different from cli, e.g. cursor -> cursor-agent
32
+ defaultArgs?: string[]; // function to ensure certain args are present
33
+
34
+ // status detect, and actions
35
+ ready?: RegExp[]; // regex matcher for stdin ready, or line index for gemini
36
+ fatal?: RegExp[]; // array of regex to match for fatal errors
37
+ exitCommands?: string[]; // commands to exit the cli gracefully
38
+ promptArg?: (string & {}) | "first-arg" | "last-arg"; // argument name to pass the prompt, e.g. --prompt, or first-arg for positional arg
39
+
40
+ // handle special format
41
+ 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
42
+
43
+ // auto responds
44
+ enter?: RegExp[]; // array of regex to match for sending Enter
45
+ typingRespond?: { [message: string]: RegExp[] }; // type specified message to a specified pattern
46
+
47
+ // crash/resuming-session behaviour
48
+ restoreArgs?: string[]; // arguments to continue the session when crashed
49
+ restartWithoutContinueArg?: RegExp[]; // array of regex to match for errors that require restart without continue args
50
+ };
51
+ export type AgentYesConfig = {
52
+ configDir?: string; // directory to store agent-yes config files, e.g. session store
53
+ logsDir?: string; // directory to store agent-yes log files
54
+ clis: { [key: string]: AgentCliConfig };
55
+ };
56
+
57
+ // load user config from agent-yes.config.ts if exists
58
+ export const config = await import("../agent-yes.config.ts").then((mod) => mod.default || mod);
59
+ export const CLIS_CONFIG = config.clis as Record<
60
+ keyof Awaited<typeof config>["clis"],
61
+ AgentCliConfig
62
+ >;
63
+
64
+ /**
65
+ * Main function to run agent-cli with automatic yes/no responses
66
+ * @param options Configuration options
67
+ * @param options.continueOnCrash - If true, automatically restart agent-cli when it crashes:
68
+ * 1. Shows message 'agent-cli crashed, restarting..'
69
+ * 2. Spawns a new 'agent-cli --continue' process
70
+ * 3. Re-attaches the new process to the shell stdio (pipes new process stdin/stdout)
71
+ * 4. If it crashes with "No conversation found to continue", exits the process
72
+ * @param options.exitOnIdle - Exit when agent-cli is idle. Boolean or timeout in milliseconds, recommended 5000 - 60000, default is false
73
+ * @param options.cliArgs - Additional arguments to pass to the agent-cli CLI
74
+ * @param options.removeControlCharactersFromStdout - Remove ANSI control characters from stdout. Defaults to !process.stdout.isTTY
75
+ * @param options.disableLock - Disable the running lock feature that prevents concurrent agents in the same directory/repo
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * import agentYes from 'agent-yes';
80
+ * await agentYes({
81
+ * prompt: 'help me solve all todos in my codebase',
82
+ *
83
+ * // optional
84
+ * cliArgs: ['--verbose'], // additional args to pass to agent-cli
85
+ * exitOnIdle: 30000, // exit after 30 seconds of idle
86
+ * robust: true, // auto restart with --continue if claude crashes, default is true
87
+ * logFile: 'claude-output.log', // save logs to file
88
+ * disableLock: false, // disable running lock (default is false)
89
+ * });
90
+ * ```
91
+ */
92
+ export default async function agentYes({
93
+ cli,
94
+ cliArgs = [],
95
+ prompt,
96
+ robust = true,
97
+ cwd,
98
+ env,
99
+ exitOnIdle,
100
+ logFile,
101
+ removeControlCharactersFromStdout = false, // = !process.stdout.isTTY,
102
+ verbose = false,
103
+ queue = false,
104
+ install = false,
105
+ resume = false,
106
+ useSkills = false,
107
+ useFifo = false,
108
+ }: {
109
+ cli: SUPPORTED_CLIS;
110
+ cliArgs?: string[];
111
+ prompt?: string;
112
+ robust?: boolean;
113
+ cwd?: string;
114
+ env?: Record<string, string>;
115
+ exitOnIdle?: number;
116
+ logFile?: string;
117
+ removeControlCharactersFromStdout?: boolean;
118
+ verbose?: boolean;
119
+ queue?: boolean;
120
+ install?: boolean; // if true, install the cli tool if not installed, e.g. will run `npm install -g cursor-agent`
121
+ resume?: boolean; // if true, resume previous session in current cwd if any
122
+ useSkills?: boolean; // if true, prepend SKILL.md header to the prompt for non-Claude agents
123
+ useFifo?: boolean; // if true, enable FIFO input stream on Linux for additional stdin input
124
+ }) {
125
+ // those overrides seems only works in bun
126
+ // await Promise.allSettled([
127
+ // import(path.join(process.cwd(), "agent-yes.config")),
128
+ // ])
129
+ // .then((e) => e.flatMap((e) => (e.status === "fulfilled" ? [e.value] : [])))
130
+ // .then(e=>e.at(0))
131
+ // .then((e) => e.default as ReturnType<typeof defineAgentYesConfig>)
132
+ // .then(async (override) => deepMixin(config, override || {}))
133
+ // .catch((error) => {
134
+ // if (process.env.VERBOSE)
135
+ // console.warn("Fail to load agent-yes.config.ts", error);
136
+ // });
137
+
138
+ if (!cli) throw new Error(`cli is required`);
139
+ const conf =
140
+ CLIS_CONFIG[cli] ||
141
+ DIE(`Unsupported cli tool: ${cli}, current process.argv: ${process.argv.join(" ")}`);
142
+
143
+ // Acquire lock before starting agent (if in git repo or same cwd and lock is not disabled)
144
+ const workingDir = cwd ?? process.cwd();
145
+ if (queue) {
146
+ if (queue && shouldUseLock(workingDir)) {
147
+ await acquireLock(workingDir, prompt ?? "Interactive session");
148
+ }
149
+
150
+ // Register cleanup handlers for lock release
151
+ const cleanupLock = async () => {
152
+ if (queue && shouldUseLock(workingDir)) {
153
+ await releaseLock().catch(() => null); // Ignore errors during cleanup
154
+ }
155
+ };
156
+
157
+ process.on("exit", () => {
158
+ if (queue) releaseLock().catch(() => null);
159
+ });
160
+ process.on("SIGINT", async (code) => {
161
+ await cleanupLock();
162
+ process.exit(code);
163
+ });
164
+ process.on("SIGTERM", async (code) => {
165
+ await cleanupLock();
166
+ process.exit(code);
167
+ });
168
+ }
169
+
170
+ process.stdin.setRawMode?.(true); // must be called any stdout/stdin usage
171
+
172
+ let isFatal = false; // when true, do not restart on crash, and exit agent
173
+ let shouldRestartWithoutContinue = false; // when true, restart without continue args
174
+
175
+ const stdinReady = new ReadyManager();
176
+ const stdinFirstReady = new ReadyManager(); // if user send ctrl+c before
177
+
178
+ // force ready after 10s to avoid stuck forever if the ready-word mismatched
179
+ sleep(10e3).then(() => {
180
+ if (!stdinReady.isReady) stdinReady.ready();
181
+ if (!stdinFirstReady.isReady) stdinFirstReady.ready();
182
+ });
183
+ const nextStdout = new ReadyManager();
184
+
185
+ const shellOutputStream = new TransformStream<string, string>();
186
+ const outputWriter = shellOutputStream.writable.getWriter();
187
+
188
+ logger.debug(`Using ${ptyPackage} for pseudo terminal management.`);
189
+
190
+ const datetime = new Date().toISOString().replace(/\D/g, "").slice(0, 17);
191
+ const logPath = config.logsDir && path.resolve(config.logsDir, `${cli}-yes-${datetime}.log`);
192
+ const rawLogPath =
193
+ config.logsDir && path.resolve(config.logsDir, `${cli}-yes-${datetime}.raw.log`);
194
+ const rawLinesLogPath =
195
+ config.logsDir && path.resolve(config.logsDir, `${cli}-yes-${datetime}.lines.log`);
196
+ const debuggingLogsPath =
197
+ config.logsDir && path.resolve(config.logsDir, `${cli}-yes-${datetime}.debug.log`);
198
+
199
+ // add
200
+ if (debuggingLogsPath) logger.add(new winston.transports.File({
201
+ filename: debuggingLogsPath,
202
+ level: "debug",
203
+ }))
204
+
205
+ // Detect if running as sub-agent
206
+ const isSubAgent = !!process.env.CLAUDE_PPID;
207
+ if (isSubAgent)
208
+ logger.info(`[${cli}-yes] Running as sub-agent (CLAUDE_PPID=${process.env.CLAUDE_PPID})`);
209
+
210
+
211
+ const getPtyOptions = () => {
212
+ const ptyEnv = { ...(env ?? (process.env as Record<string, string>)) };
213
+ return {
214
+ name: "xterm-color",
215
+ ...getTerminalDimensions(),
216
+ cwd: cwd ?? process.cwd(),
217
+ env: ptyEnv,
218
+ };
219
+ };
220
+
221
+ // Apply CLI specific configurations (moved to CLI_CONFIGURES)
222
+ const cliConf = (CLIS_CONFIG as Record<string, AgentCliConfig>)[cli] || {};
223
+ cliArgs = cliConf.defaultArgs ? [...cliConf.defaultArgs, ...cliArgs] : cliArgs;
224
+
225
+ // If enabled, read SKILL.md header and prepend to the prompt for non-Claude agents
226
+ try {
227
+ const workingDir = cwd ?? process.cwd();
228
+ if (useSkills && cli !== "claude") {
229
+ // Find git root to determine search boundary
230
+ let gitRoot: string | null = null;
231
+ try {
232
+ const result = execaCommandSync("git rev-parse --show-toplevel", {
233
+ cwd: workingDir,
234
+ reject: false,
235
+ });
236
+ if (result.exitCode === 0) {
237
+ gitRoot = result.stdout.trim();
238
+ }
239
+ } catch {
240
+ // Not a git repo, will only check cwd
241
+ }
242
+
243
+ // Walk up from cwd to git root (or stop at filesystem root) collecting SKILL.md files
244
+ const skillHeaders: string[] = [];
245
+ let currentDir = workingDir;
246
+ const searchLimit = gitRoot || path.parse(currentDir).root;
247
+
248
+ while (true) {
249
+ const skillPath = path.resolve(currentDir, "SKILL.md");
250
+ const md = await readFile(skillPath, "utf8").catch(() => null);
251
+ if (md) {
252
+ // Extract header (content before first level-2 heading `## `)
253
+ const headerMatch = md.match(/^[\s\S]*?(?=\n##\s)/);
254
+ const headerRaw = (headerMatch ? headerMatch[0] : md).trim();
255
+ if (headerRaw) {
256
+ skillHeaders.push(headerRaw);
257
+ if (verbose)
258
+ logger.info(`[skills] Found SKILL.md in ${currentDir} (${headerRaw.length} chars)`);
259
+ }
260
+ }
261
+
262
+ // Stop if we've reached git root or filesystem root
263
+ if (currentDir === searchLimit) break;
264
+
265
+ const parentDir = path.dirname(currentDir);
266
+ if (parentDir === currentDir) break; // Reached filesystem root
267
+ currentDir = parentDir;
268
+ }
269
+
270
+ if (skillHeaders.length > 0) {
271
+ // Combine all headers (most specific first)
272
+ const combined = skillHeaders.join("\n\n---\n\n");
273
+ const MAX = 2000; // increased limit for multiple skills
274
+ const header = combined.length > MAX ? combined.slice(0, MAX) + "…" : combined;
275
+ const prefix = `Use this repository skill as context:\n\n${header}`;
276
+ prompt = prompt ? `${prefix}\n\n${prompt}` : prefix;
277
+ if (verbose)
278
+ logger.info(
279
+ `[skills] Injected ${skillHeaders.length} SKILL.md header(s) (${header.length} chars total)`,
280
+ );
281
+ } else {
282
+ if (verbose) logger.info("[skills] No SKILL.md found in directory hierarchy");
283
+ }
284
+ }
285
+ } catch (error) {
286
+ // Non-fatal; continue without skills
287
+ if (verbose) logger.warn("[skills] Failed to inject SKILL.md header:", { error });
288
+ }
289
+
290
+ // Handle --continue flag for codex session restoration
291
+ if (resume) {
292
+ if (cli === "codex" && resume) {
293
+ // Try to get stored session for this directory
294
+ const storedSessionId = await getSessionForCwd(workingDir);
295
+ if (storedSessionId) {
296
+ // Replace or add resume args
297
+ cliArgs = ["resume", storedSessionId, ...cliArgs];
298
+ await logger.debug(`resume|using stored session ID: ${storedSessionId}`);
299
+ } else {
300
+ throw new Error(
301
+ `No stored session found for codex in directory: ${workingDir}, please try without resume option.`,
302
+ );
303
+ }
304
+ } else if (cli === "claude") {
305
+ // just add --continue flag for claude
306
+ cliArgs = ["--continue", ...cliArgs];
307
+ await logger.debug(`resume|adding --continue flag for claude`);
308
+ } else if (cli === "gemini") {
309
+ // Gemini supports session resume natively via --resume flag
310
+ // Sessions are project/directory-specific by default (stored in ~/.gemini/tmp/<project_hash>/chats/)
311
+ cliArgs = ["--resume", ...cliArgs];
312
+ await logger.debug(`resume|adding --resume flag for gemini`);
313
+ } else {
314
+ throw new Error(
315
+ `Resume option is not supported for cli: ${cli}, make a feature request if you want it. https://github.com/snomiao/agent-yes/issues`,
316
+ );
317
+ }
318
+ }
319
+
320
+ // If possible pass prompt via cli args, its usually faster than stdin
321
+ if (prompt && cliConf.promptArg) {
322
+ if (cliConf.promptArg === "first-arg") {
323
+ cliArgs = [prompt, ...cliArgs];
324
+ prompt = undefined; // clear prompt to avoid sending later
325
+ } else if (cliConf.promptArg === "last-arg") {
326
+ cliArgs = [...cliArgs, prompt];
327
+ prompt = undefined; // clear prompt to avoid sending later
328
+ } else if (cliConf.promptArg.startsWith("--")) {
329
+ cliArgs = [cliConf.promptArg, prompt, ...cliArgs];
330
+ prompt = undefined; // clear prompt to avoid sending later
331
+ } else {
332
+ logger.warn(`Unknown promptArg format: ${cliConf.promptArg}`);
333
+ }
334
+ }
335
+ // Determine the actual cli command to run
336
+
337
+ const spawn = () => {
338
+ const cliCommand = cliConf?.binary || cli;
339
+ let [bin, ...args] = [...parseCommandString(cliCommand), ...cliArgs];
340
+ if (verbose) logger.info(`Spawning ${bin} with args: ${JSON.stringify(args)}`);
341
+ logger.info(`Spawning ${bin} with args: ${JSON.stringify(args)}`);
342
+ // throw new Error(JSON.stringify([bin!, args, getPtyOptions()]))
343
+ const spawned = pty.spawn(bin!, args, getPtyOptions());
344
+ logger.info(`[${cli}-yes] Spawned ${bin} with PID ${spawned.pid}`);
345
+ // if (globalThis.Bun)
346
+ // args = args.map((arg) => `'${arg.replace(/'/g, "\\'")}'`);
347
+ return spawned;
348
+ };
349
+
350
+ let shell = catcher(
351
+ // error handler
352
+ (error: unknown, _fn, ..._args) => {
353
+ logger.error(`Fatal: Failed to start ${cli}.`);
354
+
355
+ const isNotFound = isCommandNotFoundError(error);
356
+ if (cliConf?.install && isNotFound) {
357
+ logger.info(`Please install the cli by run ${cliConf.install}`);
358
+
359
+ if (install) {
360
+ logger.info(`Attempting to install ${cli}...`);
361
+ execaCommandSync(cliConf.install, { stdio: "inherit" });
362
+ logger.info(`${cli} installed successfully. Please rerun the command.`);
363
+ return spawn();
364
+ } else {
365
+ logger.error(
366
+ `If you did not installed it yet, Please install it first: ${cliConf.install}`,
367
+ );
368
+ throw error;
369
+ }
370
+ }
371
+
372
+ if (globalThis.Bun && error instanceof Error && error.stack?.includes("bun-pty")) {
373
+ // try to fix bun-pty issues
374
+ logger.error(`Detected bun-pty issue, attempted to fix it. Please try again.`);
375
+ require("./pty-fix");
376
+ // unable to retry with same process, so exit here.
377
+ }
378
+ throw error;
379
+
380
+ function isCommandNotFoundError(e: unknown) {
381
+ if (e instanceof Error) {
382
+ return (
383
+ e.message.includes("command not found") || // unix
384
+ e.message.includes("ENOENT") || // unix
385
+ e.message.includes("spawn") // windows
386
+ );
387
+ }
388
+ return false;
389
+ }
390
+ },
391
+ spawn,
392
+ )();
393
+ const pendingExitCode = Promise.withResolvers<number | null>();
394
+
395
+ async function onData(data: string) {
396
+ // append data to the buffer, so we can process it later
397
+ await outputWriter.write(data);
398
+ }
399
+
400
+ shell.onData(onData);
401
+ shell.onExit(async function onExit({ exitCode }) {
402
+ stdinReady.unready(); // start buffer stdin
403
+ const agentCrashed = exitCode !== 0;
404
+
405
+ // Handle restart without continue args (e.g., "No conversation found to continue")
406
+ // logger.debug(``, { shouldRestartWithoutContinue, robust })
407
+ if (shouldRestartWithoutContinue) {
408
+ shouldRestartWithoutContinue = false; // reset flag
409
+ isFatal = false; // reset fatal flag to allow restart
410
+
411
+ // Restart without continue args - use original cliArgs without restoreArgs
412
+ const cliCommand = cliConf?.binary || cli;
413
+ let [bin, ...args] = [
414
+ ...parseCommandString(cliCommand),
415
+ ...cliArgs.filter((arg) => !["--continue", "--resume"].includes(arg)),
416
+ ];
417
+ logger.info(`Restarting ${cli} ${JSON.stringify([bin, ...args])}`);
418
+
419
+ shell = pty.spawn(bin!, args, getPtyOptions());
420
+ shell.onData(onData);
421
+ shell.onExit(onExit);
422
+ return;
423
+ }
424
+
425
+ if (agentCrashed && robust && conf?.restoreArgs) {
426
+ if (!conf.restoreArgs) {
427
+ logger.warn(
428
+ `robust is only supported for ${Object.entries(CLIS_CONFIG)
429
+ .filter(([_, v]) => v.restoreArgs)
430
+ .map(([k]) => k)
431
+ .join(", ")} currently, not ${cli}`,
432
+ );
433
+ return;
434
+ }
435
+ if (isFatal) return pendingExitCode.resolve(exitCode);
436
+
437
+ logger.info(`${cli} crashed, restarting...`);
438
+
439
+ // For codex, try to use stored session ID for this directory
440
+ let restoreArgs = conf.restoreArgs;
441
+ if (cli === "codex") {
442
+ const storedSessionId = await getSessionForCwd(workingDir);
443
+ if (storedSessionId) {
444
+ // Use specific session ID instead of --last
445
+ restoreArgs = ["resume", storedSessionId];
446
+ logger.debug(`restore|using stored session ID: ${storedSessionId}`);
447
+ } else {
448
+ logger.debug(`restore|no stored session, using default restore args`);
449
+ }
450
+ }
451
+
452
+ shell = pty.spawn(cli, restoreArgs, getPtyOptions());
453
+ shell.onData(onData);
454
+ shell.onExit(onExit);
455
+ return;
456
+ }
457
+ return pendingExitCode.resolve(exitCode);
458
+ });
459
+
460
+ // when current tty resized, resize the pty too
461
+ process.stdout.on("resize", () => {
462
+ const { cols, rows } = getTerminalDimensions(); // minimum 80 columns to avoid layout issues
463
+ shell.resize(cols, rows); // minimum 80 columns to avoid layout issues
464
+ });
465
+
466
+ const terminalRender = new TerminalTextRender();
467
+ const isStillWorkingQ = () =>
468
+ terminalRender
469
+ .render()
470
+ .replace(/\s+/g, " ")
471
+ .match(/esc to interrupt|to run in background/);
472
+
473
+ const idleWaiter = new IdleWaiter();
474
+ if (exitOnIdle)
475
+ idleWaiter.wait(exitOnIdle).then(async () => {
476
+ if (isStillWorkingQ()) {
477
+ logger.warn("[${cli}-yes] ${cli} is idle, but seems still working, not exiting yet");
478
+ return;
479
+ }
480
+
481
+ logger.info("[${cli}-yes] ${cli} is idle, exiting...");
482
+ await exitAgent();
483
+ });
484
+
485
+ // Message streaming
486
+
487
+ // Message streaming with stdin and optional FIFO (Linux only)
488
+
489
+ await sflow(fromReadable<Buffer>(process.stdin))
490
+ .map((buffer) => buffer.toString())
491
+
492
+ .by(function handleTerminateSignals(s) {
493
+ let aborted = false;
494
+ return s.map((chunk) => {
495
+ // handle CTRL+Z and filter it out, as I dont know how to support it yet
496
+ if (!aborted && chunk === "\u001A") {
497
+ return "";
498
+ }
499
+ // handle CTRL+C, when stdin is not ready (no response from agent yet, usually this is when agent loading)
500
+ if (!aborted && !stdinReady.isReady && chunk === "\u0003") {
501
+ logger.error("User aborted: SIGINT");
502
+ shell.kill("SIGINT");
503
+ pendingExitCode.resolve(130); // SIGINT
504
+ aborted = true;
505
+ return chunk; // still pass into agent, but they prob be killed XD
506
+ }
507
+ return chunk; // normal inputs
508
+ });
509
+ })
510
+
511
+ // read from FIFO if available, e.g. /tmp/agent-yes-*.stdin, which can be used to send additional input from other processes
512
+ .by((s) => {
513
+ if (!useFifo) return s;
514
+ const fifoResult = createFifoStream(cli);
515
+ if (!fifoResult) return s;
516
+ pendingExitCode.promise.finally(() => fifoResult.cleanup());
517
+ return s.merge(fifoResult.stream);
518
+ })
519
+
520
+ // .map((e) => e.replaceAll('\x1a', '')) // remove ctrl+z from user's input, to prevent bug (but this seems bug)
521
+ // .forEach(e => appendFile('.cache/io.log', "input |" + JSON.stringify(e) + '\n')) // for debugging
522
+
523
+ .onStart(async function promptOnStart() {
524
+ // send prompt when start
525
+ logger.debug("Sending prompt message: " + JSON.stringify(prompt));
526
+ if (prompt) await sendMessage(prompt);
527
+ })
528
+
529
+ // pipe content by shell
530
+ .by({
531
+ writable: new WritableStream<string>({
532
+ write: async (data) => {
533
+ await stdinReady.wait();
534
+ shell.write(data);
535
+ },
536
+ }),
537
+ readable: shellOutputStream.readable,
538
+ })
539
+
540
+ .forEach(() => idleWaiter.ping())
541
+ .forEach(() => nextStdout.ready())
542
+
543
+ .forkTo(async function rawLogger(f) {
544
+ if (!rawLogPath) return f.run(); // no stream
545
+
546
+ // try stream the raw log for realtime debugging, including control chars, note: it will be a huge file
547
+ return await mkdir(path.dirname(rawLogPath), { recursive: true })
548
+ .then(() => {
549
+ logger.debug(`[${cli}-yes] raw logs streaming to ${rawLogPath}`);
550
+ return f
551
+ .forEach(async (chars) => {
552
+ await writeFile(rawLogPath, chars, { flag: "a" }).catch(() => null);
553
+ })
554
+ .run();
555
+ })
556
+ .catch(() => f.run());
557
+ })
558
+
559
+ // handle cursor position requests and render terminal output
560
+ .by(function consoleResponder(e) {
561
+ // wait for cli ready and send prompt if provided
562
+ if (cli === "codex") shell.write(`\u001b[1;1R`); // send cursor position response when stdin is not tty
563
+ return e.forEach((text) => {
564
+ // render terminal output for log file
565
+ terminalRender.write(text);
566
+
567
+ // Handle Device Attributes query (DA) - ESC[c or ESC[0c
568
+ // This must be handled regardless of TTY status
569
+ if (text.includes("\u001b[c") || text.includes("\u001b[0c")) {
570
+ // Respond shell with VT100 with Advanced Video Option
571
+ shell.write("\u001b[?1;2c");
572
+ if (verbose) {
573
+ logger.debug("device|respond DA: VT100 with Advanced Video Option");
574
+ }
575
+ return;
576
+ }
577
+
578
+ // todo: .onStatus((msg)=> shell.write(msg))
579
+ if (process.stdin.isTTY) return; // only handle it when stdin is not tty, because tty already handled this
580
+
581
+ if (!text.includes("\u001b[6n")) return; // only asked for cursor position
582
+ // todo: use terminalRender API to get cursor position when new version is available
583
+ // xterm replies CSI row; column R if asked cursor position
584
+ // https://en.wikipedia.org/wiki/ANSI_escape_code#:~:text=citation%20needed%5D-,xterm%20replies,-CSI%20row%C2%A0%3B
585
+ // when agent asking position, respond with row; col
586
+ // const rendered = terminalRender.render();
587
+ const { col, row } = terminalRender.getCursorPosition();
588
+ shell.write(`\u001b[${row};${col}R`); // reply cli when getting cursor position
589
+ logger.debug(`cursor|respond position: row=${String(row)}, col=${String(col)}`);
590
+ // const row = rendered.split('\n').length + 1;
591
+ // const col = (rendered.split('\n').slice(-1)[0]?.length || 0) + 1;
592
+ });
593
+ })
594
+
595
+ // auto-response
596
+ .forkTo(function autoResponse(e) {
597
+ return (
598
+ e
599
+ .map((e) => removeControlCharacters(e))
600
+ // .map((e) => e.replaceAll("\r", "")) // remove carriage return
601
+ .by((s) => {
602
+ 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
603
+ return s.lines({ EOL: "NONE" }); // other clis use ink, which is rerendering the block based on \n lines
604
+ })
605
+
606
+ // .forkTo(async function rawLinesLogger(f) {
607
+ // if (!rawLinesLogPath) return f.run(); // no stream
608
+ // // try stream the raw log for realtime debugging, including control chars, note: it will be a huge file
609
+ // return await mkdir(path.dirname(rawLinesLogPath), { recursive: true })
610
+ // .then(() => {
611
+ // logger.debug(`[${cli}-yes] raw lines logs streaming to ${rawLinesLogPath}`);
612
+ // return f
613
+ // .forEach(async (chars, i) => {
614
+ // await writeFile(rawLinesLogPath, `L${i}|` + chars, { flag: "a" }).catch(() => null);
615
+ // })
616
+ // .run();
617
+ // })
618
+ // .catch(() => f.run());
619
+ // })
620
+
621
+ // Generic auto-response handler driven by CLI_CONFIGURES
622
+ .forEach(async function autoResponseOnChunk(e, i) {
623
+ logger.debug(`stdout|${e}`);
624
+ // ready matcher: if matched, mark stdin ready
625
+ if (conf.ready?.some((rx: RegExp) => e.match(rx))) {
626
+ logger.debug(`ready |${e}`);
627
+ if (cli === "gemini" && i <= 80) return; // gemini initial noise, only after many lines
628
+ stdinReady.ready();
629
+ stdinFirstReady.ready();
630
+ }
631
+ // enter matchers: send Enter when any enter regex matches
632
+
633
+ if (conf.enter?.some((rx: RegExp) => e.match(rx))) {
634
+ logger.debug(`enter |${e}`);
635
+ return await sendEnter(400); // wait for idle for a short while and then send Enter
636
+ }
637
+
638
+ // typingRespond matcher: if matched, send the specified message
639
+ const typingResponded = await sflow(Object.entries(conf.typingRespond ?? {}))
640
+ .filter(([_sendString, onThePatterns]) => onThePatterns.some((rx) => e.match(rx)))
641
+ .map(async ([sendString]) => await sendMessage(sendString, { waitForReady: false }))
642
+ .toCount();
643
+ if (typingResponded) return;
644
+
645
+ // fatal matchers: set isFatal flag when matched
646
+ if (conf.fatal?.some((rx: RegExp) => e.match(rx))) {
647
+ logger.debug(`fatal |${e}`);
648
+ isFatal = true;
649
+ await exitAgent();
650
+ }
651
+
652
+ // restartWithoutContinueArg matchers: set flag to restart without continue args
653
+ if (conf.restartWithoutContinueArg?.some((rx: RegExp) => e.match(rx))) {
654
+ await logger.debug(`restart-without-continue|${e}`);
655
+ shouldRestartWithoutContinue = true;
656
+ isFatal = true; // also set fatal to trigger exit
657
+ await exitAgent();
658
+ }
659
+
660
+ // session ID capture for codex
661
+ if (cli === "codex") {
662
+ const sessionId = extractSessionId(e);
663
+ if (sessionId) {
664
+ await logger.debug(`session|captured session ID: ${sessionId}`);
665
+ await storeSessionForCwd(workingDir, sessionId);
666
+ }
667
+ }
668
+ })
669
+ .run()
670
+ );
671
+ })
672
+ .by((s) => (removeControlCharactersFromStdout ? s.map((e) => removeControlCharacters(e)) : s))
673
+
674
+ // terminate whole stream when shell did exited (already crash-handled)
675
+ .by(
676
+ new TransformStream({
677
+ start: function terminator(ctrl) {
678
+ pendingExitCode.promise.then(() => ctrl.terminate());
679
+ },
680
+ transform: (e, ctrl) => ctrl.enqueue(e),
681
+ flush: (ctrl) => ctrl.terminate(),
682
+ }),
683
+ )
684
+ .to(fromWritable(process.stdout));
685
+
686
+ if (logPath) {
687
+ await mkdir(path.dirname(logPath), { recursive: true }).catch(() => null);
688
+ await writeFile(logPath, terminalRender.render()).catch(() => null);
689
+ logger.info(`[${cli}-yes] Full logs saved to ${logPath}`);
690
+ }
691
+
692
+ // and then get its exitcode
693
+ const exitCode = await pendingExitCode.promise;
694
+ logger.info(`[${cli}-yes] ${cli} exited with code ${exitCode}`);
695
+
696
+ // Update task status.writable release lock
697
+ await outputWriter.close();
698
+
699
+ // deprecated logFile option, we have logPath now, but keep for backward compatibility
700
+ if (logFile) {
701
+ if (verbose) logger.info(`[${cli}-yes] Writing rendered logs to ${logFile}`);
702
+ const logFilePath = path.resolve(logFile);
703
+ await mkdir(path.dirname(logFilePath), { recursive: true }).catch(() => null);
704
+ await writeFile(logFilePath, terminalRender.render());
705
+ }
706
+
707
+ return { exitCode, logs: terminalRender.render() };
708
+
709
+ async function sendEnter(waitms = 1000) {
710
+ // wait for idle for a bit to let agent cli finish rendering
711
+ const st = Date.now();
712
+ await idleWaiter.wait(waitms); // wait for idle a while
713
+ const et = Date.now();
714
+ // process.stdout.write(`\ridleWaiter.wait(${waitms}) took ${et - st}ms\r`);
715
+ logger.debug(`sendEn| idleWaiter.wait(${String(waitms)}) took ${String(et - st)}ms`);
716
+ nextStdout.unready();
717
+ // send the enter key
718
+ shell.write("\r");
719
+
720
+ // retry once if not received any output in 1 second after sending Enter
721
+ await Promise.race([
722
+ nextStdout.wait(),
723
+ new Promise<void>((resolve) =>
724
+ setTimeout(() => {
725
+ if (!nextStdout.ready) {
726
+ shell.write("\r");
727
+ }
728
+ resolve();
729
+ }, 1000),
730
+ ),
731
+ ]);
732
+ }
733
+
734
+ async function sendMessage(message: string, { waitForReady = true } = {}) {
735
+ if (waitForReady) await stdinReady.wait();
736
+ // show in-place message: write msg and move cursor back start
737
+ logger.debug(`send |${message}`);
738
+ nextStdout.unready();
739
+ shell.write(message);
740
+ idleWaiter.ping(); // just sent a message, wait for echo
741
+ logger.debug(`waiting next stdout|${message}`);
742
+ await nextStdout.wait();
743
+ logger.debug(`sending enter`);
744
+ await sendEnter(1000);
745
+ logger.debug(`sent enter`);
746
+ }
747
+
748
+ async function exitAgent() {
749
+ robust = false; // disable robust to avoid auto restart
750
+
751
+ // send exit command to the shell, must sleep a bit to avoid claude treat it as pasted input
752
+ for (const cmd of cliConf.exitCommands ?? ["/exit"]) await sendMessage(cmd);
753
+
754
+ // wait for shell to exit or kill it with a timeout
755
+ let exited = false;
756
+ await Promise.race([
757
+ pendingExitCode.promise.then(() => (exited = true)), // resolve when shell exits
758
+
759
+ // if shell doesn't exit in 5 seconds, kill it
760
+ new Promise<void>((resolve) =>
761
+ setTimeout(() => {
762
+ if (exited) return; // if shell already exited, do nothing
763
+ shell.kill(); // kill the shell process if it doesn't exit in time
764
+ resolve();
765
+ }, 5000),
766
+ ), // 5 seconds timeout
767
+ ]);
768
+ }
769
+
770
+ function getTerminalDimensions() {
771
+ if (!process.stdout.isTTY) return { cols: 80, rows: 30 }; // default size when not tty
772
+ return {
773
+ // TODO: enforce minimum cols/rows to avoid layout issues
774
+ // cols: Math.max(process.stdout.columns, 80),
775
+ cols: Math.min(Math.max(20, process.stdout.columns), 80),
776
+ rows: process.stdout.rows,
777
+ };
778
+ }
779
+ }
780
+
781
+ function sleep(ms: number) {
782
+ return new Promise((resolve) => setTimeout(resolve, ms));
783
+ }