agent-yes 1.72.4 → 1.73.0

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 CHANGED
@@ -4,7 +4,7 @@ import { mkdir, readFile, writeFile } from "fs/promises";
4
4
  import path from "path";
5
5
  import DIE from "phpdie";
6
6
  import sflow from "sflow";
7
- import { TerminalRenderStream } from "terminal-render";
7
+ import { XtermProxy } from "./xterm-proxy.ts";
8
8
  import {
9
9
  extractSessionId,
10
10
  getSessionForCwd,
@@ -185,9 +185,14 @@ export default async function agentYes({
185
185
  process.stdin.setRawMode?.(true); // must be called any stdout/stdin usage
186
186
  if (verbose) logger.debug(`[stdin] Raw mode set, isRaw: ${(process.stdin as any).isRaw}`);
187
187
 
188
- const terminalStream = new TerminalRenderStream({ mode: "raw" });
189
- const terminalRender = terminalStream.getRenderer();
190
- const outputWriter = terminalStream.writable.getWriter();
188
+ // XtermProxy: headless xterm emulator that auto-responds to all terminal
189
+ // queries (DSR, DA, etc.) so the spawned CLI never blocks waiting for replies.
190
+ // writeToPty is set after shell spawn (see below).
191
+ let shellWrite: (data: string) => void = () => {};
192
+ const xtermProxy = new XtermProxy({
193
+ ...getTerminalDimensions(),
194
+ writeToPty: (data) => shellWrite(data),
195
+ });
191
196
 
192
197
  logger.debug(`Using ${ptyPackage} for pseudo terminal management.`);
193
198
 
@@ -329,11 +334,14 @@ export default async function agentYes({
329
334
  ptyOptions,
330
335
  });
331
336
 
337
+ // Wire up the xterm proxy to write back to the PTY
338
+ shellWrite = (data: string) => shell.write(data);
339
+
332
340
  // Attach data handler IMMEDIATELY after spawn to avoid losing early PTY output.
333
341
  // node-pty emits 'data' events eagerly — if no listener is attached, events are lost.
334
342
  function onData(data: string) {
335
343
  const currentPid = shell.pid;
336
- outputWriter.write(data);
344
+ xtermProxy.write(data);
337
345
  globalAgentRegistry.appendStdout(currentPid, data);
338
346
  }
339
347
  shell.onData(onData);
@@ -447,6 +455,7 @@ export default async function agentYes({
447
455
  env: ptyEnv,
448
456
  };
449
457
  shell = pty.spawn(bin!, args, restartPtyOptions);
458
+ shellWrite = (data: string) => shell.write(data);
450
459
  // Register process in pidStore (non-blocking)
451
460
  try {
452
461
  await pidStore.registerProcess({ pid: shell.pid, cli, args, prompt, cwd: workingDir });
@@ -548,6 +557,7 @@ export default async function agentYes({
548
557
  env: ptyEnv,
549
558
  };
550
559
  shell = pty.spawn(cli, restoreArgs, restorePtyOptions);
560
+ shellWrite = (data: string) => shell.write(data);
551
561
  // Register process in pidStore (non-blocking)
552
562
  try {
553
563
  await pidStore.registerProcess({
@@ -601,14 +611,15 @@ export default async function agentYes({
601
611
  return pendingExitCode.resolve(exitCode);
602
612
  });
603
613
 
604
- // when current tty resized, resize the pty too
614
+ // when current tty resized, resize both pty and xterm proxy
605
615
  process.stdout.on("resize", () => {
606
616
  const { cols, rows } = getTerminalDimensions();
607
617
  shell.resize(cols, rows);
618
+ xtermProxy.resize(cols, rows);
608
619
  });
609
620
 
610
621
  const isStillWorkingQ = () => {
611
- const rendered = terminalRender.tail(24).replace(/\s+/g, " ");
622
+ const rendered = xtermProxy.tail(24).replace(/\s+/g, " ");
612
623
  return conf.working?.some((rgx) => rgx.test(rendered));
613
624
  };
614
625
 
@@ -617,7 +628,7 @@ export default async function agentYes({
617
628
  let lastHeartbeatRendered = "";
618
629
  const heartbeatInterval = setInterval(async () => {
619
630
  try {
620
- const rendered = removeControlCharacters(terminalRender.tail(12));
631
+ const rendered = removeControlCharacters(xtermProxy.tail(12));
621
632
 
622
633
  // Skip if output hasn't changed since last heartbeat
623
634
  if (rendered === lastHeartbeatRendered) return;
@@ -895,7 +906,7 @@ export default async function agentYes({
895
906
  shell.write(data);
896
907
  },
897
908
  }),
898
- readable: terminalStream.readable,
909
+ readable: xtermProxy.readable,
899
910
  })
900
911
 
901
912
  .forEach(() => {
@@ -926,97 +937,72 @@ export default async function agentYes({
926
937
  // if (cli === "codex" && !process.stdin.isTTY) shell.write(`\u001b[1;1R`); // send cursor position response when stdin is not tty
927
938
 
928
939
  let lastRendered = "";
929
- return e
930
- .forEach((chunk) => {
931
- // NOTE: terminalRender is already updated by terminalStream.writable.write() in onData()
932
- // (terminal-render v1.5+ calls renderer.write() as a side effect of the writable).
933
- // Calling terminalRender.write(chunk) here again would double-process every chunk,
934
- // corrupting cursor state and breaking all pattern matching.
935
- // ============ HANDLE special control sequences
936
- // Handle Device Attributes query (DA) - ESC[c or ESC[0c
937
- // This must be handled regardless of TTY status
938
- if (chunk.includes("\u001b[c") || chunk.includes("\u001b[0c")) {
939
- // Respond shell with VT100 with Advanced Video Option
940
- shell.write("\u001b[?1;2c");
941
- if (verbose) {
942
- logger.debug("device|respond DA: VT100 with Advanced Video Option");
940
+ return (
941
+ e
942
+ // Terminal query responses (DA, DSR, etc.) are handled automatically
943
+ // by XtermProxy via @xterm/headless no ad-hoc interception needed.
944
+ .forEach(async (line, lineIndex) => {
945
+ // ============ respond on rendered screen
946
+ const rendered = xtermProxy.tail(24);
947
+ // Skip processing if output hasn't changed
948
+ if (rendered === lastRendered) return;
949
+ lastRendered = rendered;
950
+
951
+ logger.debug(`stdout|${line}`);
952
+
953
+ // ready matcher: if matched, mark stdin ready
954
+ if (conf.ready?.some((rx: RegExp) => line.match(rx))) {
955
+ logger.debug(`ready |${line}`);
956
+ if (cli === "gemini" && lineIndex <= 80) return; // gemini initial noise, only after many lines
957
+ ctx.stdinReady.ready();
958
+ ctx.stdinFirstReady.ready();
943
959
  }
944
- return;
945
- }
946
-
947
- // Only handle cursor position when stdin is not tty, because tty already handled this
948
- if (process.stdin.isTTY) return;
949
-
950
- // Handle cursor position request - ESC[6n
951
- if (!chunk.includes("\u001b[6n")) return;
952
-
953
- // xterm replies CSI row; column R if asked cursor position
954
- // https://en.wikipedia.org/wiki/ANSI_escape_code#:~:text=citation%20needed%5D-,xterm%20replies,-CSI%20row%C2%A0%3B
955
- const { col, row } = terminalRender.getCursorPosition();
956
- shell.write(`\u001b[${row};${col}R`); // reply cli when getting cursor position
957
-
958
- logger.debug(`cursor|respond position: row=${String(row)}, col=${String(col)}`);
959
- })
960
- .forEach(async (line, lineIndex) => {
961
- // ============ respond on rendered screen
962
- const rendered = terminalRender.tail(24);
963
- // Skip processing if output hasn't changed
964
- if (rendered === lastRendered) return;
965
-
966
- logger.debug(`stdout|${line}`);
967
-
968
- // ready matcher: if matched, mark stdin ready
969
- if (conf.ready?.some((rx: RegExp) => line.match(rx))) {
970
- logger.debug(`ready |${line}`);
971
- if (cli === "gemini" && lineIndex <= 80) return; // gemini initial noise, only after many lines
972
- ctx.stdinReady.ready();
973
- ctx.stdinFirstReady.ready();
974
- }
975
960
 
976
- // enter matchers: send Enter when any enter regex matches
977
- if (conf.enter?.some((rx: RegExp) => line.match(rx))) {
978
- logger.debug(`sendEnter matched|${line}`);
979
- return await sendEnter(ctx.messageContext, 400); // wait for idle for a short while and then send Enter
980
- }
961
+ // enter matchers: send Enter when any enter regex matches
962
+ if (conf.enter?.some((rx: RegExp) => line.match(rx))) {
963
+ logger.debug(`sendEnter matched|${line}`);
964
+ return await sendEnter(ctx.messageContext, 400); // wait for idle for a short while and then send Enter
965
+ }
981
966
 
982
- // typingRespond matcher: if matched, send the specified message
983
- const typeingRespondMatched = Object.entries(conf.typingRespond ?? {}).filter(
984
- ([_sendString, onThePatterns]) => onThePatterns.some((rx) => line.match(rx)),
985
- );
986
- const typingResponded =
987
- typeingRespondMatched.length &&
988
- (await sflow(typeingRespondMatched)
989
- .map(
990
- async ([sendString]) =>
991
- await sendMessage(ctx.messageContext, sendString, { waitForReady: false }),
992
- )
993
- .toCount());
994
- if (typingResponded) return;
995
-
996
- // fatal matchers: set isFatal flag when matched
997
- if (conf.fatal?.some((rx: RegExp) => line.match(rx))) {
998
- logger.debug(`fatal |${line}`);
999
- ctx.isFatal = true;
1000
- await exitAgent();
1001
- }
967
+ // typingRespond matcher: if matched, send the specified message
968
+ const typeingRespondMatched = Object.entries(conf.typingRespond ?? {}).filter(
969
+ ([_sendString, onThePatterns]) => onThePatterns.some((rx) => line.match(rx)),
970
+ );
971
+ const typingResponded =
972
+ typeingRespondMatched.length &&
973
+ (await sflow(typeingRespondMatched)
974
+ .map(
975
+ async ([sendString]) =>
976
+ await sendMessage(ctx.messageContext, sendString, { waitForReady: false }),
977
+ )
978
+ .toCount());
979
+ if (typingResponded) return;
980
+
981
+ // fatal matchers: set isFatal flag when matched
982
+ if (conf.fatal?.some((rx: RegExp) => line.match(rx))) {
983
+ logger.debug(`fatal |${line}`);
984
+ ctx.isFatal = true;
985
+ await exitAgent();
986
+ }
1002
987
 
1003
- // restartWithoutContinueArg matchers: set flag to restart without continue args
1004
- if (conf.restartWithoutContinueArg?.some((rx: RegExp) => line.match(rx))) {
1005
- logger.debug(`restart-without-continue|${line}`);
1006
- ctx.shouldRestartWithoutContinue = true;
1007
- ctx.isFatal = true; // also set fatal to trigger exit
1008
- await exitAgent();
1009
- }
988
+ // restartWithoutContinueArg matchers: set flag to restart without continue args
989
+ if (conf.restartWithoutContinueArg?.some((rx: RegExp) => line.match(rx))) {
990
+ logger.debug(`restart-without-continue|${line}`);
991
+ ctx.shouldRestartWithoutContinue = true;
992
+ ctx.isFatal = true; // also set fatal to trigger exit
993
+ await exitAgent();
994
+ }
1010
995
 
1011
- // session ID capture for codex
1012
- if (cli === "codex") {
1013
- const sessionId = extractSessionId(line);
1014
- if (sessionId) {
1015
- logger.debug(`session|captured session ID: ${sessionId}`);
1016
- await storeSessionForCwd(workingDir, sessionId);
996
+ // session ID capture for codex
997
+ if (cli === "codex") {
998
+ const sessionId = extractSessionId(line);
999
+ if (sessionId) {
1000
+ logger.debug(`session|captured session ID: ${sessionId}`);
1001
+ await storeSessionForCwd(workingDir, sessionId);
1002
+ }
1017
1003
  }
1018
- }
1019
- });
1004
+ })
1005
+ );
1020
1006
  })
1021
1007
 
1022
1008
  // auto-response
@@ -1043,7 +1029,7 @@ export default async function agentYes({
1043
1029
  .by(createTerminatorStream(pendingExitCode.promise))
1044
1030
  .to(fromWritable(process.stdout));
1045
1031
 
1046
- await saveLogFile(ctx.logPaths.logPath, terminalRender.render());
1032
+ await saveLogFile(ctx.logPaths.logPath, xtermProxy.render());
1047
1033
 
1048
1034
  // and then get its exitcode
1049
1035
  const exitCode = await pendingExitCode.promise;
@@ -1052,13 +1038,14 @@ export default async function agentYes({
1052
1038
  // Final pidStore cleanup
1053
1039
  await pidStore.close();
1054
1040
 
1055
- // Update task status.writable release lock
1056
- await outputWriter.close();
1041
+ // Capture final render before disposing xterm proxy
1042
+ const finalRender = xtermProxy.render();
1043
+ xtermProxy.dispose();
1057
1044
 
1058
1045
  // deprecated logFile option, we have logPath now, but keep for backward compatibility
1059
- await saveDeprecatedLogFile(logFile, terminalRender.render(), verbose);
1046
+ await saveDeprecatedLogFile(logFile, finalRender, verbose);
1060
1047
 
1061
- return { exitCode, logs: terminalRender.render() };
1048
+ return { exitCode, logs: finalRender };
1062
1049
 
1063
1050
  async function exitAgent() {
1064
1051
  ctx.robust = false; // disable robust to avoid auto restart
@@ -333,4 +333,92 @@ describe("CLI argument parsing", () => {
333
333
  expect(result.cli).toBe("claude");
334
334
  expect(result.autoYes).toBe(true);
335
335
  });
336
+
337
+ it("should parse bare positional words as prompt (cy arg1 arg2)", () => {
338
+ const result = parseCliArgs(["node", "/usr/local/bin/cy", "arg1", "arg2"]);
339
+
340
+ expect(result.cli).toBe("claude");
341
+ expect(result.prompt).toBe("arg1 arg2");
342
+ expect(result.cliArgs).toEqual([]);
343
+ });
344
+
345
+ it("should parse bare positional words as prompt with explicit CLI name", () => {
346
+ const result = parseCliArgs(["node", "/path/to/cli", "claude", "fix", "this", "bug"]);
347
+
348
+ expect(result.cli).toBe("claude");
349
+ expect(result.prompt).toBe("fix this bug");
350
+ expect(result.cliArgs).toEqual([]);
351
+ });
352
+
353
+ it("should parse bare words as prompt with claude-yes script name", () => {
354
+ const result = parseCliArgs(["node", "/usr/local/bin/claude-yes", "solve", "all", "todos"]);
355
+
356
+ expect(result.cli).toBe("claude");
357
+ expect(result.prompt).toBe("solve all todos");
358
+ expect(result.cliArgs).toEqual([]);
359
+ });
360
+
361
+ it("should separate CLI flags from bare words as prompt", () => {
362
+ const result = parseCliArgs([
363
+ "node",
364
+ "/usr/local/bin/claude-yes",
365
+ "--some-flag",
366
+ "value",
367
+ "fix",
368
+ "the",
369
+ "bug",
370
+ ]);
371
+
372
+ expect(result.cli).toBe("claude");
373
+ expect(result.prompt).toBe("fix the bug");
374
+ expect(result.cliArgs).toContain("--some-flag");
375
+ expect(result.cliArgs).toContain("value");
376
+ });
377
+
378
+ it("should combine --prompt flag with bare positional words", () => {
379
+ const result = parseCliArgs([
380
+ "node",
381
+ "/usr/local/bin/claude-yes",
382
+ "--prompt",
383
+ "prefix:",
384
+ "solve",
385
+ "this",
386
+ ]);
387
+
388
+ expect(result.prompt).toBe("prefix: solve this");
389
+ });
390
+
391
+ it("should combine bare positional words with dash prompt", () => {
392
+ const result = parseCliArgs([
393
+ "node",
394
+ "/usr/local/bin/claude-yes",
395
+ "solve",
396
+ "--",
397
+ "this",
398
+ "too",
399
+ ]);
400
+
401
+ expect(result.prompt).toBe("solve this too");
402
+ });
403
+
404
+ it("should not pass --no-rust to the target CLI", () => {
405
+ const result = parseCliArgs(["node", "/path/to/cy", "--no-rust"]);
406
+
407
+ expect(result.useRust).toBe(false);
408
+ expect(result.cliArgs).not.toContain("--no-rust");
409
+ });
410
+
411
+ it("should not pass --no-robust to the target CLI", () => {
412
+ const result = parseCliArgs(["node", "/path/to/cy", "--no-robust"]);
413
+
414
+ expect(result.robust).toBe(false);
415
+ expect(result.cliArgs).not.toContain("--no-robust");
416
+ });
417
+
418
+ it("should not pass --no-queue to the target CLI", () => {
419
+ const result = parseCliArgs(["node", "/path/to/cy", "--no-queue"]);
420
+
421
+ expect(result.queue).toBe(false);
422
+ expect(result.cliArgs).not.toContain("--no-queue");
423
+ });
336
424
  });
@@ -211,12 +211,33 @@ export function parseCliArgs(argv: string[]) {
211
211
  }
212
212
  });
213
213
 
214
+ // Collect bare positional words as prompt text (e.g., `cy arg1 arg2` → prompt = "arg1 arg2")
215
+ const positionalPromptWords: string[] = [];
216
+
214
217
  const cliArgsForSpawn = (() => {
215
218
  if (parsedArgv._[0] && !cliName) {
216
- // Explicit CLI name provided as positional arg
217
- return rawArgs.slice((cliArgIndex ?? 0) + 1, dashIndex ?? undefined);
219
+ // Explicit CLI name provided as positional arg — separate flags from bare words
220
+ const allAfterCli = rawArgs.slice((cliArgIndex ?? 0) + 1, dashIndex ?? undefined);
221
+ const result: string[] = [];
222
+ for (let i = 0; i < allAfterCli.length; i++) {
223
+ const arg = allAfterCli[i]!;
224
+ if (arg.startsWith("-")) {
225
+ result.push(arg);
226
+ // Consume the next arg as the flag's value if separate (--flag value)
227
+ if (!arg.includes("=") && i + 1 < allAfterCli.length) {
228
+ const nextArg = allAfterCli[i + 1];
229
+ if (nextArg && !nextArg.startsWith("-")) {
230
+ result.push(nextArg);
231
+ i++;
232
+ }
233
+ }
234
+ } else {
235
+ positionalPromptWords.push(arg);
236
+ }
237
+ }
238
+ return result;
218
239
  } else if (cliName) {
219
- // CLI name from script, filter out only what yargs consumed
240
+ // CLI name from script, filter out what yargs consumed; bare words become prompt
220
241
  const result: string[] = [];
221
242
  const argsToCheck = rawArgs.slice(0, dashIndex ?? undefined);
222
243
 
@@ -226,7 +247,11 @@ export function parseCliArgs(argv: string[]) {
226
247
 
227
248
  const [flag] = arg.split("=");
228
249
 
229
- if (flag && yargsConsumed.has(flag)) {
250
+ // Check both the flag itself and its --no- negation (yargs stores --no-x as key "x")
251
+ const isConsumed =
252
+ (flag && yargsConsumed.has(flag)) ||
253
+ (flag?.startsWith("--no-") && yargsConsumed.has(`--${flag.slice(5)}`));
254
+ if (isConsumed) {
230
255
  // Skip consumed flag and its value if separate
231
256
  if (!arg.includes("=") && i + 1 < argsToCheck.length) {
232
257
  const nextArg = argsToCheck[i + 1];
@@ -234,14 +259,27 @@ export function parseCliArgs(argv: string[]) {
234
259
  i++; // Skip value
235
260
  }
236
261
  }
237
- } else {
262
+ } else if (arg.startsWith("-")) {
263
+ // Non-consumed flag → pass to target CLI
238
264
  result.push(arg);
265
+ // Consume the next arg as the flag's value if separate
266
+ if (!arg.includes("=") && i + 1 < argsToCheck.length) {
267
+ const nextArg = argsToCheck[i + 1];
268
+ if (nextArg && !nextArg.startsWith("-")) {
269
+ result.push(nextArg);
270
+ i++;
271
+ }
272
+ }
273
+ } else {
274
+ // Bare word → treat as prompt text
275
+ positionalPromptWords.push(arg);
239
276
  }
240
277
  }
241
278
  return result;
242
279
  }
243
280
  return [];
244
281
  })();
282
+ const positionalPrompt = positionalPromptWords.join(" ") || undefined;
245
283
  const dashPrompt: string | undefined =
246
284
  dashIndex === undefined ? undefined : rawArgs.slice(dashIndex + 1).join(" ");
247
285
 
@@ -262,7 +300,8 @@ export function parseCliArgs(argv: string[]) {
262
300
  ? parsedArgv._[0]?.toString()?.replace?.(/-yes$/, "")
263
301
  : undefined)) as (typeof SUPPORTED_CLIS)[number],
264
302
  cliArgs: [...cliArgsForSpawn, ...(parsedArgv.yes ? ["--dangerously-skip-permissions"] : [])],
265
- prompt: [parsedArgv.prompt, dashPrompt].filter(Boolean).join(" ") || undefined,
303
+ prompt:
304
+ [parsedArgv.prompt, positionalPrompt, dashPrompt].filter(Boolean).join(" ") || undefined,
266
305
  install: parsedArgv.install,
267
306
  exitOnIdle: Number(
268
307
  (parsedArgv.timeout || parsedArgv.idle || parsedArgv.exitOnIdle)?.replace(/.*/, (e) =>
package/ts/rustBinary.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  * Rust binary helper - finds or downloads the appropriate prebuilt binary
3
3
  */
4
4
 
5
+ import { execFileSync } from "child_process";
5
6
  import { existsSync, mkdirSync, unlinkSync } from "fs";
6
7
  import { chmod, copyFile } from "fs/promises";
7
8
  import path from "path";
9
+ import pkg from "../package.json" with { type: "json" };
8
10
 
9
11
  // Platform/arch to binary name mapping
10
12
  const PLATFORM_MAP: Record<string, string> = {
@@ -192,6 +194,70 @@ export async function downloadBinary(verbose = false): Promise<string> {
192
194
  return binaryPath;
193
195
  }
194
196
 
197
+ /**
198
+ * Get the version of a Rust binary by running it with --version
199
+ */
200
+ function getRustBinaryVersion(binaryPath: string): string | null {
201
+ try {
202
+ const output = execFileSync(binaryPath, ["--version"], {
203
+ timeout: 5000,
204
+ encoding: "utf8",
205
+ stdio: ["ignore", "pipe", "ignore"],
206
+ });
207
+ // Output is like "agent-yes 1.72.3" or "agent-yes v1.72.3"
208
+ const match = output.match(/(\d+\.\d+\.\d+)/);
209
+ return match ? match[1] : null;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Check if a binary path is inside a git repo (dev build), and rebuild if outdated.
217
+ * Returns the same path if up-to-date or rebuilt, undefined if rebuild failed.
218
+ */
219
+ function autoRebuildIfOutdated(binaryPath: string, verbose: boolean): boolean {
220
+ // Only auto-rebuild for local dev builds (target/release or target/debug)
221
+ if (!binaryPath.includes("/target/release") && !binaryPath.includes("/target/debug")) {
222
+ return true; // not a dev build, skip
223
+ }
224
+
225
+ const binaryVersion = getRustBinaryVersion(binaryPath);
226
+ if (verbose) {
227
+ console.log(`[rust] Binary version: ${binaryVersion}, package version: ${pkg.version}`);
228
+ }
229
+
230
+ if (binaryVersion === pkg.version) {
231
+ return true; // up to date
232
+ }
233
+
234
+ // Find the rs/ directory relative to the binary (binary is at rs/target/release/agent-yes)
235
+ const rsDir = binaryPath.replace(/\/target\/(release|debug)\/agent-yes.*$/, "");
236
+ if (!existsSync(path.join(rsDir, "Cargo.toml"))) {
237
+ if (verbose) console.log(`[rust] Cannot find Cargo.toml at ${rsDir}, skipping rebuild`);
238
+ return true; // can't rebuild, use as-is
239
+ }
240
+
241
+ process.stderr.write(
242
+ `\x1b[33m[rust] Binary outdated (${binaryVersion ?? "unknown"} → ${pkg.version}), rebuilding…\x1b[0m\n`,
243
+ );
244
+
245
+ try {
246
+ const isRelease = binaryPath.includes("/target/release");
247
+ const args = ["build", ...(isRelease ? ["--release"] : [])];
248
+ execFileSync("cargo", args, {
249
+ cwd: rsDir,
250
+ stdio: "inherit",
251
+ timeout: 300_000, // 5 min max
252
+ });
253
+ process.stderr.write(`\x1b[32m[rust] Rebuild complete\x1b[0m\n`);
254
+ return true;
255
+ } catch {
256
+ process.stderr.write(`\x1b[31m[rust] Auto-rebuild failed, using outdated binary\x1b[0m\n`);
257
+ return true; // still usable, just old
258
+ }
259
+ }
260
+
195
261
  /**
196
262
  * Get or download the Rust binary
197
263
  */
@@ -210,6 +276,8 @@ export async function getRustBinary(
210
276
  if (verbose) {
211
277
  console.log(`[rust] Using existing binary: ${existing}`);
212
278
  }
279
+ // Auto-rebuild if it's a dev build and version is outdated
280
+ autoRebuildIfOutdated(existing, verbose);
213
281
  return existing;
214
282
  }
215
283
  }
@@ -4,6 +4,8 @@ import {
4
4
  compareVersions,
5
5
  fetchLatestVersion,
6
6
  displayVersion,
7
+ detectInstallMethod,
8
+ versionString,
7
9
  } from "./versionChecker";
8
10
 
9
11
  vi.mock("execa", () => ({ execaCommand: vi.fn().mockResolvedValue({}) }));
@@ -12,6 +14,16 @@ vi.mock("fs/promises", () => ({
12
14
  readFile: vi.fn(),
13
15
  writeFile: vi.fn().mockResolvedValue(undefined),
14
16
  }));
17
+ vi.mock("fs", async (importOriginal) => {
18
+ const actual = await importOriginal<typeof import("fs")>();
19
+ return {
20
+ ...actual,
21
+ // Return false for .git checks so the dev-checkout guard doesn't skip auto-update in tests
22
+ existsSync: vi.fn(() => false),
23
+ lstatSync: actual.lstatSync,
24
+ readlinkSync: actual.readlinkSync,
25
+ };
26
+ });
15
27
  vi.mock("child_process", () => ({
16
28
  execFileSync: vi.fn(() => {
17
29
  // Simulate successful re-exec by throwing an exit-like error
@@ -106,6 +118,14 @@ describe("versionChecker", () => {
106
118
  expect(fetch).not.toHaveBeenCalled();
107
119
  });
108
120
 
121
+ it("should skip when running from a git dev checkout", async () => {
122
+ const fs = await import("fs");
123
+ // Make the .git check return true so the dev-checkout guard triggers
124
+ vi.mocked(fs.existsSync).mockReturnValueOnce(true);
125
+ await checkAndAutoUpdate();
126
+ expect(fetch).not.toHaveBeenCalled();
127
+ });
128
+
109
129
  it("should skip when AGENT_YES_UPDATED matches current version", async () => {
110
130
  const pkg = await import("../package.json");
111
131
  process.env.AGENT_YES_UPDATED = pkg.default.version;
@@ -252,4 +272,32 @@ describe("versionChecker", () => {
252
272
  expect(console.log).toHaveBeenCalledWith(expect.stringContaining("unable to check"));
253
273
  });
254
274
  });
275
+
276
+ describe("detectInstallMethod", () => {
277
+ it("should return a string", () => {
278
+ const method = detectInstallMethod();
279
+ expect(typeof method).toBe("string");
280
+ expect(method.length).toBeGreaterThan(0);
281
+ });
282
+
283
+ it("should return 'git' when .git exists in parent of script dir", async () => {
284
+ const fs = await import("fs");
285
+ vi.mocked(fs.existsSync).mockReturnValueOnce(true);
286
+ expect(detectInstallMethod()).toBe("git");
287
+ });
288
+
289
+ it("should return 'source' when not in node_modules and no .git", async () => {
290
+ const fs = await import("fs");
291
+ vi.mocked(fs.existsSync).mockReturnValueOnce(false);
292
+ expect(detectInstallMethod()).toBe("source");
293
+ });
294
+ });
295
+
296
+ describe("versionString", () => {
297
+ it("should include version and install method", () => {
298
+ const str = versionString();
299
+ expect(str).toContain("agent-yes v");
300
+ expect(str).toMatch(/agent-yes v\d+\.\d+\.\d+ \(.+\)/);
301
+ });
302
+ });
255
303
  });