agent-yes 1.164.0 → 1.166.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.
Files changed (32) hide show
  1. package/dist/{SUPPORTED_CLIS-DAf9N7Wn.js → SUPPORTED_CLIS-DCwX8lvW.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-DsYijbWx.js +8 -0
  3. package/dist/cli.js +14 -5
  4. package/dist/index.js +2 -2
  5. package/dist/{remotes-DkNuq417.js → remotes-ClT1bq16.js} +1 -1
  6. package/dist/{remotes-DBCvpp3B.js → remotes-T6nf0t3K.js} +2 -2
  7. package/dist/{schedule-mKjjuT1M.js → schedule-BkUFuCvW.js} +5 -5
  8. package/dist/{serve-CMNGOlQ3.js → serve-BtJDjoPO.js} +12 -9
  9. package/dist/{setup-NJ4_GFXO.js → setup-CK6lH0UM.js} +3 -3
  10. package/dist/{share-Bq_tDXQU.js → share-BjqQBWM-.js} +1 -1
  11. package/dist/spawnGate-B_VDMXYL.js +107 -0
  12. package/dist/spawnGate-UH73I2le.js +5 -0
  13. package/dist/{subcommands-XvqBWxtm.js → subcommands-CuuiDKr0.js} +230 -20
  14. package/dist/subcommands-lQ3cyReU.js +9 -0
  15. package/dist/{tray-BMzpUSfa.js → tray-CZarCA2Q.js} +1 -1
  16. package/dist/{ts-Bjfd09LQ.js → ts-YJSzO6hM.js} +2 -2
  17. package/dist/{versionChecker-DiNfz39j.js → versionChecker-DwkNjj7n.js} +2 -2
  18. package/dist/{webrtcRemote-Ccdzmuc-.js → webrtcRemote-GAgF5K45.js} +1 -1
  19. package/dist/{workspaceConfig-B0Q9-q2B.js → workspaceConfig-D3OH7and.js} +41 -2
  20. package/package.json +1 -1
  21. package/ts/cli.ts +21 -0
  22. package/ts/needsInput.spec.ts +42 -1
  23. package/ts/needsInput.ts +54 -0
  24. package/ts/serve.ts +8 -0
  25. package/ts/spawnGate.spec.ts +155 -0
  26. package/ts/spawnGate.ts +123 -0
  27. package/ts/subcommands.spec.ts +33 -1
  28. package/ts/subcommands.ts +308 -31
  29. package/ts/workspaceConfig.spec.ts +85 -0
  30. package/ts/workspaceConfig.ts +64 -0
  31. package/dist/SUPPORTED_CLIS-BJ8hXe7M.js +0 -8
  32. package/dist/subcommands-CNDyinaw.js +0 -9
@@ -3,7 +3,7 @@ import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
3
3
  import { a as updateGlobalPidStatus, i as readGlobalPids } from "./globalPidIndex-DlmmJlO8.js";
4
4
  import { t as loadSharedCliDefaults } from "./configShared-CEyhl0WH.js";
5
5
  import { n as isWebrtcSpec } from "./webrtcLink-CBZkZ-LT.js";
6
- import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-DBCvpp3B.js";
6
+ import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-T6nf0t3K.js";
7
7
  import ms from "ms";
8
8
  import yargs from "yargs";
9
9
  import { appendFile, mkdir, open, readFile, stat, writeFile } from "fs/promises";
@@ -183,6 +183,43 @@ function classifyNeedsInput(lines, cfg) {
183
183
  const end = Math.min(lines.length, last + 6);
184
184
  return { question: lines.slice(start, end).map((l) => l.trim()).filter((l) => l && !isChromeLine(l)).join(" • ").slice(0, 400) };
185
185
  }
186
+ const OPTION_LINE = /^[\s❯›>▶◉○●·*\-]*?(\d+)\.\s/;
187
+ /**
188
+ * Parse the selection menu a `needs_input` agent is parked on into a cursor
189
+ * position + the available option numbers, so a caller can compute how far the
190
+ * cursor must move (Down/Up) to reach a target option. Returns null when the
191
+ * screen isn't a menu (delegates that judgement to {@link classifyNeedsInput},
192
+ * so `working` still wins) or no numbered cursor line is found. Pure — the
193
+ * `ay select` action reuses the exact detection `ay ls` renders with.
194
+ */
195
+ function parseMenu(lines, cfg) {
196
+ const ni = classifyNeedsInput(lines, cfg);
197
+ if (!ni) return null;
198
+ const patterns = cfg.needsInput ?? [];
199
+ let cursorLine = -1;
200
+ for (let i = 0; i < lines.length; i++) if (patterns.some((re) => reTest(re, lines[i]))) cursorLine = i;
201
+ if (cursorLine < 0) return null;
202
+ const cm = /(\d+)\./.exec(lines[cursorLine]);
203
+ if (!cm) return null;
204
+ const cursor = parseInt(cm[1], 10);
205
+ const start = Math.max(0, cursorLine - 12);
206
+ const end = Math.min(lines.length, cursorLine + 12);
207
+ const options = [];
208
+ for (let i = start; i < end; i++) {
209
+ const m = OPTION_LINE.exec(lines[i]);
210
+ if (m) {
211
+ const v = parseInt(m[1], 10);
212
+ if (!options.includes(v)) options.push(v);
213
+ }
214
+ }
215
+ if (!options.includes(cursor)) options.push(cursor);
216
+ options.sort((a, b) => a - b);
217
+ return {
218
+ cursor,
219
+ options,
220
+ question: ni.question
221
+ };
222
+ }
186
223
  /**
187
224
  * True when the rendered screen still shows a "busy" marker (config `working`,
188
225
  * e.g. claude's `esc to interrupt`). Paired with a long-quiet log this is the
@@ -500,6 +537,8 @@ const SUBCOMMANDS = new Set([
500
537
  "tail",
501
538
  "head",
502
539
  "send",
540
+ "key",
541
+ "select",
503
542
  "spawn",
504
543
  "attach",
505
544
  "stop",
@@ -549,6 +588,8 @@ async function runSubcommand(argv) {
549
588
  case "tail": return await cmdRead(rest, { mode: "tail" });
550
589
  case "head": return await cmdRead(rest, { mode: "head" });
551
590
  case "send": return await cmdSend(rest);
591
+ case "key": return await cmdKey(rest);
592
+ case "select": return await cmdSelect(rest);
552
593
  case "spawn": return await cmdSpawn(rest);
553
594
  case "attach": return await cmdAttach(rest);
554
595
  case "stop": return await cmdStop(rest);
@@ -556,19 +597,19 @@ async function runSubcommand(argv) {
556
597
  case "restart": return await cmdRestart(rest);
557
598
  case "note": return await cmdNote(rest);
558
599
  case "serve": {
559
- const { cmdServe } = await import("./serve-CMNGOlQ3.js");
600
+ const { cmdServe } = await import("./serve-BtJDjoPO.js");
560
601
  return cmdServe(rest);
561
602
  }
562
603
  case "setup": {
563
- const { cmdSetup } = await import("./setup-NJ4_GFXO.js");
604
+ const { cmdSetup } = await import("./setup-CK6lH0UM.js");
564
605
  return cmdSetup(rest);
565
606
  }
566
607
  case "schedule": {
567
- const { cmdSchedule } = await import("./schedule-mKjjuT1M.js");
608
+ const { cmdSchedule } = await import("./schedule-BkUFuCvW.js");
568
609
  return cmdSchedule(rest);
569
610
  }
570
611
  case "remote": {
571
- const { cmdRemote } = await import("./remotes-DkNuq417.js");
612
+ const { cmdRemote } = await import("./remotes-ClT1bq16.js");
572
613
  return cmdRemote(rest);
573
614
  }
574
615
  case "reap":
@@ -585,7 +626,7 @@ async function runSubcommand(argv) {
585
626
  }
586
627
  function cmdHelp(managerCommands = true) {
587
628
  const setupLine = managerCommands ? ` ay setup guided setup: pick a workspace, share to agent-yes.com\n` : ``;
588
- process.stdout.write("ay - agent-yes CLI\n\nManagement:\n ay ls [keyword] list running agents\n ay tail [-f] [-n N] <keyword> last N lines (96), -f to follow\n ay read <keyword> [page opts] paginate: --last/--head N, --range A:B,\n --before-line L [--limit N]\n ay cat <keyword> full log\n ay head <keyword> first N lines\n ay send <keyword> <msg> send a message\n ay attach <keyword> interactive attach (detach: Ctrl-\\)\n ay stop <keyword> graceful shutdown (/exit for claude/codex)\n ay exit <keyword> [reason] graceful shutdown, recording who/why (= 'ay send <kw> exit')\n ay restart <keyword> [--fresh] stop (if live) + relaunch resuming the session; --fresh replays the prompt\n ay status <keyword> agent status snapshot\n ay result <keyword> [--wait] pull an agent's structured result envelope\n ay result set '<json>' (inside an agent) deposit your result envelope\n ay reap kill process groups leaked by dead agents\n\nRemote:\n" + setupLine + " ay schedule <when> <cli> -- <msg> run an agent on a schedule (HH:MM or cron)\n ay serve [--port N] start HTTP API server (prints token)\n ay serve status show serve daemon/server status\n ay remote add <alias> http://<token>@<host>:<port>\n ay remote ls / rm <alias> manage saved remotes\n ay ls <token>@<host>:<port> connect inline (no alias needed)\n ay send <token>@<host>:<port>:<kw> <msg>\n\nRun an agent:\n ay [claude|codex|gemini|...] [options] -- [prompt]\n ay claude -- \"fix the bug in auth.ts\"\n ay claude --help full agent-runner options\n\nLabs (examples at https://github.com/snomiao/agent-yes/tree/main/lab):\n local-role-play/ designer + builder on one machine\n http-remote/ ay serve remote access demo\n p2p-pairing/ libp2p P2P (needs: cargo build --features swarm)\n");
629
+ process.stdout.write("ay - agent-yes CLI\n\nManagement:\n ay ls [keyword] list running agents\n ay tail [-f] [-n N] <keyword> last N lines (96), -f to follow\n ay read <keyword> [page opts] paginate: --last/--head N, --range A:B,\n --before-line L [--limit N]\n ay cat <keyword> full log\n ay head <keyword> first N lines\n ay send <keyword> <msg> send a message\n ay key <keyword> <key...> send raw keystrokes (down/up/enter/esc/…) — drives menus\n ay select <keyword> <N> pick option N of a needs_input selection menu\n ay attach <keyword> interactive attach (detach: Ctrl-\\)\n ay stop <keyword> graceful shutdown (/exit for claude/codex)\n ay exit <keyword> [reason] graceful shutdown, recording who/why (= 'ay send <kw> exit')\n ay restart <keyword> [--fresh] stop (if live) + relaunch resuming the session; --fresh replays the prompt\n ay status <keyword> agent status snapshot\n ay result <keyword> [--wait] pull an agent's structured result envelope\n ay result set '<json>' (inside an agent) deposit your result envelope\n ay reap kill process groups leaked by dead agents\n\nRemote:\n" + setupLine + " ay schedule <when> <cli> -- <msg> run an agent on a schedule (HH:MM or cron)\n ay serve [--port N] start HTTP API server (prints token)\n ay serve status show serve daemon/server status\n ay remote add <alias> http://<token>@<host>:<port>\n ay remote ls / rm <alias> manage saved remotes\n ay ls <token>@<host>:<port> connect inline (no alias needed)\n ay send <token>@<host>:<port>:<kw> <msg>\n\nRun an agent:\n ay [claude|codex|gemini|...] [options] -- [prompt]\n ay claude -- \"fix the bug in auth.ts\"\n ay claude --help full agent-runner options\n\nLabs (examples at https://github.com/snomiao/agent-yes/tree/main/lab):\n local-role-play/ designer + builder on one machine\n http-remote/ ay serve remote access demo\n p2p-pairing/ libp2p P2P (needs: cargo build --features swarm)\n");
589
630
  return 0;
590
631
  }
591
632
  function matchKeyword(record, keyword) {
@@ -941,7 +982,7 @@ async function fetchRemoteRecordsRaw(url, token, opts) {
941
982
  let base = url;
942
983
  let bearer = token;
943
984
  if (isWebrtcSpec(url)) {
944
- const { startWebrtcBridge } = await import("./webrtcRemote-Ccdzmuc-.js");
985
+ const { startWebrtcBridge } = await import("./webrtcRemote-GAgF5K45.js");
945
986
  bridge = await startWebrtcBridge(url);
946
987
  base = bridge.baseUrl;
947
988
  bearer = bridge.token;
@@ -1895,6 +1936,71 @@ async function cmdSpawn(rest) {
1895
1936
  prompt: prompt || void 0
1896
1937
  });
1897
1938
  }
1939
+ /**
1940
+ * Shared safety gate for every command that writes to a live agent's stdin
1941
+ * (`send`, `key`, `select`): refuse a self-targeting loop, and require that THIS
1942
+ * sender actually looked at THIS target recently — an agent is blocked, an
1943
+ * interactive human is only warned — unless `force`. Returns the sender context
1944
+ * so a caller can reuse it (e.g. `send`'s `[from …]` prefix). Extracted from
1945
+ * cmdSend so the action commands enforce the identical guard.
1946
+ */
1947
+ async function enforceSendGuards(record, force) {
1948
+ const sender = await senderContext();
1949
+ if (sender.agent && sender.agent.pid === record.pid && !force) throw new Error(`refusing to send to yourself (pid ${record.pid}) — pass --force if you really mean it.`);
1950
+ const last = await lastReadAt(sender.key, record.pid);
1951
+ if (!(last !== null && Date.now() - last <= READ_WINDOW_MS) && !force) {
1952
+ const ago = last === null ? "never read" : `last read ${Math.round((Date.now() - last) / 1e3)}s ago`;
1953
+ const what = `pid ${record.pid} (${record.cli}, ${shortenPath(record.cwd)}) — ${ago}, not within ${READ_WINDOW_MS / 1e3}s`;
1954
+ if (sender.agent) throw new Error(`${what}.\n Confirm it's the right agent first: ay tail ${record.pid}\n then resend, or pass --force to override.`);
1955
+ process.stderr.write(`warning: ${what} — make sure this is the agent you meant (ay tail ${record.pid}).\n`);
1956
+ }
1957
+ return sender;
1958
+ }
1959
+ const KEY_PACE_MS = 40;
1960
+ /**
1961
+ * The named-key sequence that moves a menu cursor from `cursor` to option
1962
+ * `target` and confirms: |Δ| Downs (target below) or Ups (target above), then
1963
+ * Enter. Pure so the arrow arithmetic is unit-tested independent of any live PTY.
1964
+ */
1965
+ function menuSelectKeys(cursor, target) {
1966
+ const delta = target - cursor;
1967
+ return [...Array(Math.abs(delta)).fill(delta > 0 ? "down" : "up"), "enter"];
1968
+ }
1969
+ /** Write each already-encoded key sequence to the FIFO with a pace gap between
1970
+ * them (no gap after the last). Raw bytes, no `[from]` framing, no auto-Enter. */
1971
+ async function writeKeysPaced(fifoPath, byteSeqs, paceMs) {
1972
+ for (let i = 0; i < byteSeqs.length; i++) {
1973
+ if (byteSeqs[i] === "") continue;
1974
+ await writeToIpc(fifoPath, byteSeqs[i]);
1975
+ if (i < byteSeqs.length - 1 && paceMs > 0) await new Promise((r) => setTimeout(r, paceMs));
1976
+ }
1977
+ }
1978
+ /**
1979
+ * The selection menu a needs_input agent is parked on, or null when it isn't on
1980
+ * one. Mirrors extractNeedsInput (same 32 KB tail render + config patterns) but
1981
+ * returns the cursor position + option numbers so `ay select` can compute the
1982
+ * cursor delta.
1983
+ */
1984
+ async function extractMenu(logPath, cli) {
1985
+ const cfg = (await cliDefaults())[cli];
1986
+ if (!cfg?.needsInput?.length) return null;
1987
+ const lines = await renderLogTailLines(logPath, 40);
1988
+ if (!lines) return null;
1989
+ return parseMenu(lines, {
1990
+ needsInput: cfg.needsInput,
1991
+ working: cfg.working
1992
+ });
1993
+ }
1994
+ /** Poll until the agent is no longer parked on a menu (selection accepted → it
1995
+ * resumed / moved on) or the deadline passes. Returns true if it cleared. */
1996
+ async function waitForNeedsInputClear(record, timeoutMs) {
1997
+ const deadline = Date.now() + timeoutMs;
1998
+ while (Date.now() < deadline) {
1999
+ await new Promise((r) => setTimeout(r, 250));
2000
+ if ((await snapshotStatus(record)).state !== "needs_input") return true;
2001
+ }
2002
+ return false;
2003
+ }
1898
2004
  async function cmdSend(rest) {
1899
2005
  const argv = await yargs(rest).usage("Usage: ay send <keyword> <msg|-> [options]").option("code", {
1900
2006
  type: "string",
@@ -1941,16 +2047,7 @@ async function cmdSend(rest) {
1941
2047
  for await (const chunk of process.stdin) chunks.push(chunk);
1942
2048
  body = Buffer.concat(chunks).toString("utf-8").trimEnd();
1943
2049
  } else body = rawMessage;
1944
- const sender = await senderContext();
1945
- const force = Boolean(argv.force) || process.env.AGENT_YES_FORCE_SEND === "1";
1946
- if (sender.agent && sender.agent.pid === record.pid && !force) throw new Error(`refusing to send to yourself (pid ${record.pid}) — pass --force if you really mean it.`);
1947
- const last = await lastReadAt(sender.key, record.pid);
1948
- if (!(last !== null && Date.now() - last <= READ_WINDOW_MS) && !force) {
1949
- const ago = last === null ? "never read" : `last read ${Math.round((Date.now() - last) / 1e3)}s ago`;
1950
- const what = `pid ${record.pid} (${record.cli}, ${shortenPath(record.cwd)}) — ${ago}, not within ${READ_WINDOW_MS / 1e3}s`;
1951
- if (sender.agent) throw new Error(`${what}.\n Confirm it's the right agent first: ay tail ${record.pid}\n then resend, or pass --force to override.`);
1952
- process.stderr.write(`warning: ${what} — make sure this is the agent you meant (ay tail ${record.pid}).\n`);
1953
- }
2050
+ const sender = await enforceSendGuards(record, Boolean(argv.force) || process.env.AGENT_YES_FORCE_SEND === "1");
1954
2051
  if (isExitRequest(body)) {
1955
2052
  const reason = sender.agent ? `requested by ${sender.agent.cli} #${sender.agent.pid} @ ${shortenPath(sender.agent.cwd)}` : `requested via 'ay send ${keyword} exit'`;
1956
2053
  const { strategy } = await gracefulExitAgent(record, reason);
@@ -1973,6 +2070,104 @@ async function cmdSend(rest) {
1973
2070
  }
1974
2071
  return 0;
1975
2072
  }
2073
+ async function resolveWritableAgent(keyword, opts) {
2074
+ const record = await resolveOne(keyword, opts);
2075
+ if (!record.fifo_file) throw new Error(`pid ${record.pid}: no fifo_file recorded — this agent didn't register a stdin FIFO (an older agent, or one not started with --stdpush). Restarting it (ay restart ${record.pid}) re-registers one.`);
2076
+ return record;
2077
+ }
2078
+ async function cmdKey(rest) {
2079
+ const argv = await yargs(rest).usage("Usage: ay key <keyword> <key...> [options]\n\nSend raw named keystrokes to a live agent's TUI — no message framing, no\nauto-Enter. Drives selection menus and other interactive prompts that a\nplain `ay send` (text + Enter) can't. Keys are paced so the CLI registers\neach as a discrete event, not a paste.\n\nKeys: up down left right enter esc tab space backspace delete home end\n pageup pagedown ctrl-c ctrl-d ctrl-y raw:0xNN\n\nExamples:\n ay key 1234 down down enter # move the menu cursor down twice, confirm\n ay key 1234 esc # dismiss a menu\n ay key 1234 raw:0x1b # a literal ESC byte").option("pace", {
2080
+ type: "number",
2081
+ default: KEY_PACE_MS,
2082
+ description: "ms between keystrokes"
2083
+ }).option("all", {
2084
+ type: "boolean",
2085
+ default: false,
2086
+ description: "Include exited agents"
2087
+ }).option("latest", {
2088
+ type: "boolean",
2089
+ default: false,
2090
+ description: "Use most recent match"
2091
+ }).option("cwd", {
2092
+ type: "string",
2093
+ description: "Restrict to agents under this dir"
2094
+ }).option("force", {
2095
+ type: "boolean",
2096
+ default: false,
2097
+ description: "Skip the recency/self-send guard (also: AGENT_YES_FORCE_SEND=1)"
2098
+ }).help(false).version(false).exitProcess(false).parseAsync();
2099
+ const keyword = argv._[0] !== void 0 ? String(argv._[0]) : void 0;
2100
+ const keyNames = argv._.slice(1).map(String);
2101
+ if (!keyword || keyNames.length === 0) throw new Error("usage: ay key <keyword> <key...> (e.g. ay key 1234 down down enter)");
2102
+ const byteSeqs = keyNames.map((n) => controlCodeFromName(n.toLowerCase()));
2103
+ const record = await resolveWritableAgent(keyword, {
2104
+ all: argv.all,
2105
+ active: false,
2106
+ json: false,
2107
+ latest: argv.latest,
2108
+ cwdScope: typeof argv.cwd === "string" ? path.resolve(argv.cwd) : null
2109
+ });
2110
+ await enforceSendGuards(record, Boolean(argv.force) || process.env.AGENT_YES_FORCE_SEND === "1");
2111
+ await writeKeysPaced(record.fifo_file, byteSeqs, Math.max(0, argv.pace));
2112
+ process.stdout.write(`sent to pid ${record.pid} (${record.cli}): ${keyNames.join(" ")}\n`);
2113
+ return 0;
2114
+ }
2115
+ async function cmdSelect(rest) {
2116
+ const argv = await yargs(rest).usage("Usage: ay select <keyword> <N> [options]\n\nPick option N of the selection menu a needs_input agent is parked on.\nRe-parses the live menu (the same ❯-cursor detection `ay ls` uses), computes\nhow far the cursor must move, and sends that many Down/Up keys + Enter — so\nit's robust to a pre-highlighted default (never assumes the cursor starts at 1)\nand doesn't rely on numeric hotkeys (arrow-driven menus ignore them).\n\nExamples:\n ay select 1234 2 # choose option 2\n ay select 1234 2 --wait # …and block until the menu clears").option("pace", {
2117
+ type: "number",
2118
+ default: KEY_PACE_MS,
2119
+ description: "ms between keystrokes"
2120
+ }).option("wait", {
2121
+ type: "boolean",
2122
+ default: false,
2123
+ description: "Block until the agent leaves needs_input (or --timeout)"
2124
+ }).option("timeout", {
2125
+ type: "number",
2126
+ default: 10,
2127
+ description: "Seconds to wait with --wait"
2128
+ }).option("all", {
2129
+ type: "boolean",
2130
+ default: false,
2131
+ description: "Include exited agents"
2132
+ }).option("latest", {
2133
+ type: "boolean",
2134
+ default: false,
2135
+ description: "Use most recent match"
2136
+ }).option("cwd", {
2137
+ type: "string",
2138
+ description: "Restrict to agents under this dir"
2139
+ }).option("force", {
2140
+ type: "boolean",
2141
+ default: false,
2142
+ description: "Skip the recency/self-send guard (also: AGENT_YES_FORCE_SEND=1)"
2143
+ }).help(false).version(false).exitProcess(false).parseAsync();
2144
+ const keyword = argv._[0] !== void 0 ? String(argv._[0]) : void 0;
2145
+ const n = Number(argv._[1]);
2146
+ if (!keyword || !Number.isInteger(n) || n < 1) throw new Error("usage: ay select <keyword> <N> (N = the 1-based option number to choose)");
2147
+ const record = await resolveWritableAgent(keyword, {
2148
+ all: argv.all,
2149
+ active: false,
2150
+ json: false,
2151
+ latest: argv.latest,
2152
+ cwdScope: typeof argv.cwd === "string" ? path.resolve(argv.cwd) : null
2153
+ });
2154
+ if (!record.log_file) throw new Error(`pid ${record.pid}: no log_file recorded — can't read the menu to select from.`);
2155
+ await enforceSendGuards(record, Boolean(argv.force) || process.env.AGENT_YES_FORCE_SEND === "1");
2156
+ const menu = await extractMenu(record.log_file, record.cli);
2157
+ if (!menu) throw new Error(`pid ${record.pid} (${record.cli}) is not parked on a selection menu (not needs_input).\n Check with: ay status ${record.pid}`);
2158
+ if (menu.options.length > 0 && !menu.options.includes(n)) throw new Error(`option ${n} is out of range — this menu offers ${menu.options.join(", ")}.`);
2159
+ const byteSeqs = menuSelectKeys(menu.cursor, n).map((k) => controlCodeFromName(k));
2160
+ await writeKeysPaced(record.fifo_file, byteSeqs, Math.max(0, argv.pace));
2161
+ const delta = n - menu.cursor;
2162
+ const moved = delta === 0 ? "cursor already there" : `${Math.abs(delta)}× ${delta > 0 ? "down" : "up"}`;
2163
+ process.stdout.write(`pid ${record.pid} (${record.cli}): selected option ${n} (${moved} + enter)\n`);
2164
+ if (argv.wait) {
2165
+ const ok = await waitForNeedsInputClear(record, Math.max(1, argv.timeout) * 1e3);
2166
+ process.stdout.write(ok ? ` menu cleared — selection accepted.\n` : ` still needs_input after ${argv.timeout}s — re-check with 'ay status ${record.pid}'.\n`);
2167
+ return ok ? 0 : 1;
2168
+ }
2169
+ return 0;
2170
+ }
1976
2171
  function stopTipForCli(cli, pid) {
1977
2172
  const cmd = GRACEFUL_EXIT_COMMANDS[cli];
1978
2173
  if (cmd) return ` tip: ${cli} ignores a single Ctrl+C — try 'ay stop ${pid}' (sends '${cmd}') or double Ctrl+C.\n`;
@@ -2000,12 +2195,27 @@ function controlCodeFromName(name) {
2000
2195
  case "ctrl\\":
2001
2196
  case "ctrl-backslash": return "";
2002
2197
  case "tab": return " ";
2198
+ case "up": return "\x1B[A";
2199
+ case "down": return "\x1B[B";
2200
+ case "right": return "\x1B[C";
2201
+ case "left": return "\x1B[D";
2202
+ case "home": return "\x1B[H";
2203
+ case "end": return "\x1B[F";
2204
+ case "pageup":
2205
+ case "pgup": return "\x1B[5~";
2206
+ case "pagedown":
2207
+ case "pgdn": return "\x1B[6~";
2208
+ case "space": return " ";
2209
+ case "backspace":
2210
+ case "bs": return "";
2211
+ case "delete":
2212
+ case "del": return "\x1B[3~";
2003
2213
  case "none":
2004
2214
  case "": return "";
2005
2215
  default:
2006
2216
  const m = /^raw:0x([0-9a-f]+)$/i.exec(name);
2007
2217
  if (m) return String.fromCharCode(parseInt(m[1], 16));
2008
- throw new Error(`unknown --code=${name}`);
2218
+ throw new Error(`unknown key/code: ${name}`);
2009
2219
  }
2010
2220
  }
2011
2221
  async function writeToIpc(ipcPath, payload) {
@@ -2794,5 +3004,5 @@ async function cmdResultSet(rest) {
2794
3004
  }
2795
3005
 
2796
3006
  //#endregion
2797
- export { restartHintLines as C, writeToIpc as D, stopTipForCli as E, resolveResumeArgs as S, snapshotStatus as T, readPtysize as _, cursorAbs as a, resolveOne as b, extractTaskCounts as c, isExitRequest as d, isPidAlive as f, readNotes as g, matchKeyword as h, controlCodeFromName as i, finalizedLines as l, listRecords as m, READ_PAGE_DEFAULT as n, deriveLiveStatus as o, isSubcommand as p, cmdHelp as r, extractNeedsInput as s, GRACEFUL_EXIT_COMMANDS as t, isAgentStuck as u, renderRawLog as v, runSubcommand as w, resolveReadWindow as x, renderRawLogLines as y };
2798
- //# sourceMappingURL=subcommands-XvqBWxtm.js.map
3007
+ export { writeToIpc as A, resolveReadWindow as C, snapshotStatus as D, runSubcommand as E, stopTipForCli as O, resolveOne as S, restartHintLines as T, menuSelectKeys as _, cursorAbs as a, renderRawLog as b, extractNeedsInput as c, isAgentStuck as d, isExitRequest as f, matchKeyword as g, listRecords as h, controlCodeFromName as i, writeKeysPaced as k, extractTaskCounts as l, isSubcommand as m, READ_PAGE_DEFAULT as n, deriveLiveStatus as o, isPidAlive as p, cmdHelp as r, extractMenu as s, GRACEFUL_EXIT_COMMANDS as t, finalizedLines as u, readNotes as v, resolveResumeArgs as w, renderRawLogLines as x, readPtysize as y };
3008
+ //# sourceMappingURL=subcommands-CuuiDKr0.js.map
@@ -0,0 +1,9 @@
1
+ import "./logger-CDIsZ-Pp.js";
2
+ import "./globalPidIndex-DlmmJlO8.js";
3
+ import "./configShared-CEyhl0WH.js";
4
+ import "./e2e-Bfw7qL9O.js";
5
+ import "./webrtcLink-CBZkZ-LT.js";
6
+ import "./remotes-T6nf0t3K.js";
7
+ import { A as writeToIpc, C as resolveReadWindow, D as snapshotStatus, E as runSubcommand, O as stopTipForCli, S as resolveOne, T as restartHintLines, _ as menuSelectKeys, a as cursorAbs, b as renderRawLog, c as extractNeedsInput, d as isAgentStuck, f as isExitRequest, g as matchKeyword, h as listRecords, i as controlCodeFromName, k as writeKeysPaced, l as extractTaskCounts, m as isSubcommand, n as READ_PAGE_DEFAULT, o as deriveLiveStatus, p as isPidAlive, r as cmdHelp, s as extractMenu, t as GRACEFUL_EXIT_COMMANDS, u as finalizedLines, v as readNotes, w as resolveResumeArgs, x as renderRawLogLines, y as readPtysize } from "./subcommands-CuuiDKr0.js";
8
+
9
+ export { cmdHelp, isSubcommand, runSubcommand };
@@ -187,4 +187,4 @@ async function startTray() {
187
187
 
188
188
  //#endregion
189
189
  export { ensureTray, startTray };
190
- //# sourceMappingURL=tray-BMzpUSfa.js.map
190
+ //# sourceMappingURL=tray-CZarCA2Q.js.map
@@ -1,5 +1,5 @@
1
1
  import { n as logger, t as addTransport } from "./logger-CDIsZ-Pp.js";
2
- import { r as getInstalledPackage } from "./versionChecker-DiNfz39j.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-DwkNjj7n.js";
3
3
  import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
4
4
  import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-V4qvXgAw.js";
5
5
  import { t as PidStore } from "./pidStore-BfoBWUjc.js";
@@ -1803,4 +1803,4 @@ function sleep(ms) {
1803
1803
 
1804
1804
  //#endregion
1805
1805
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1806
- //# sourceMappingURL=ts-Bjfd09LQ.js.map
1806
+ //# sourceMappingURL=ts-YJSzO6hM.js.map
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
 
8
8
  //#region package.json
9
9
  var name = "agent-yes";
10
- var version = "1.164.0";
10
+ var version = "1.166.0";
11
11
 
12
12
  //#endregion
13
13
  //#region ts/versionChecker.ts
@@ -215,4 +215,4 @@ async function displayVersion() {
215
215
 
216
216
  //#endregion
217
217
  export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
218
- //# sourceMappingURL=versionChecker-DiNfz39j.js.map
218
+ //# sourceMappingURL=versionChecker-DwkNjj7n.js.map
@@ -256,4 +256,4 @@ async function startWebrtcBridge(link) {
256
256
 
257
257
  //#endregion
258
258
  export { startWebrtcBridge };
259
- //# sourceMappingURL=webrtcRemote-Ccdzmuc-.js.map
259
+ //# sourceMappingURL=webrtcRemote-GAgF5K45.js.map
@@ -96,6 +96,45 @@ function getSpawnHook() {
96
96
  function hasSpawnHook() {
97
97
  return getSpawnHook() !== null;
98
98
  }
99
+ /**
100
+ * Cap on concurrently-live agents admitted via `/api/spawn`. Env
101
+ * `AGENT_YES_MAX_AGENTS` overrides the config `maxAgents`. A non-positive,
102
+ * missing, or unparseable value means **unlimited** (returns undefined), which
103
+ * preserves the historical no-cap behavior. Exists to stop an unbounded fan-out
104
+ * of agents from exhausting host RAM and tripping the OOM-killer.
105
+ */
106
+ function getMaxAgents() {
107
+ const raw = process.env.AGENT_YES_MAX_AGENTS?.trim();
108
+ const n = raw !== void 0 && raw !== "" ? Number(raw) : readConfig().maxAgents;
109
+ const v = typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : NaN;
110
+ return v > 0 ? v : void 0;
111
+ }
112
+ /**
113
+ * Minimum system MemAvailable (MB) required to admit a new spawn. Env
114
+ * `AGENT_YES_MIN_FREE_MB` overrides config `minFreeMb`. Non-positive/unset =
115
+ * no floor (undefined). Complements {@link getMaxAgents}: a count cap alone
116
+ * can't stop OOM when individual agents are large, so we also refuse to spawn
117
+ * when free memory is already low.
118
+ */
119
+ function getMinFreeMb() {
120
+ const raw = process.env.AGENT_YES_MIN_FREE_MB?.trim();
121
+ const n = raw !== void 0 && raw !== "" ? Number(raw) : readConfig().minFreeMb;
122
+ const v = typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : NaN;
123
+ return v > 0 ? v : void 0;
124
+ }
125
+ /**
126
+ * Max time (ms) a CLI spawn blocks waiting for capacity before failing open.
127
+ * Env `AGENT_YES_SPAWN_WAIT_MS` overrides config `spawnWaitMs`. A non-negative
128
+ * finite value is used as-is (0 = don't wait, check once then proceed); anything
129
+ * missing/garbage falls back to the 10-minute default. Bounded fail-open is
130
+ * deliberate: recursive spawns must never deadlock permanently on each other.
131
+ */
132
+ function getSpawnWaitMs() {
133
+ const DEFAULT = 6e5;
134
+ const raw = process.env.AGENT_YES_SPAWN_WAIT_MS?.trim();
135
+ const n = raw !== void 0 && raw !== "" ? Number(raw) : readConfig().spawnWaitMs;
136
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT;
137
+ }
99
138
  /** Persist the workspace root, tilde-expanded and resolved to an absolute path. */
100
139
  function setWorkspaceRoot(dir) {
101
140
  const abs = path.resolve(expandTilde(dir));
@@ -122,5 +161,5 @@ function resolveSpawnCwd(input) {
122
161
  }
123
162
 
124
163
  //#endregion
125
- export { isProvisionAllowed as a, hasSpawnHook as i, getSpawnHook as n, resolveSpawnCwd as o, getWorkspaceRoot as r, setWorkspaceRoot as s, getProvisionRoot as t };
126
- //# sourceMappingURL=workspaceConfig-B0Q9-q2B.js.map
164
+ export { getSpawnWaitMs as a, isProvisionAllowed as c, getSpawnHook as i, resolveSpawnCwd as l, getMinFreeMb as n, getWorkspaceRoot as o, getProvisionRoot as r, hasSpawnHook as s, getMaxAgents as t, setWorkspaceRoot as u };
165
+ //# sourceMappingURL=workspaceConfig-D3OH7and.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.164.0",
3
+ "version": "1.166.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
package/ts/cli.ts CHANGED
@@ -62,6 +62,27 @@ if (config.tray) {
62
62
  ensureTray(); // fire-and-forget, don't await
63
63
  }
64
64
 
65
+ // Spawn admission control — applies to a REAL agent launch only (subcommands,
66
+ // --version, --tray already handled/exited above; --append-prompt / --version
67
+ // below are not launches). Sits BEFORE the --rust dispatch so it gates BOTH the
68
+ // Rust and the TypeScript runtimes. Blocks with φ-backoff until there's capacity
69
+ // (or fails open after a timeout) so a burst of recursive `ay <cli>` spawns gets
70
+ // spaced out instead of storming the host into the OOM-killer. No-op (instant)
71
+ // unless maxAgents/minFreeMb is configured — see ts/spawnGate.ts.
72
+ if (!config.showVersion && !config.appendPrompt && !config.tray) {
73
+ const { waitForSpawnCapacity } = await import("./spawnGate.ts");
74
+ await waitForSpawnCapacity({
75
+ onWait: (reason, waitedMs) => {
76
+ if (config.verbose || waitedMs === 0)
77
+ console.error(`[agent-yes] spawn gate: ${reason} — waiting…`);
78
+ },
79
+ onProceedAnyway: (reason, waitedMs) =>
80
+ console.error(
81
+ `[agent-yes] spawn gate: ${reason} — waited ${Math.round(waitedMs / 1000)}s, proceeding anyway`,
82
+ ),
83
+ });
84
+ }
85
+
65
86
  // Handle --rust: spawn the Rust binary instead, fall back to TypeScript if unavailable
66
87
  if (config.useRust) {
67
88
  let rustBinary: string | undefined;
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "vitest";
2
- import { classifyNeedsInput, isWorkingScreen } from "./needsInput.ts";
2
+ import { classifyNeedsInput, isWorkingScreen, parseMenu } from "./needsInput.ts";
3
3
  import { loadSharedCliDefaults } from "./configShared.ts";
4
4
 
5
5
  // Use the REAL shipped claude/codex patterns so the test guards the actual config.
@@ -72,3 +72,44 @@ test("detects a codex selection menu (› cursor)", () => {
72
72
  test("no patterns configured → always null", () => {
73
73
  expect(classifyNeedsInput(["❯ 1. anything"], { needsInput: [], working: [] })).toBeNull();
74
74
  });
75
+
76
+ test("parseMenu: cursor on option 1, all options collected (for ay select)", () => {
77
+ const screen = [
78
+ "Which auth method should we use?",
79
+ "",
80
+ "❯ 1. Session tokens",
81
+ " 2. JWT",
82
+ " 3. OAuth",
83
+ "",
84
+ "? for shortcuts",
85
+ ];
86
+ const menu = parseMenu(screen, claude);
87
+ expect(menu).not.toBeNull();
88
+ expect(menu!.cursor).toBe(1);
89
+ expect(menu!.options).toEqual([1, 2, 3]);
90
+ });
91
+
92
+ test("parseMenu: reads a pre-highlighted non-first default (cursor delta, not blind N-1)", () => {
93
+ const screen = ["Proceed?", " 1. Yes", "❯ 2. No, ask again", " 3. Cancel"];
94
+ const menu = parseMenu(screen, claude);
95
+ expect(menu!.cursor).toBe(2);
96
+ expect(menu!.options).toEqual([1, 2, 3]);
97
+ });
98
+
99
+ test("parseMenu: codex › cursor", () => {
100
+ const menu = parseMenu(["Pick a branch", " 1. main", "› 2. develop"], codex);
101
+ expect(menu!.cursor).toBe(2);
102
+ expect(menu!.options).toEqual([1, 2]);
103
+ });
104
+
105
+ test("parseMenu: null when not on a menu (idle prompt / working)", () => {
106
+ expect(parseMenu(['❯ Try "fix the bug"', "? for shortcuts"], claude)).toBeNull();
107
+ expect(parseMenu(["❯ 1. Session tokens", "✻ Working… (esc to interrupt)"], claude)).toBeNull();
108
+ });
109
+
110
+ test("parseMenu: a 'N.M' version in an option label isn't mistaken for another option", () => {
111
+ const screen = ["Cleanup?", "❯ 1. Delete 3.5GB of cache", " 2. Keep"];
112
+ const menu = parseMenu(screen, claude);
113
+ expect(menu!.cursor).toBe(1);
114
+ expect(menu!.options).toEqual([1, 2]);
115
+ });
package/ts/needsInput.ts CHANGED
@@ -67,6 +67,60 @@ export function classifyNeedsInput(
67
67
  return { question: block.join(" • ").slice(0, 400) };
68
68
  }
69
69
 
70
+ export interface MenuState {
71
+ /** The 1-based option number the menu cursor (❯/›/>) currently sits on. */
72
+ cursor: number;
73
+ /** Every visible option number, ascending — for range-checking a requested N. */
74
+ options: number[];
75
+ /** Same compact menu rendering as {@link classifyNeedsInput}. */
76
+ question: string;
77
+ }
78
+
79
+ // An option row: an optional cursor glyph / bullet, then "N. " (the trailing
80
+ // space rejects version-like "3.5GB" that isn't a menu option).
81
+ const OPTION_LINE = /^[\s❯›>▶◉○●·*\-]*?(\d+)\.\s/;
82
+
83
+ /**
84
+ * Parse the selection menu a `needs_input` agent is parked on into a cursor
85
+ * position + the available option numbers, so a caller can compute how far the
86
+ * cursor must move (Down/Up) to reach a target option. Returns null when the
87
+ * screen isn't a menu (delegates that judgement to {@link classifyNeedsInput},
88
+ * so `working` still wins) or no numbered cursor line is found. Pure — the
89
+ * `ay select` action reuses the exact detection `ay ls` renders with.
90
+ */
91
+ export function parseMenu(
92
+ lines: string[],
93
+ cfg: { needsInput?: RegExp[]; working?: RegExp[] },
94
+ ): MenuState | null {
95
+ const ni = classifyNeedsInput(lines, cfg);
96
+ if (!ni) return null;
97
+ const patterns = cfg.needsInput ?? [];
98
+ // The cursor line is the last one carrying a needsInput match (matches how
99
+ // classifyNeedsInput anchors its question window).
100
+ let cursorLine = -1;
101
+ for (let i = 0; i < lines.length; i++) {
102
+ if (patterns.some((re) => reTest(re, lines[i]!))) cursorLine = i;
103
+ }
104
+ if (cursorLine < 0) return null;
105
+ const cm = /(\d+)\./.exec(lines[cursorLine]!);
106
+ if (!cm) return null;
107
+ const cursor = parseInt(cm[1]!, 10);
108
+ // Gather option numbers from the rows around the cursor (a menu is contiguous).
109
+ const start = Math.max(0, cursorLine - 12);
110
+ const end = Math.min(lines.length, cursorLine + 12);
111
+ const options: number[] = [];
112
+ for (let i = start; i < end; i++) {
113
+ const m = OPTION_LINE.exec(lines[i]!);
114
+ if (m) {
115
+ const v = parseInt(m[1]!, 10);
116
+ if (!options.includes(v)) options.push(v);
117
+ }
118
+ }
119
+ if (!options.includes(cursor)) options.push(cursor);
120
+ options.sort((a, b) => a - b);
121
+ return { cursor, options, question: ni.question };
122
+ }
123
+
70
124
  /**
71
125
  * True when the rendered screen still shows a "busy" marker (config `working`,
72
126
  * e.g. claude's `esc to interrupt`). Paired with a long-quiet log this is the
package/ts/serve.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  type CommonOpts,
22
22
  } from "./subcommands.ts";
23
23
  import { updateGlobalPidStatus } from "./globalPidIndex.ts";
24
+ import { spawnRejectionReason } from "./spawnGate.ts";
24
25
  import { pgidForWrapper } from "./reaper.ts";
25
26
  import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
26
27
  import { getInstalledPackage } from "./versionChecker.ts";
@@ -1705,6 +1706,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
1705
1706
  return new Response(`unsupported cli: ${cli}`, { status: 400 });
1706
1707
  const prompt = String(body.prompt ?? "");
1707
1708
 
1709
+ // Admission control BEFORE any provisioning (clone/worktree) so a capped
1710
+ // request fails fast without doing expensive work. Enforces the optional
1711
+ // concurrency cap + memory floor that keep a fan-out of agents from
1712
+ // driving the host into the OOM-killer. 429 = back-pressure, retry later.
1713
+ const reject = await spawnRejectionReason();
1714
+ if (reject) return new Response(reject, { status: 429 });
1715
+
1708
1716
  // Resolve the working directory. A `from` source is provisioned (clone /
1709
1717
  // worktree) through codehost/provision; a plain `cwd` is resolved to the
1710
1718
  // workspace root and mkdir-p'd so a missing dir no longer ENOENTs.