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/LICENSE +21 -0
- package/README.md +504 -0
- package/dist/agent-yes.js +2 -0
- package/dist/amp-yes.js +2 -0
- package/dist/auggie-yes.js +2 -0
- package/dist/claude-yes.js +2 -0
- package/dist/cli.js +31474 -0
- package/dist/cli.js.map +483 -0
- package/dist/codex-yes.js +2 -0
- package/dist/copilot-yes.js +2 -0
- package/dist/cursor-yes.js +2 -0
- package/dist/gemini-yes.js +2 -0
- package/dist/grok-yes.js +2 -0
- package/dist/index.js +25148 -0
- package/dist/index.js.map +435 -0
- package/dist/qwen-yes.js +2 -0
- package/package.json +145 -0
- package/ts/ReadyManager.spec.ts +72 -0
- package/ts/ReadyManager.ts +16 -0
- package/ts/SUPPORTED_CLIS.ts +5 -0
- package/ts/catcher.spec.ts +259 -0
- package/ts/catcher.ts +35 -0
- package/ts/cli-idle.spec.ts +20 -0
- package/ts/cli.ts +30 -0
- package/ts/defineConfig.ts +12 -0
- package/ts/idleWaiter.spec.ts +55 -0
- package/ts/idleWaiter.ts +31 -0
- package/ts/index.ts +783 -0
- package/ts/logger.ts +17 -0
- package/ts/parseCliArgs.spec.ts +231 -0
- package/ts/parseCliArgs.ts +182 -0
- package/ts/postbuild.ts +29 -0
- package/ts/pty-fix.ts +155 -0
- package/ts/pty.ts +18 -0
- package/ts/removeControlCharacters.spec.ts +73 -0
- package/ts/removeControlCharacters.ts +8 -0
- package/ts/runningLock.spec.ts +485 -0
- package/ts/runningLock.ts +362 -0
- package/ts/session-integration.spec.ts +93 -0
- package/ts/sleep.ts +3 -0
- package/ts/utils.spec.ts +169 -0
- package/ts/utils.ts +23 -0
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
|
+
}
|