claude-yes 1.31.1 → 1.32.1

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