agent-yes 1.169.0 → 1.170.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 (62) hide show
  1. package/dist/{SUPPORTED_CLIS-D1WUoZA9.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
  3. package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
  4. package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
  5. package/dist/cli.js +10 -212
  6. package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
  7. package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
  8. package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
  9. package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
  10. package/dist/index.js +4 -4
  11. package/dist/notifyDaemon-D5jtpCAR.js +591 -0
  12. package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
  13. package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
  14. package/dist/pidStore-CLxwp0qI.js +5 -0
  15. package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
  16. package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
  17. package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
  18. package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
  19. package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
  20. package/dist/rustBinary-DsO0IlNq.js +228 -0
  21. package/dist/{schedule-DTHFMzDj.js → schedule-ChTPIcXf.js} +7 -7
  22. package/dist/{serve-D-joIHrL.js → serve-De1gAWNI.js} +120 -26
  23. package/dist/{setup-K6VTmirE.js → setup-nlMqyEPB.js} +3 -3
  24. package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
  25. package/dist/spawnGate-BFhva-2F.js +5 -0
  26. package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
  27. package/dist/{subcommands-BZPW0UVn.js → subcommands-BFlpV23b.js} +1049 -44
  28. package/dist/subcommands-DLDpwrfG.js +9 -0
  29. package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
  30. package/dist/{ts-DeahYDN3.js → ts-DsxejviJ.js} +10 -10
  31. package/dist/{versionChecker-C7KP-aoh.js → versionChecker-Czk3aHqy.js} +2 -2
  32. package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
  33. package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
  34. package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
  35. package/lab/ui/console-logic.js +79 -10
  36. package/lab/ui/index.html +174 -8
  37. package/lab/ui/landing.html +11 -1
  38. package/package.json +1 -1
  39. package/scripts/build-rs.ts +9 -5
  40. package/ts/badges.spec.ts +51 -1
  41. package/ts/badges.ts +35 -1
  42. package/ts/index.ts +13 -4
  43. package/ts/notifyDaemon.spec.ts +276 -0
  44. package/ts/notifyDaemon.ts +698 -0
  45. package/ts/notifyInbox.spec.ts +192 -0
  46. package/ts/notifyInbox.ts +294 -0
  47. package/ts/notifyRouter.spec.ts +355 -0
  48. package/ts/notifyRouter.ts +288 -0
  49. package/ts/notifyStore.spec.ts +308 -0
  50. package/ts/notifyStore.ts +439 -0
  51. package/ts/reaper.spec.ts +18 -1
  52. package/ts/reaper.ts +33 -1
  53. package/ts/rustBinary.ts +30 -0
  54. package/ts/serve.ts +180 -14
  55. package/ts/subcommands.spec.ts +341 -0
  56. package/ts/subcommands.ts +764 -29
  57. package/ts/workspaceConfig.spec.ts +48 -0
  58. package/ts/workspaceConfig.ts +46 -3
  59. package/dist/SUPPORTED_CLIS-BFV10E3f.js +0 -8
  60. package/dist/pidStore-5qyLX3Fh.js +0 -5
  61. package/dist/spawnGate-CQ1Il3Xk.js +0 -5
  62. package/dist/subcommands-DuGwtmz5.js +0 -9
package/ts/serve.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  extractBadges,
13
13
  extractNeedsInput,
14
14
  extractTaskCounts,
15
+ isUserTyping,
15
16
  listRecords,
16
17
  readNotes,
17
18
  readPtysize,
@@ -21,14 +22,18 @@ import {
21
22
  writeToIpc,
22
23
  type CommonOpts,
23
24
  } from "./subcommands.ts";
25
+ import { TYPING_BADGE } from "./badges.ts";
24
26
  import { updateGlobalPidStatus } from "./globalPidIndex.ts";
25
27
  import { spawnRejectionReason } from "./spawnGate.ts";
28
+ import { findSpawnHiddenLauncher } from "./rustBinary.ts";
26
29
  import { pgidForWrapper } from "./reaper.ts";
27
30
  import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
28
31
  import { getInstalledPackage } from "./versionChecker.ts";
29
32
  import {
33
+ getProvisionHook,
30
34
  getProvisionRoot,
31
35
  getSpawnHook,
36
+ hasProvisionHook,
32
37
  hasSpawnHook,
33
38
  isProvisionAllowed,
34
39
  resolveSpawnCwd,
@@ -234,6 +239,83 @@ function freshAgentEnv(): Record<string, string> {
234
239
  return env;
235
240
  }
236
241
 
242
+ // Best-effort owner/repo from a worktree's github origin remote — used to build
243
+ // the KOHO_* env a provision hook branches on (which account to select for this
244
+ // repo). Mirrors codehost's forkWorktree parse; null when there's no github
245
+ // origin (the hook still runs, just without KOHO_OWNER/REPO).
246
+ function originOwnerRepo(cwd: string): { owner: string; repo: string } | null {
247
+ try {
248
+ const r = Bun.spawnSync(["git", "-C", cwd, "remote", "get-url", "origin"]);
249
+ if (r.exitCode !== 0) return null;
250
+ const url = new TextDecoder().decode(r.stdout).trim();
251
+ const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/i);
252
+ return m && m[1] && m[2] ? { owner: m[1], repo: m[2] } : null;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ type ProvisionHookResult =
259
+ | { ran: false }
260
+ | { ran: true; ok: boolean; code: number | null; detail: string };
261
+
262
+ // Run the "koho-style" provision hook (a host-local trusted shell hook) BEFORE a
263
+ // fork/from git op. Its purpose is to prepare the host for provisioning — most
264
+ // usefully to select the right git identity (e.g. `gh auth switch --user <who>`,
265
+ // keyed on the KOHO_* env we export) before the clone/worktree/setup runs. When
266
+ // configured it is ALSO the provisioning gate: its exit code decides admission
267
+ // (0 = allow, non-zero = deny), overriding the static provisionAllowlist.
268
+ // Returns { ran:false } when no hook is set, so the caller falls back to the
269
+ // allowlist. Unlike the spawn hook there is no `exec "$@"`: this hook runs to
270
+ // completion and hands control back — it prepares state, it is not the agent.
271
+ async function runProvisionHook(
272
+ cwd: string,
273
+ koho: Record<string, string>,
274
+ ): Promise<ProvisionHookResult> {
275
+ const hook = getProvisionHook();
276
+ if (!hook) return { ran: false };
277
+ const shell =
278
+ process.env.AGENT_YES_PROVISION_SHELL?.trim() ||
279
+ process.env.AGENT_YES_SPAWN_SHELL?.trim() ||
280
+ "/bin/sh";
281
+ const outPath = path.join(
282
+ agentYesHome(),
283
+ `provision-hook-${process.pid}-${performance.now().toString(36).replace(".", "")}.log`,
284
+ );
285
+ const timeoutMs = Number(process.env.AGENT_YES_PROVISION_HOOK_TIMEOUT_MS) || 60_000;
286
+ let code: number | null = null;
287
+ try {
288
+ // Recovered login-shell env (freshAgentEnv), NOT the daemon's raw env: the
289
+ // oxmgr/launchd daemon runs with a minimal PATH (/usr/bin:/bin:…) that lacks
290
+ // Homebrew/bun/etc., so `gh`/`git` would be "command not found" and `set -e`
291
+ // would deny EVERY provision. freshAgentEnv re-derives the user's real PATH
292
+ // (as a fresh terminal would) while preserving HOME — so the hook finds `gh`
293
+ // and its credential store. `set -e` so any failing step denies the provision.
294
+ const child = Bun.spawn([shell, "-c", `set -e\n${hook}`], {
295
+ cwd,
296
+ env: { ...freshAgentEnv(), ...koho },
297
+ stdin: "ignore",
298
+ stdout: Bun.file(outPath),
299
+ stderr: Bun.file(outPath),
300
+ });
301
+ code = await Promise.race([
302
+ child.exited,
303
+ new Promise<number>((r) => setTimeout(() => r(124), timeoutMs)),
304
+ ]);
305
+ if (code === 124) child.kill();
306
+ } catch (e) {
307
+ return { ran: true, ok: false, code: null, detail: (e as Error).message };
308
+ }
309
+ let detail = "";
310
+ try {
311
+ detail = (await Bun.file(outPath).text()).slice(0, 4096).trimEnd();
312
+ } catch {
313
+ /* no output captured */
314
+ }
315
+ await unlink(outPath).catch(() => {});
316
+ return { ran: true, ok: code === 0, code, detail };
317
+ }
318
+
237
319
  // ---------------------------------------------------------------------------
238
320
  // ay serve install / uninstall / logs (oxmgr daemon management)
239
321
  // ---------------------------------------------------------------------------
@@ -513,6 +595,17 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
513
595
  // args after `--`. Both auto-restart on crash by default (pm2) / via the
514
596
  // explicit flag (oxmgr).
515
597
  const serveArgv = ayServeArgv(effArgs);
598
+ // On Windows, interpose the window-less launcher so the manager (pm2/oxmgr)
599
+ // doesn't flash a console window on each (re)start. `ay-spawn-hidden` is a
600
+ // GUI-subsystem shim that starts the real `ay serve …` with CREATE_NO_WINDOW
601
+ // and mirrors its lifetime/exit-code, so the manager still tracks it exactly.
602
+ // Resolves to undefined off Windows or when the launcher isn't installed
603
+ // (older builds / a release predating it) → fall back to the raw command.
604
+ const spawnHidden = findSpawnHiddenLauncher();
605
+ // The command the manager launches: unchanged normally, or the launcher
606
+ // followed by the real argv when interposing. Split into program + args for
607
+ // pm2 (which takes `<program> … -- <args>`); oxmgr takes the joined string.
608
+ const managedArgv = spawnHidden ? [spawnHidden, ...serveArgv] : serveArgv;
516
609
  // WebRTC daemons get an oxmgr health watchdog: the native WebRTC stack can
517
610
  // freeze the JS event loop (host answers nobody, no in-process timer can
518
611
  // recover it), so an EXTERNAL probe of the serve heartbeat is the only thing
@@ -536,7 +629,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
536
629
  ? [
537
630
  mgr.bin,
538
631
  "start",
539
- serveArgv.join(" "),
632
+ managedArgv.join(" "),
540
633
  "--name",
541
634
  DAEMON_NAME,
542
635
  "--restart",
@@ -552,7 +645,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
552
645
  : [
553
646
  mgr.bin,
554
647
  "start",
555
- serveArgv[0]!,
648
+ managedArgv[0]!,
556
649
  "--name",
557
650
  DAEMON_NAME,
558
651
  "--interpreter",
@@ -565,7 +658,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
565
658
  "--exp-backoff-restart-delay",
566
659
  "200",
567
660
  "--",
568
- ...serveArgv.slice(1),
661
+ ...managedArgv.slice(1),
569
662
  ];
570
663
  const proc = Bun.spawn(startArgv, { stdio: ["ignore", "inherit", "inherit"] });
571
664
  const code = await proc.exited;
@@ -1153,8 +1246,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
1153
1246
  // dot and the CLI agree.
1154
1247
  const question =
1155
1248
  status !== "exited" && !r.unresponsive ? await logNeedsInput(r.log_file, r.cli) : null;
1249
+ // Last-active time: the log file's mtime, i.e. when the agent last wrote
1250
+ // output (stdout). The console's left panel shows the age off this instead
1251
+ // of started_at, so a long-lived-but-quiet agent reads as stale, not "new".
1252
+ // Falls back to started_at when there's no log yet (freshly spawned).
1253
+ const lastActiveAt = r.log_file
1254
+ ? await stat(r.log_file)
1255
+ .then((s) => s.mtimeMs)
1256
+ .catch(() => r.started_at)
1257
+ : r.started_at;
1156
1258
  return {
1157
1259
  ...r,
1260
+ last_active_at: lastActiveAt,
1158
1261
  // Precedence: exited stays exited; the Rust supervisor's unresponsive flag is
1159
1262
  // an authoritative wedge signal (`stuck`); then a blocked menu (`needs_input`);
1160
1263
  // else the base live status — so the console's dot matches `ay ls`. (A dead
@@ -1170,8 +1273,15 @@ export async function cmdServe(rest: string[]): Promise<number> {
1170
1273
  // badge). Skipped for exited agents — their screen is no longer live.
1171
1274
  tasks: status === "exited" ? null : await logTasks(r.log_file),
1172
1275
  // Status flags matched against the rendered screen (see badges.ts) — e.g.
1173
- // an active /goal loop. [] when none matched or for exited agents.
1174
- badges: status === "exited" ? [] : await logBadges(r.log_file),
1276
+ // an active /goal loop. [] when none matched or for exited agents. The
1277
+ // time-derived "typing" chip (user typing at the terminal) is appended
1278
+ // from the stdin-activity marker, not the screen — same chip `ay ls` shows.
1279
+ badges:
1280
+ status === "exited"
1281
+ ? []
1282
+ : await logBadges(r.log_file).then(async (b) =>
1283
+ (await isUserTyping(r.pid)) ? [...b, TYPING_BADGE.id] : b,
1284
+ ),
1175
1285
  };
1176
1286
  };
1177
1287
 
@@ -1339,7 +1449,10 @@ export async function cmdServe(rest: string[]): Promise<number> {
1339
1449
  // share token are not necessarily host admins. Setting it stays host-local
1340
1450
  // (edit ~/.agent-yes/config.json) — there is no PUT.
1341
1451
  if (req.method === "GET" && p === "/api/spawn-config") {
1342
- return Response.json({ hasSpawnHook: hasSpawnHook() });
1452
+ return Response.json({
1453
+ hasSpawnHook: hasSpawnHook(),
1454
+ hasProvisionHook: hasProvisionHook(),
1455
+ });
1343
1456
  }
1344
1457
 
1345
1458
  // GET /api/notes
@@ -1620,7 +1733,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
1620
1733
  const record = await resolveOne(keyword, defaultOpts({ all: true }));
1621
1734
  const args = ["restart", String(record.pid)];
1622
1735
  if (body.fresh) args.push("--fresh");
1623
- const child = Bun.spawn(["agent-yes", ...args], {
1736
+ // Resolve `ay` to an absolute command — same as the /api/spawn path below.
1737
+ // The detached daemon (oxmgr/launchd/pm2) usually has a PATH WITHOUT
1738
+ // ~/.bun/bin and ~/.cargo/bin, so a bare "agent-yes"/"ay" fails with
1739
+ // "Executable not found in $PATH" — the exact error the console surfaced on
1740
+ // restart. Prefer PATH; fall back to re-running THIS process's own ay entry
1741
+ // (process.argv[1]) — always present, since the daemon is itself an `ay serve`.
1742
+ const ayBin = Bun.which("ay") ?? process.argv[1];
1743
+ const ayCmd =
1744
+ process.platform === "win32" && ayBin.toLowerCase().endsWith(".exe")
1745
+ ? [ayBin]
1746
+ : [process.execPath, ayBin];
1747
+ const child = Bun.spawn([...ayCmd, ...args], {
1624
1748
  cwd: record.cwd,
1625
1749
  detached: true,
1626
1750
  stdio: ["ignore", "ignore", "ignore"],
@@ -1782,6 +1906,25 @@ export async function cmdServe(rest: string[]): Promise<number> {
1782
1906
  { status: 501 },
1783
1907
  );
1784
1908
  }
1909
+ // koho-style provision gate — runs BEFORE the worktree + setup-repo.sh so
1910
+ // it can select the git identity for this fork; its exit code overrides
1911
+ // the allowlist. When no hook is configured, falls through to the allowlist
1912
+ // check after the fork resolves owner/repo.
1913
+ const forkOrigin = originOwnerRepo(fork.fromCwd);
1914
+ const forkHook = await runProvisionHook(fork.fromCwd, {
1915
+ KOHO_ACTION: "fork",
1916
+ KOHO_FROM_CWD: fork.fromCwd,
1917
+ KOHO_BRANCH: fork.branch,
1918
+ KOHO_OWNER: forkOrigin?.owner ?? "",
1919
+ KOHO_REPO: forkOrigin?.repo ?? "",
1920
+ KOHO_WS_ROOT: getProvisionRoot() ?? "",
1921
+ });
1922
+ if (forkHook.ran && !forkHook.ok)
1923
+ return new Response(
1924
+ `provision hook denied this fork (exit ${forkHook.code})` +
1925
+ (forkHook.detail ? `:\n${forkHook.detail}` : ""),
1926
+ { status: 403 },
1927
+ );
1785
1928
  let result: {
1786
1929
  ok: boolean;
1787
1930
  folder: string;
@@ -1800,11 +1943,14 @@ export async function cmdServe(rest: string[]): Promise<number> {
1800
1943
  return new Response(`fork failed: ${(e as Error).message}`, { status: 502 });
1801
1944
  }
1802
1945
  if (!result?.ok) return new Response(result?.error ?? "fork failed", { status: 502 });
1803
- // Same allowlist gate as `from` the fork runs setup-repo.sh (code exec).
1804
- if (result.spec && !isProvisionAllowed(result.spec.owner, result.spec.repo))
1946
+ // Allowlist gate only when a provision hook did NOT already gate this
1947
+ // (a configured hook overrides the allowlist). The fork runs setup-repo.sh
1948
+ // (code exec), the same risk surface as `from`.
1949
+ if (!forkHook.ran && result.spec && !isProvisionAllowed(result.spec.owner, result.spec.repo))
1805
1950
  return new Response(
1806
1951
  `forking '${result.spec.owner}/${result.spec.repo}' is not allowed — add the owner ` +
1807
- `to provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all)`,
1952
+ `to provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all), ` +
1953
+ `or set a provisionHook to gate it yourself`,
1808
1954
  { status: 403 },
1809
1955
  );
1810
1956
  cwd = result.folder;
@@ -1840,14 +1986,34 @@ export async function cmdServe(rest: string[]): Promise<number> {
1840
1986
  }
1841
1987
  if (!spec) return new Response(`unrecognized spawn source: ${from}`, { status: 400 });
1842
1988
  // Provisioning clones the repo and runs its setup script (dependency
1843
- // installs + package lifecycle hooks = code execution on the host), so
1844
- // gate it behind an owner/repo allowlist empty allowlist = deny all.
1845
- if (!isProvisionAllowed(spec.owner, spec.repo))
1989
+ // installs + package lifecycle hooks = code execution on the host), so it
1990
+ // is gated. A koho-style provision hook, when configured, runs first (to
1991
+ // select the git identity for the clone) and its exit code IS the gate,
1992
+ // overriding the allowlist; otherwise the owner/repo allowlist gates it
1993
+ // (empty allowlist = deny all).
1994
+ const fromHook = await runProvisionHook(getProvisionRoot() ?? homedir(), {
1995
+ KOHO_ACTION: "from",
1996
+ KOHO_SOURCE: from,
1997
+ KOHO_OWNER: spec.owner,
1998
+ KOHO_REPO: spec.repo,
1999
+ KOHO_BRANCH: spec.branch,
2000
+ KOHO_WS_ROOT: getProvisionRoot() ?? "",
2001
+ });
2002
+ if (fromHook.ran) {
2003
+ if (!fromHook.ok)
2004
+ return new Response(
2005
+ `provision hook denied '${spec.owner}/${spec.repo}' (exit ${fromHook.code})` +
2006
+ (fromHook.detail ? `:\n${fromHook.detail}` : ""),
2007
+ { status: 403 },
2008
+ );
2009
+ } else if (!isProvisionAllowed(spec.owner, spec.repo)) {
1846
2010
  return new Response(
1847
2011
  `provisioning '${spec.owner}/${spec.repo}' is not allowed — add the owner to ` +
1848
- `provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all)`,
2012
+ `provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all), ` +
2013
+ `or set a provisionHook to gate it yourself`,
1849
2014
  { status: 403 },
1850
2015
  );
2016
+ }
1851
2017
  let result: { ok: boolean; folder: string; action: string; error?: string };
1852
2018
  try {
1853
2019
  const wsRoot = getProvisionRoot();
@@ -1,5 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { mkdir, mkdtemp, rm, utimes, writeFile } from "fs/promises";
3
+ import { appendFileSync } from "fs";
3
4
  import { tmpdir } from "os";
4
5
  import path from "path";
5
6
 
@@ -986,6 +987,346 @@ describe("subcommands.isSlashCommand", () => {
986
987
  });
987
988
  });
988
989
 
990
+ describe("subcommands.waitForLogQuiet", () => {
991
+ it("resolves once writes to the file stop for quietMs, with the final size", async () => {
992
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-quiet-"));
993
+ try {
994
+ const log = path.join(dir, "a.log");
995
+ await writeFile(log, "hello");
996
+ const { waitForLogQuiet } = await loadModule();
997
+ const size = await waitForLogQuiet(log, 50, 500);
998
+ expect(size).toBe(5);
999
+ } finally {
1000
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1001
+ }
1002
+ });
1003
+
1004
+ it("returns the last observed size when maxWaitMs elapses without ever going quiet", async () => {
1005
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-quiet-"));
1006
+ try {
1007
+ const log = path.join(dir, "a.log");
1008
+ await writeFile(log, "x");
1009
+ const timer = setInterval(() => {
1010
+ appendFileSync(log, "x");
1011
+ }, 20);
1012
+ try {
1013
+ const { waitForLogQuiet } = await loadModule();
1014
+ const start = Date.now();
1015
+ const size = await waitForLogQuiet(log, 50, 200);
1016
+ expect(Date.now() - start).toBeGreaterThanOrEqual(190); // hit the cap, not the quiet window
1017
+ expect(size).toBeGreaterThan(0);
1018
+ } finally {
1019
+ clearInterval(timer);
1020
+ }
1021
+ } finally {
1022
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1023
+ }
1024
+ });
1025
+
1026
+ it("returns null when the file can't be stat'd", async () => {
1027
+ const { waitForLogQuiet } = await loadModule();
1028
+ expect(await waitForLogQuiet("/no/such/file/here", 50, 200)).toBeNull();
1029
+ });
1030
+ });
1031
+
1032
+ describe("subcommands.submitAndConfirm (ay send swallowed-Enter fix)", () => {
1033
+ const itUnix = process.platform === "linux" || process.platform === "darwin";
1034
+ // claude's shipped `working` busy marker — matches cliDefaults()["claude"].working.
1035
+ const BUSY = "⏺ Cogitating…\r\nesc to interrupt · ← for agents\r\n";
1036
+ const IDLE = "❯ some leftover unsent text\r\n"; // no busy marker, never changes
1037
+
1038
+ const rec = (over: any) => ({
1039
+ pid: process.pid,
1040
+ cli: "claude",
1041
+ prompt: null,
1042
+ cwd: "/tmp",
1043
+ log_file: null,
1044
+ fifo_file: null,
1045
+ status: "active",
1046
+ exit_code: null,
1047
+ exit_reason: null,
1048
+ started_at: 0,
1049
+ ...over,
1050
+ });
1051
+
1052
+ async function withFifo(fn: (fifo: string) => Promise<void>) {
1053
+ const { spawnSync } = await import("child_process");
1054
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-confirm-"));
1055
+ const fifo = path.join(dir, "test.fifo");
1056
+ try {
1057
+ const r = spawnSync("mkfifo", [fifo]);
1058
+ if (r.status !== 0) return; // mkfifo unavailable — skip
1059
+ const fs = await import("fs");
1060
+ const rdwrFd = fs.openSync(fifo, fs.constants.O_RDWR); // keeps writes from blocking
1061
+ try {
1062
+ await fn(fifo);
1063
+ } finally {
1064
+ fs.closeSync(rdwrFd);
1065
+ }
1066
+ } finally {
1067
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1068
+ }
1069
+ }
1070
+
1071
+ it.skipIf(!itUnix)(
1072
+ "confirms on the first attempt when the busy marker newly appears after sending",
1073
+ async () => {
1074
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-confirm-log-"));
1075
+ try {
1076
+ const log = path.join(dir, "a.log");
1077
+ await writeFile(log, "❯ \r\n"); // idle — no busy marker yet
1078
+ const { submitAndConfirm } = await loadModule();
1079
+ await withFifo(async (fifo) => {
1080
+ // The Enter kicks off work: the busy marker appears shortly after,
1081
+ // well within the confirm window — a genuine idle→busy transition.
1082
+ setTimeout(() => appendFileSync(log, BUSY), 100);
1083
+ const { confirmed, screen } = await submitAndConfirm(rec({ log_file: log }), fifo, "\r");
1084
+ expect(confirmed).toBe(true);
1085
+ expect(screen.join("\n")).toContain("esc to interrupt");
1086
+ });
1087
+ } finally {
1088
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1089
+ }
1090
+ },
1091
+ );
1092
+
1093
+ it.skipIf(!itUnix)(
1094
+ "does NOT confirm from a busy marker that was already on screen before sending (false-positive guard)",
1095
+ async () => {
1096
+ // A screen already showing "esc to interrupt" (busy from whatever the agent
1097
+ // was already doing) proves nothing about whether THIS Enter landed — e.g.
1098
+ // it could be sitting queued, unsubmitted, behind an unrelated in-flight
1099
+ // turn. Static and unchanging (no growth either), so this must NOT confirm.
1100
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-confirm-log-"));
1101
+ try {
1102
+ const log = path.join(dir, "a.log");
1103
+ await writeFile(log, BUSY);
1104
+ const { submitAndConfirm } = await loadModule();
1105
+ await withFifo(async (fifo) => {
1106
+ const { confirmed } = await submitAndConfirm(rec({ log_file: log }), fifo, "\r");
1107
+ expect(confirmed).toBe(false);
1108
+ });
1109
+ } finally {
1110
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1111
+ }
1112
+ },
1113
+ 10_000,
1114
+ );
1115
+
1116
+ it.skipIf(!itUnix)("confirms via log growth even without a recognized busy marker", async () => {
1117
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-confirm-log-"));
1118
+ try {
1119
+ const log = path.join(dir, "a.log");
1120
+ await writeFile(log, "❯ \r\n");
1121
+ const { submitAndConfirm } = await loadModule();
1122
+ await withFifo(async (fifo) => {
1123
+ // Simulate the CLI starting to respond shortly after Enter lands — well
1124
+ // within the first attempt's confirm window.
1125
+ setTimeout(() => appendFileSync(log, "some real response text appears here\r\n"), 100);
1126
+ const { confirmed } = await submitAndConfirm(rec({ log_file: log }), fifo, "\r");
1127
+ expect(confirmed).toBe(true);
1128
+ });
1129
+ } finally {
1130
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1131
+ }
1132
+ });
1133
+
1134
+ it.skipIf(!itUnix)(
1135
+ "gives up after exhausting retries when the Enter is swallowed (screen never changes)",
1136
+ async () => {
1137
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-confirm-log-"));
1138
+ try {
1139
+ const log = path.join(dir, "a.log");
1140
+ await writeFile(log, IDLE); // never touched again — nothing to confirm
1141
+ const { submitAndConfirm } = await loadModule();
1142
+ await withFifo(async (fifo) => {
1143
+ const { confirmed, screen } = await submitAndConfirm(rec({ log_file: log }), fifo, "\r");
1144
+ expect(confirmed).toBe(false);
1145
+ expect(screen.join("\n")).toContain("leftover unsent text");
1146
+ });
1147
+ } finally {
1148
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1149
+ }
1150
+ },
1151
+ 10_000,
1152
+ );
1153
+ });
1154
+
1155
+ describe("subcommands.cmdSend end-to-end submit-confirm wiring", () => {
1156
+ const itUnix = process.platform === "linux" || process.platform === "darwin";
1157
+ const BUSY = "⏺ Cogitating…\r\nesc to interrupt · ← for agents\r\n";
1158
+
1159
+ // A background drain so cmdSend's writes to the FIFO never block, mirroring
1160
+ // the "delivers a message to a real FIFO" setup above.
1161
+ async function withDrainedFifo(fn: (fifo: string) => Promise<void>) {
1162
+ const { spawnSync } = await import("child_process");
1163
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-send-e2e-"));
1164
+ const fifo = path.join(dir, "test.fifo");
1165
+ try {
1166
+ if (spawnSync("mkfifo", [fifo]).status !== 0) return; // mkfifo unavailable — skip
1167
+ const fs = await import("fs");
1168
+ // O_NONBLOCK is REQUIRED here: without it, readSync on an empty FIFO BLOCKS
1169
+ // the whole event loop. An O_RDWR fd keeps a writer open, so the read waits
1170
+ // for bytes that only arrive once fn() calls writeToIpc — but fn() can't make
1171
+ // progress while the loop is blocked in readSync → deadlock. That froze the
1172
+ // vitest worker so hard its 30s testTimeout couldn't even fire, wedging the CI
1173
+ // job for the full 6h cap. The catch below expects EAGAIN, which only happens
1174
+ // with O_NONBLOCK. The sibling drain in "writeToIpc reliable delivery" already
1175
+ // opens O_NONBLOCK for exactly this reason.
1176
+ const rdwrFd = fs.openSync(fifo, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
1177
+ const drain = setInterval(() => {
1178
+ try {
1179
+ fs.readSync(rdwrFd, Buffer.alloc(4096), 0, 4096, null);
1180
+ } catch {
1181
+ /* EAGAIN or similar — nothing pending, ignore */
1182
+ }
1183
+ }, 20);
1184
+ // Belt-and-suspenders: never let the drain timer alone keep the worker alive.
1185
+ // If a future test's fn() hangs past testTimeout, the finally below won't run
1186
+ // (the awaited fn stays pending), but an unref'd timer can't wedge process exit.
1187
+ drain.unref();
1188
+ try {
1189
+ await fn(fifo);
1190
+ } finally {
1191
+ clearInterval(drain);
1192
+ fs.closeSync(rdwrFd);
1193
+ }
1194
+ } finally {
1195
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1196
+ }
1197
+ }
1198
+
1199
+ async function send(fifo: string, log: string, body: string, extraArgs: string[] = []) {
1200
+ const { runSubcommand } = await loadModule();
1201
+ const { appendGlobalPid } = await import("./globalPidIndex.ts");
1202
+ await appendGlobalPid({
1203
+ pid: process.pid,
1204
+ cli: "claude",
1205
+ prompt: null,
1206
+ cwd: process.cwd(),
1207
+ log_file: log,
1208
+ fifo_file: fifo,
1209
+ status: "active",
1210
+ exit_code: null,
1211
+ exit_reason: null,
1212
+ started_at: Date.now(),
1213
+ });
1214
+ const stdout: string[] = [];
1215
+ const orig = process.stdout.write.bind(process.stdout);
1216
+ (process.stdout as any).write = (s: any) => {
1217
+ stdout.push(String(s));
1218
+ return true;
1219
+ };
1220
+ const savedAyPid = process.env.AGENT_YES_PID;
1221
+ delete process.env.AGENT_YES_PID; // isolate from the send-safety guard, as above
1222
+ try {
1223
+ const code = await runSubcommand([
1224
+ "bun",
1225
+ "cli.js",
1226
+ "send",
1227
+ String(process.pid),
1228
+ body,
1229
+ "--force",
1230
+ ...extraArgs,
1231
+ ]);
1232
+ return { code, stdout: stdout.join("") };
1233
+ } finally {
1234
+ process.stdout.write = orig;
1235
+ if (savedAyPid !== undefined) process.env.AGENT_YES_PID = savedAyPid;
1236
+ }
1237
+ }
1238
+
1239
+ it.skipIf(!itUnix)(
1240
+ "exits 0 and reports 'sent' when a working marker appears after the Enter (genuine confirm)",
1241
+ async () => {
1242
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-send-e2e-log-"));
1243
+ // A STATIC pre-existing busy marker cannot confirm — that's the #157
1244
+ // wasAlreadyWorking guard (see the "already on screen before sending" test).
1245
+ // Confirmation needs a real idle→working transition (or >=8 bytes of growth)
1246
+ // AFTER the submit. Model that: the log is idle until we send, then a steady
1247
+ // trickle of the working marker lands within submitAndConfirm's window, so it
1248
+ // confirms deterministically without fragile single-shot timing.
1249
+ const log = path.join(dir, "a.log");
1250
+ await writeFile(log, "❯ \r\n"); // idle until the submitted Enter lands
1251
+ let working = false;
1252
+ const respond = setInterval(() => {
1253
+ if (working) appendFileSync(log, BUSY);
1254
+ }, 40);
1255
+ respond.unref();
1256
+ try {
1257
+ await withDrainedFifo(async (fifo) => {
1258
+ working = true; // the submitted Enter kicks the agent into working
1259
+ const { code, stdout } = await send(fifo, log, "hello");
1260
+ expect(code).toBe(0);
1261
+ expect(stdout).toMatch(/^sent to pid/);
1262
+ expect(stdout).not.toMatch(/NOT confirmed/);
1263
+ });
1264
+ } finally {
1265
+ clearInterval(respond);
1266
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1267
+ }
1268
+ },
1269
+ );
1270
+
1271
+ it.skipIf(!itUnix)(
1272
+ "exits non-zero and reports the leftover screen when submission can't be confirmed",
1273
+ async () => {
1274
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-send-e2e-log-"));
1275
+ try {
1276
+ const log = path.join(dir, "a.log");
1277
+ await writeFile(log, "❯ \r\n"); // stays this way — nothing ever confirms
1278
+ await withDrainedFifo(async (fifo) => {
1279
+ const { code, stdout } = await send(fifo, log, "hello");
1280
+ expect(code).toBe(1);
1281
+ expect(stdout).toMatch(/NOT confirmed/);
1282
+ });
1283
+ } finally {
1284
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1285
+ }
1286
+ },
1287
+ 10_000,
1288
+ );
1289
+
1290
+ it.skipIf(!itUnix)(
1291
+ "applies submit-confirm for --code=cr too, not just the default --code=enter",
1292
+ async () => {
1293
+ // canConfirm gates on the resolved trailing byte, not the code NAME, so
1294
+ // every alias that resolves to Enter must go through the same confirm
1295
+ // path — regression test for that alias-vs-byte gap.
1296
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-send-e2e-log-"));
1297
+ try {
1298
+ const log = path.join(dir, "a.log");
1299
+ await writeFile(log, "❯ \r\n"); // never confirms — nothing ever changes
1300
+ await withDrainedFifo(async (fifo) => {
1301
+ const { code, stdout } = await send(fifo, log, "hello", ["--code=cr"]);
1302
+ expect(code).toBe(1);
1303
+ expect(stdout).toMatch(/NOT confirmed/);
1304
+ });
1305
+ } finally {
1306
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1307
+ }
1308
+ },
1309
+ 10_000,
1310
+ );
1311
+
1312
+ it.skipIf(!itUnix)("--no-wait skips confirmation entirely and always exits 0", async () => {
1313
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-send-e2e-log-"));
1314
+ try {
1315
+ const log = path.join(dir, "a.log");
1316
+ await writeFile(log, "❯ \r\n"); // would fail to confirm if checked — but we opt out
1317
+ await withDrainedFifo(async (fifo) => {
1318
+ const start = Date.now();
1319
+ const { code, stdout } = await send(fifo, log, "hello", ["--no-wait"]);
1320
+ expect(code).toBe(0);
1321
+ expect(stdout).not.toMatch(/NOT confirmed/);
1322
+ expect(Date.now() - start).toBeLessThan(1000); // fast — no settle/confirm polling
1323
+ });
1324
+ } finally {
1325
+ await rm(dir, { recursive: true, force: true }).catch(() => null);
1326
+ }
1327
+ });
1328
+ });
1329
+
989
1330
  describe("subcommands.writeToIpc reliable delivery", () => {
990
1331
  const itUnix = process.platform === "linux" || process.platform === "darwin";
991
1332