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
package/ts/subcommands.ts CHANGED
@@ -18,7 +18,13 @@ import path from "path";
18
18
  import { type GlobalPidRecord, readGlobalPids, updateGlobalPidStatus } from "./globalPidIndex.ts";
19
19
  import { buildAgentForest, flattenForest } from "./agentTree.ts";
20
20
  import { parseTaskCounts, type TaskCounts } from "./todoParse.ts";
21
- import { classifyNeedsInput, isWorkingScreen, type NeedsInput } from "./needsInput.ts";
21
+ import {
22
+ classifyNeedsInput,
23
+ isWorkingScreen,
24
+ parseMenu,
25
+ type MenuState,
26
+ type NeedsInput,
27
+ } from "./needsInput.ts";
22
28
  import { diffLsStates, type LiveState, type LsAgentState } from "./lsWatch.ts";
23
29
  import {
24
30
  buildStoredResult,
@@ -251,6 +257,8 @@ const SUBCOMMANDS = new Set([
251
257
  "tail",
252
258
  "head",
253
259
  "send",
260
+ "key",
261
+ "select",
254
262
  "spawn",
255
263
  "attach",
256
264
  "stop",
@@ -326,6 +334,10 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
326
334
  return await cmdRead(rest, { mode: "head" });
327
335
  case "send":
328
336
  return await cmdSend(rest);
337
+ case "key":
338
+ return await cmdKey(rest);
339
+ case "select":
340
+ return await cmdSelect(rest);
329
341
  case "spawn":
330
342
  return await cmdSpawn(rest);
331
343
  case "attach":
@@ -392,6 +404,8 @@ export function cmdHelp(managerCommands = true): number {
392
404
  ` ay cat <keyword> full log\n` +
393
405
  ` ay head <keyword> first N lines\n` +
394
406
  ` ay send <keyword> <msg> send a message\n` +
407
+ ` ay key <keyword> <key...> send raw keystrokes (down/up/enter/esc/…) — drives menus\n` +
408
+ ` ay select <keyword> <N> pick option N of a needs_input selection menu\n` +
395
409
  ` ay attach <keyword> interactive attach (detach: Ctrl-\\)\n` +
396
410
  ` ay stop <keyword> graceful shutdown (/exit for claude/codex)\n` +
397
411
  ` ay exit <keyword> [reason] graceful shutdown, recording who/why (= 'ay send <kw> exit')\n` +
@@ -2215,9 +2229,112 @@ async function cmdSpawn(rest: string[]): Promise<number> {
2215
2229
  }
2216
2230
 
2217
2231
  // ---------------------------------------------------------------------------
2218
- // ay send
2232
+ // ay send / ay key / ay select — inject input into a live agent
2219
2233
  // ---------------------------------------------------------------------------
2220
2234
 
2235
+ /**
2236
+ * Shared safety gate for every command that writes to a live agent's stdin
2237
+ * (`send`, `key`, `select`): refuse a self-targeting loop, and require that THIS
2238
+ * sender actually looked at THIS target recently — an agent is blocked, an
2239
+ * interactive human is only warned — unless `force`. Returns the sender context
2240
+ * so a caller can reuse it (e.g. `send`'s `[from …]` prefix). Extracted from
2241
+ * cmdSend so the action commands enforce the identical guard.
2242
+ */
2243
+ async function enforceSendGuards(
2244
+ record: GlobalPidRecord,
2245
+ force: boolean,
2246
+ ): Promise<{ key: string; agent: GlobalPidRecord | null }> {
2247
+ const sender = await senderContext();
2248
+
2249
+ // Self-send guard: an agent firing at its own pid is almost always a loop.
2250
+ if (sender.agent && sender.agent.pid === record.pid && !force) {
2251
+ throw new Error(
2252
+ `refusing to send to yourself (pid ${record.pid}) — pass --force if you really mean it.`,
2253
+ );
2254
+ }
2255
+
2256
+ // Recency guard: require that THIS sender tailed THIS resolved target within
2257
+ // the window. Catches a fuzzy keyword resolving to an agent you never looked
2258
+ // at. Agents are blocked (override with --force / AGENT_YES_FORCE_SEND=1);
2259
+ // an interactive human shell is only warned.
2260
+ const last = await lastReadAt(sender.key, record.pid);
2261
+ const fresh = last !== null && Date.now() - last <= READ_WINDOW_MS;
2262
+ if (!fresh && !force) {
2263
+ const ago =
2264
+ last === null ? "never read" : `last read ${Math.round((Date.now() - last) / 1000)}s ago`;
2265
+ const what = `pid ${record.pid} (${record.cli}, ${shortenPath(record.cwd)}) — ${ago}, not within ${READ_WINDOW_MS / 1000}s`;
2266
+ if (sender.agent) {
2267
+ throw new Error(
2268
+ `${what}.\n Confirm it's the right agent first: ay tail ${record.pid}\n then resend, or pass --force to override.`,
2269
+ );
2270
+ }
2271
+ process.stderr.write(
2272
+ `warning: ${what} — make sure this is the agent you meant (ay tail ${record.pid}).\n`,
2273
+ );
2274
+ }
2275
+ return sender;
2276
+ }
2277
+
2278
+ // Inter-keystroke pace (ms) for `ay key` / `ay select`. Fast enough to feel
2279
+ // instant, slow enough that the CLI's input loop registers each key as a
2280
+ // discrete event instead of coalescing the burst into a bracketed paste — claude
2281
+ // treats a fast multi-byte blob as pasted text (see the run loop's paste guard),
2282
+ // which would drop arrow keys into the composer instead of moving the menu.
2283
+ const KEY_PACE_MS = 40;
2284
+
2285
+ /**
2286
+ * The named-key sequence that moves a menu cursor from `cursor` to option
2287
+ * `target` and confirms: |Δ| Downs (target below) or Ups (target above), then
2288
+ * Enter. Pure so the arrow arithmetic is unit-tested independent of any live PTY.
2289
+ */
2290
+ export function menuSelectKeys(cursor: number, target: number): string[] {
2291
+ const delta = target - cursor;
2292
+ const nav = Array(Math.abs(delta)).fill(delta > 0 ? "down" : "up");
2293
+ return [...nav, "enter"];
2294
+ }
2295
+
2296
+ /** Write each already-encoded key sequence to the FIFO with a pace gap between
2297
+ * them (no gap after the last). Raw bytes, no `[from]` framing, no auto-Enter. */
2298
+ export async function writeKeysPaced(
2299
+ fifoPath: string,
2300
+ byteSeqs: string[],
2301
+ paceMs: number,
2302
+ ): Promise<void> {
2303
+ for (let i = 0; i < byteSeqs.length; i++) {
2304
+ if (byteSeqs[i] === "") continue; // `none`/empty — nothing to send
2305
+ await writeToIpc(fifoPath, byteSeqs[i]!);
2306
+ if (i < byteSeqs.length - 1 && paceMs > 0) {
2307
+ await new Promise((r) => setTimeout(r, paceMs));
2308
+ }
2309
+ }
2310
+ }
2311
+
2312
+ /**
2313
+ * The selection menu a needs_input agent is parked on, or null when it isn't on
2314
+ * one. Mirrors extractNeedsInput (same 32 KB tail render + config patterns) but
2315
+ * returns the cursor position + option numbers so `ay select` can compute the
2316
+ * cursor delta.
2317
+ */
2318
+ export async function extractMenu(logPath: string, cli: string): Promise<MenuState | null> {
2319
+ const cfg = (await cliDefaults())[cli];
2320
+ if (!cfg?.needsInput?.length) return null;
2321
+ const lines = await renderLogTailLines(logPath, 40);
2322
+ if (!lines) return null;
2323
+ return parseMenu(lines, { needsInput: cfg.needsInput, working: cfg.working });
2324
+ }
2325
+
2326
+ /** Poll until the agent is no longer parked on a menu (selection accepted → it
2327
+ * resumed / moved on) or the deadline passes. Returns true if it cleared. */
2328
+ async function waitForNeedsInputClear(record: GlobalPidRecord, timeoutMs: number): Promise<boolean> {
2329
+ const deadline = Date.now() + timeoutMs;
2330
+ while (Date.now() < deadline) {
2331
+ await new Promise((r) => setTimeout(r, 250));
2332
+ const snap = await snapshotStatus(record);
2333
+ if (snap.state !== "needs_input") return true;
2334
+ }
2335
+ return false;
2336
+ }
2337
+
2221
2338
  async function cmdSend(rest: string[]): Promise<number> {
2222
2339
  const y = yargs(rest)
2223
2340
  .usage("Usage: ay send <keyword> <msg|-> [options]")
@@ -2277,35 +2394,8 @@ async function cmdSend(rest: string[]): Promise<number> {
2277
2394
  }
2278
2395
 
2279
2396
  // Who's sending, and have they actually looked at this target recently?
2280
- const sender = await senderContext();
2281
2397
  const force = Boolean(argv.force) || process.env.AGENT_YES_FORCE_SEND === "1";
2282
-
2283
- // Self-send guard: an agent firing at its own pid is almost always a loop.
2284
- if (sender.agent && sender.agent.pid === record.pid && !force) {
2285
- throw new Error(
2286
- `refusing to send to yourself (pid ${record.pid}) — pass --force if you really mean it.`,
2287
- );
2288
- }
2289
-
2290
- // Recency guard: require that THIS sender tailed THIS resolved target within
2291
- // the window. Catches a fuzzy keyword resolving to an agent you never looked
2292
- // at. Agents are blocked (override with --force / AGENT_YES_FORCE_SEND=1);
2293
- // an interactive human shell is only warned.
2294
- const last = await lastReadAt(sender.key, record.pid);
2295
- const fresh = last !== null && Date.now() - last <= READ_WINDOW_MS;
2296
- if (!fresh && !force) {
2297
- const ago =
2298
- last === null ? "never read" : `last read ${Math.round((Date.now() - last) / 1000)}s ago`;
2299
- const what = `pid ${record.pid} (${record.cli}, ${shortenPath(record.cwd)}) — ${ago}, not within ${READ_WINDOW_MS / 1000}s`;
2300
- if (sender.agent) {
2301
- throw new Error(
2302
- `${what}.\n Confirm it's the right agent first: ay tail ${record.pid}\n then resend, or pass --force to override.`,
2303
- );
2304
- }
2305
- process.stderr.write(
2306
- `warning: ${what} — make sure this is the agent you meant (ay tail ${record.pid}).\n`,
2307
- );
2308
- }
2398
+ const sender = await enforceSendGuards(record, force);
2309
2399
 
2310
2400
  // A bare "exit" / "/exit" isn't a prompt to type — claude only honours the
2311
2401
  // literal `/exit` command, so `ay send <pid> exit` lands as plain text and the
@@ -2355,6 +2445,163 @@ async function cmdSend(rest: string[]): Promise<number> {
2355
2445
  return 0;
2356
2446
  }
2357
2447
 
2448
+ // Resolve a keyword to one agent and return it with a writable FIFO, or throw
2449
+ // with the same guidance cmdSend gives. Shared by `ay key` / `ay select`.
2450
+ async function resolveWritableAgent(keyword: string, opts: CommonOpts): Promise<GlobalPidRecord> {
2451
+ const record = await resolveOne(keyword, opts);
2452
+ if (!record.fifo_file) {
2453
+ throw new Error(
2454
+ `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.`,
2455
+ );
2456
+ }
2457
+ return record;
2458
+ }
2459
+
2460
+ async function cmdKey(rest: string[]): Promise<number> {
2461
+ const y = yargs(rest)
2462
+ .usage(
2463
+ "Usage: ay key <keyword> <key...> [options]\n\n" +
2464
+ "Send raw named keystrokes to a live agent's TUI — no message framing, no\n" +
2465
+ "auto-Enter. Drives selection menus and other interactive prompts that a\n" +
2466
+ "plain `ay send` (text + Enter) can't. Keys are paced so the CLI registers\n" +
2467
+ "each as a discrete event, not a paste.\n\n" +
2468
+ "Keys: up down left right enter esc tab space backspace delete home end\n" +
2469
+ " pageup pagedown ctrl-c ctrl-d ctrl-y raw:0xNN\n\n" +
2470
+ "Examples:\n" +
2471
+ " ay key 1234 down down enter # move the menu cursor down twice, confirm\n" +
2472
+ " ay key 1234 esc # dismiss a menu\n" +
2473
+ " ay key 1234 raw:0x1b # a literal ESC byte",
2474
+ )
2475
+ .option("pace", { type: "number", default: KEY_PACE_MS, description: "ms between keystrokes" })
2476
+ .option("all", { type: "boolean", default: false, description: "Include exited agents" })
2477
+ .option("latest", { type: "boolean", default: false, description: "Use most recent match" })
2478
+ .option("cwd", { type: "string", description: "Restrict to agents under this dir" })
2479
+ .option("force", {
2480
+ type: "boolean",
2481
+ default: false,
2482
+ description: "Skip the recency/self-send guard (also: AGENT_YES_FORCE_SEND=1)",
2483
+ })
2484
+ .help(false)
2485
+ .version(false)
2486
+ .exitProcess(false);
2487
+
2488
+ const argv = await y.parseAsync();
2489
+ const keyword = argv._[0] !== undefined ? String(argv._[0]) : undefined;
2490
+ const keyNames = argv._.slice(1).map(String);
2491
+ if (!keyword || keyNames.length === 0) {
2492
+ throw new Error("usage: ay key <keyword> <key...> (e.g. ay key 1234 down down enter)");
2493
+ }
2494
+ // Map every key up front so an unknown name fails before we send anything
2495
+ // (a half-sent sequence could leave a menu in a surprising state).
2496
+ const byteSeqs = keyNames.map((n) => controlCodeFromName(n.toLowerCase()));
2497
+
2498
+ const opts: CommonOpts = {
2499
+ all: argv.all,
2500
+ active: false,
2501
+ json: false,
2502
+ latest: argv.latest,
2503
+ cwdScope: typeof argv.cwd === "string" ? path.resolve(argv.cwd) : null,
2504
+ };
2505
+ const record = await resolveWritableAgent(keyword, opts);
2506
+ const force = Boolean(argv.force) || process.env.AGENT_YES_FORCE_SEND === "1";
2507
+ await enforceSendGuards(record, force);
2508
+
2509
+ await writeKeysPaced(record.fifo_file!, byteSeqs, Math.max(0, argv.pace));
2510
+ process.stdout.write(`sent to pid ${record.pid} (${record.cli}): ${keyNames.join(" ")}\n`);
2511
+ return 0;
2512
+ }
2513
+
2514
+ async function cmdSelect(rest: string[]): Promise<number> {
2515
+ const y = yargs(rest)
2516
+ .usage(
2517
+ "Usage: ay select <keyword> <N> [options]\n\n" +
2518
+ "Pick option N of the selection menu a needs_input agent is parked on.\n" +
2519
+ "Re-parses the live menu (the same ❯-cursor detection `ay ls` uses), computes\n" +
2520
+ "how far the cursor must move, and sends that many Down/Up keys + Enter — so\n" +
2521
+ "it's robust to a pre-highlighted default (never assumes the cursor starts at 1)\n" +
2522
+ "and doesn't rely on numeric hotkeys (arrow-driven menus ignore them).\n\n" +
2523
+ "Examples:\n" +
2524
+ " ay select 1234 2 # choose option 2\n" +
2525
+ " ay select 1234 2 --wait # …and block until the menu clears",
2526
+ )
2527
+ .option("pace", { type: "number", default: KEY_PACE_MS, description: "ms between keystrokes" })
2528
+ .option("wait", {
2529
+ type: "boolean",
2530
+ default: false,
2531
+ description: "Block until the agent leaves needs_input (or --timeout)",
2532
+ })
2533
+ .option("timeout", { type: "number", default: 10, description: "Seconds to wait with --wait" })
2534
+ .option("all", { type: "boolean", default: false, description: "Include exited agents" })
2535
+ .option("latest", { type: "boolean", default: false, description: "Use most recent match" })
2536
+ .option("cwd", { type: "string", description: "Restrict to agents under this dir" })
2537
+ .option("force", {
2538
+ type: "boolean",
2539
+ default: false,
2540
+ description: "Skip the recency/self-send guard (also: AGENT_YES_FORCE_SEND=1)",
2541
+ })
2542
+ .help(false)
2543
+ .version(false)
2544
+ .exitProcess(false);
2545
+
2546
+ const argv = await y.parseAsync();
2547
+ const keyword = argv._[0] !== undefined ? String(argv._[0]) : undefined;
2548
+ const n = Number(argv._[1]);
2549
+ if (!keyword || !Number.isInteger(n) || n < 1) {
2550
+ throw new Error(
2551
+ "usage: ay select <keyword> <N> (N = the 1-based option number to choose)",
2552
+ );
2553
+ }
2554
+
2555
+ const opts: CommonOpts = {
2556
+ all: argv.all,
2557
+ active: false,
2558
+ json: false,
2559
+ latest: argv.latest,
2560
+ cwdScope: typeof argv.cwd === "string" ? path.resolve(argv.cwd) : null,
2561
+ };
2562
+ const record = await resolveWritableAgent(keyword, opts);
2563
+ if (!record.log_file) {
2564
+ throw new Error(`pid ${record.pid}: no log_file recorded — can't read the menu to select from.`);
2565
+ }
2566
+ const force = Boolean(argv.force) || process.env.AGENT_YES_FORCE_SEND === "1";
2567
+ await enforceSendGuards(record, force);
2568
+
2569
+ const menu = await extractMenu(record.log_file, record.cli);
2570
+ if (!menu) {
2571
+ throw new Error(
2572
+ `pid ${record.pid} (${record.cli}) is not parked on a selection menu (not needs_input).\n Check with: ay status ${record.pid}`,
2573
+ );
2574
+ }
2575
+ if (menu.options.length > 0 && !menu.options.includes(n)) {
2576
+ throw new Error(
2577
+ `option ${n} is out of range — this menu offers ${menu.options.join(", ")}.`,
2578
+ );
2579
+ }
2580
+
2581
+ // Move the cursor from where it sits to option N, then confirm. Delta from the
2582
+ // PARSED cursor position (not a blind "N-1 downs") so a non-first default works.
2583
+ const keyNames = menuSelectKeys(menu.cursor, n);
2584
+ const byteSeqs = keyNames.map((k) => controlCodeFromName(k));
2585
+ await writeKeysPaced(record.fifo_file!, byteSeqs, Math.max(0, argv.pace));
2586
+
2587
+ const delta = n - menu.cursor;
2588
+ const moved = delta === 0 ? "cursor already there" : `${Math.abs(delta)}× ${delta > 0 ? "down" : "up"}`;
2589
+ process.stdout.write(
2590
+ `pid ${record.pid} (${record.cli}): selected option ${n} (${moved} + enter)\n`,
2591
+ );
2592
+
2593
+ if (argv.wait) {
2594
+ const ok = await waitForNeedsInputClear(record, Math.max(1, argv.timeout) * 1000);
2595
+ process.stdout.write(
2596
+ ok
2597
+ ? ` menu cleared — selection accepted.\n`
2598
+ : ` still needs_input after ${argv.timeout}s — re-check with 'ay status ${record.pid}'.\n`,
2599
+ );
2600
+ return ok ? 0 : 1;
2601
+ }
2602
+ return 0;
2603
+ }
2604
+
2358
2605
  /// CLIs that ignore a single Ctrl+C and need a more specific shutdown signal.
2359
2606
  /// Users hit this every time they try `ay send <pid> "" --code=ctrl-c` and
2360
2607
  /// see no effect — print a one-liner pointing them at `ay stop`.
@@ -2406,6 +2653,36 @@ export function controlCodeFromName(name: string): string {
2406
2653
  return "\x1c";
2407
2654
  case "tab":
2408
2655
  return "\t";
2656
+ // Navigation / editing keys — the ANSI/xterm sequences a TUI reads as cursor
2657
+ // moves. Added for `ay key` / `ay select` so a menu can be driven from a
2658
+ // parent agent (up/down + enter picks an option) the same way a human's
2659
+ // arrow keys do in the web terminal.
2660
+ case "up":
2661
+ return "\x1b[A";
2662
+ case "down":
2663
+ return "\x1b[B";
2664
+ case "right":
2665
+ return "\x1b[C";
2666
+ case "left":
2667
+ return "\x1b[D";
2668
+ case "home":
2669
+ return "\x1b[H";
2670
+ case "end":
2671
+ return "\x1b[F";
2672
+ case "pageup":
2673
+ case "pgup":
2674
+ return "\x1b[5~";
2675
+ case "pagedown":
2676
+ case "pgdn":
2677
+ return "\x1b[6~";
2678
+ case "space":
2679
+ return " ";
2680
+ case "backspace":
2681
+ case "bs":
2682
+ return "\x7f";
2683
+ case "delete":
2684
+ case "del":
2685
+ return "\x1b[3~";
2409
2686
  case "none":
2410
2687
  case "":
2411
2688
  return "";
@@ -2413,7 +2690,7 @@ export function controlCodeFromName(name: string): string {
2413
2690
  // raw:0xNN form
2414
2691
  const m = /^raw:0x([0-9a-f]+)$/i.exec(name);
2415
2692
  if (m) return String.fromCharCode(parseInt(m[1]!, 16));
2416
- throw new Error(`unknown --code=${name}`);
2693
+ throw new Error(`unknown key/code: ${name}`);
2417
2694
  }
2418
2695
  }
2419
2696
 
@@ -4,6 +4,9 @@ import { homedir, tmpdir } from "os";
4
4
  import path from "path";
5
5
  import {
6
6
  expandTilde,
7
+ getMaxAgents,
8
+ getMinFreeMb,
9
+ getSpawnWaitMs,
7
10
  getProvisionAllowlist,
8
11
  getProvisionRoot,
9
12
  getSpawnHook,
@@ -28,6 +31,9 @@ describe("workspaceConfig", () => {
28
31
  delete process.env.CODEHOST_WS_ROOT;
29
32
  delete process.env.CODEHOST_PROVISION_ALLOWLIST;
30
33
  delete process.env.AGENT_YES_SPAWN_HOOK;
34
+ delete process.env.AGENT_YES_MAX_AGENTS;
35
+ delete process.env.AGENT_YES_MIN_FREE_MB;
36
+ delete process.env.AGENT_YES_SPAWN_WAIT_MS;
31
37
  rmSync(tmp, { recursive: true, force: true });
32
38
  });
33
39
 
@@ -146,6 +152,85 @@ describe("workspaceConfig", () => {
146
152
  });
147
153
  });
148
154
 
155
+ describe("getMaxAgents", () => {
156
+ it("is undefined (unlimited) when neither env nor config is set", () => {
157
+ expect(getMaxAgents()).toBeUndefined();
158
+ });
159
+
160
+ it("reads a positive integer from config", () => {
161
+ writeConfig({ maxAgents: 8 });
162
+ expect(getMaxAgents()).toBe(8);
163
+ });
164
+
165
+ it("env AGENT_YES_MAX_AGENTS overrides config", () => {
166
+ writeConfig({ maxAgents: 8 });
167
+ process.env.AGENT_YES_MAX_AGENTS = "3";
168
+ expect(getMaxAgents()).toBe(3);
169
+ });
170
+
171
+ it("floors a fractional value", () => {
172
+ process.env.AGENT_YES_MAX_AGENTS = "4.9";
173
+ expect(getMaxAgents()).toBe(4);
174
+ });
175
+
176
+ it("treats 0, negative, and garbage as unlimited (undefined)", () => {
177
+ writeConfig({ maxAgents: 0 });
178
+ expect(getMaxAgents()).toBeUndefined();
179
+ process.env.AGENT_YES_MAX_AGENTS = "-5";
180
+ expect(getMaxAgents()).toBeUndefined();
181
+ process.env.AGENT_YES_MAX_AGENTS = "lots";
182
+ expect(getMaxAgents()).toBeUndefined();
183
+ });
184
+
185
+ it("treats a fractional value < 1 as unlimited, not a 0 hard-cap", () => {
186
+ // Regression: 0.5 must NOT floor to 0 (which would reject every spawn).
187
+ process.env.AGENT_YES_MAX_AGENTS = "0.5";
188
+ expect(getMaxAgents()).toBeUndefined();
189
+ });
190
+ });
191
+
192
+ describe("getMinFreeMb", () => {
193
+ it("is undefined (no floor) when unset", () => {
194
+ expect(getMinFreeMb()).toBeUndefined();
195
+ });
196
+
197
+ it("reads config and lets env override", () => {
198
+ writeConfig({ minFreeMb: 1024 });
199
+ expect(getMinFreeMb()).toBe(1024);
200
+ process.env.AGENT_YES_MIN_FREE_MB = "2048";
201
+ expect(getMinFreeMb()).toBe(2048);
202
+ });
203
+
204
+ it("treats non-positive/garbage/sub-1 as no floor", () => {
205
+ process.env.AGENT_YES_MIN_FREE_MB = "0";
206
+ expect(getMinFreeMb()).toBeUndefined();
207
+ process.env.AGENT_YES_MIN_FREE_MB = "nope";
208
+ expect(getMinFreeMb()).toBeUndefined();
209
+ process.env.AGENT_YES_MIN_FREE_MB = "0.5";
210
+ expect(getMinFreeMb()).toBeUndefined();
211
+ });
212
+ });
213
+
214
+ describe("getSpawnWaitMs", () => {
215
+ it("defaults to 10 minutes when unset", () => {
216
+ expect(getSpawnWaitMs()).toBe(600_000);
217
+ });
218
+
219
+ it("reads config and lets env override; allows 0 (don't wait)", () => {
220
+ writeConfig({ spawnWaitMs: 5000 });
221
+ expect(getSpawnWaitMs()).toBe(5000);
222
+ process.env.AGENT_YES_SPAWN_WAIT_MS = "0";
223
+ expect(getSpawnWaitMs()).toBe(0);
224
+ });
225
+
226
+ it("falls back to the default on negative/garbage", () => {
227
+ process.env.AGENT_YES_SPAWN_WAIT_MS = "-1";
228
+ expect(getSpawnWaitMs()).toBe(600_000);
229
+ process.env.AGENT_YES_SPAWN_WAIT_MS = "soon";
230
+ expect(getSpawnWaitMs()).toBe(600_000);
231
+ });
232
+ });
233
+
149
234
  describe("getSpawnHook / hasSpawnHook", () => {
150
235
  const isPosix = process.platform !== "win32";
151
236
 
@@ -25,6 +25,24 @@ interface Config {
25
25
  * arbitrary local code that runs on every spawn.
26
26
  */
27
27
  spawnHook?: string;
28
+ /**
29
+ * Max number of concurrently-live agents the daemon will admit via
30
+ * `/api/spawn`. `0`/unset = unlimited (current behavior). See {@link getMaxAgents}.
31
+ */
32
+ maxAgents?: number;
33
+ /**
34
+ * Refuse a new spawn when system MemAvailable is below this many MB — a memory
35
+ * floor so a burst of spawns can't drive the host into the OOM-killer. `0`/unset
36
+ * = no floor. See {@link getMinFreeMb}.
37
+ */
38
+ minFreeMb?: number;
39
+ /**
40
+ * How long (ms) a CLI spawn will block-and-wait for capacity (φ-backoff) before
41
+ * failing open and proceeding anyway. Prevents a burst of recursive `ay <cli>`
42
+ * spawns from storming the host while never permanently deadlocking a workflow.
43
+ * Unset = default 10 min. See {@link getSpawnWaitMs}.
44
+ */
45
+ spawnWaitMs?: number;
28
46
  }
29
47
 
30
48
  function configPath(): string {
@@ -131,6 +149,52 @@ export function hasSpawnHook(): boolean {
131
149
  return getSpawnHook() !== null;
132
150
  }
133
151
 
152
+ /**
153
+ * Cap on concurrently-live agents admitted via `/api/spawn`. Env
154
+ * `AGENT_YES_MAX_AGENTS` overrides the config `maxAgents`. A non-positive,
155
+ * missing, or unparseable value means **unlimited** (returns undefined), which
156
+ * preserves the historical no-cap behavior. Exists to stop an unbounded fan-out
157
+ * of agents from exhausting host RAM and tripping the OOM-killer.
158
+ */
159
+ export function getMaxAgents(): number | undefined {
160
+ const raw = process.env.AGENT_YES_MAX_AGENTS?.trim();
161
+ const n = raw !== undefined && raw !== "" ? Number(raw) : readConfig().maxAgents;
162
+ // Floor BEFORE the >0 check: a fractional 0<n<1 would otherwise floor to 0 and
163
+ // turn "invalid/unlimited" into "reject every spawn" (live >= 0 always true).
164
+ const v = typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : NaN;
165
+ return v > 0 ? v : undefined;
166
+ }
167
+
168
+ /**
169
+ * Minimum system MemAvailable (MB) required to admit a new spawn. Env
170
+ * `AGENT_YES_MIN_FREE_MB` overrides config `minFreeMb`. Non-positive/unset =
171
+ * no floor (undefined). Complements {@link getMaxAgents}: a count cap alone
172
+ * can't stop OOM when individual agents are large, so we also refuse to spawn
173
+ * when free memory is already low.
174
+ */
175
+ export function getMinFreeMb(): number | undefined {
176
+ const raw = process.env.AGENT_YES_MIN_FREE_MB?.trim();
177
+ const n = raw !== undefined && raw !== "" ? Number(raw) : readConfig().minFreeMb;
178
+ // Floor before the >0 check (see getMaxAgents) so a fractional value can't
179
+ // collapse to a meaningless 0 floor.
180
+ const v = typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : NaN;
181
+ return v > 0 ? v : undefined;
182
+ }
183
+
184
+ /**
185
+ * Max time (ms) a CLI spawn blocks waiting for capacity before failing open.
186
+ * Env `AGENT_YES_SPAWN_WAIT_MS` overrides config `spawnWaitMs`. A non-negative
187
+ * finite value is used as-is (0 = don't wait, check once then proceed); anything
188
+ * missing/garbage falls back to the 10-minute default. Bounded fail-open is
189
+ * deliberate: recursive spawns must never deadlock permanently on each other.
190
+ */
191
+ export function getSpawnWaitMs(): number {
192
+ const DEFAULT = 600_000;
193
+ const raw = process.env.AGENT_YES_SPAWN_WAIT_MS?.trim();
194
+ const n = raw !== undefined && raw !== "" ? Number(raw) : readConfig().spawnWaitMs;
195
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT;
196
+ }
197
+
134
198
  /** Persist the workspace root, tilde-expanded and resolved to an absolute path. */
135
199
  export function setWorkspaceRoot(dir: string): string {
136
200
  const abs = path.resolve(expandTilde(dir));
@@ -1,8 +0,0 @@
1
- import "./ts-Bjfd09LQ.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-DiNfz39j.js";
4
- import "./pidStore-BfoBWUjc.js";
5
- import "./globalPidIndex-DlmmJlO8.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DAf9N7Wn.js";
7
-
8
- export { SUPPORTED_CLIS };
@@ -1,9 +0,0 @@
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-DBCvpp3B.js";
7
- import { C as restartHintLines, D as writeToIpc, E as stopTipForCli, S as resolveResumeArgs, T as snapshotStatus, _ as readPtysize, a as cursorAbs, b as resolveOne, c as extractTaskCounts, d as isExitRequest, f as isPidAlive, g as readNotes, h as matchKeyword, i as controlCodeFromName, l as finalizedLines, m as listRecords, n as READ_PAGE_DEFAULT, o as deriveLiveStatus, p as isSubcommand, r as cmdHelp, s as extractNeedsInput, t as GRACEFUL_EXIT_COMMANDS, u as isAgentStuck, v as renderRawLog, w as runSubcommand, x as resolveReadWindow, y as renderRawLogLines } from "./subcommands-XvqBWxtm.js";
8
-
9
- export { cmdHelp, isSubcommand, runSubcommand };