agent-yes 1.297.0 → 1.298.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 (31) hide show
  1. package/dist/{SUPPORTED_CLIS-ByHMsXAS.js → SUPPORTED_CLIS-BuCUpIsD.js} +3 -3
  2. package/dist/{SUPPORTED_CLIS-BFmQL5we.js → SUPPORTED_CLIS-CghmN9y4.js} +2 -2
  3. package/dist/{agentShare-5ZOUUzqH.js → agentShare-C2z05Xu1.js} +2 -2
  4. package/dist/{callback-B5gNxIT_.js → callback-Blg3DkI3.js} +2 -2
  5. package/dist/{callback-DP-7Y8py.js → callback-DULGSaDT.js} +3 -3
  6. package/dist/cli.js +5 -5
  7. package/dist/{cmdPs-CROJ_bxk.js → cmdPs-CNue6rFE.js} +2 -2
  8. package/dist/index.js +2 -2
  9. package/dist/{notifyDaemon-849g16zO.js → notifyDaemon-BPpVmmj0.js} +2 -2
  10. package/dist/{parentPingLoop-EBXvSmoB.js → parentPingLoop-BPcqwhCS.js} +2 -2
  11. package/dist/{rustBinary-BN5vA22v.js → rustBinary-B_KR43x9.js} +2 -2
  12. package/dist/{rustBinary-DC-BbqOQ.js → rustBinary-D59v2mmy.js} +2 -2
  13. package/dist/{schedule-BZReJzie.js → schedule-2xXpYxCv.js} +4 -4
  14. package/dist/{serve-GoY2ofRT.js → serve-cvUh5Oab.js} +18 -18
  15. package/dist/{setup-DwQ-pEQQ.js → setup-Ba2SPIhs.js} +2 -2
  16. package/dist/{subcommands-BFcfYykF.js → subcommands-DoKUSvaF.js} +577 -49
  17. package/dist/{subcommands-CY_xRQwx.js → subcommands-btyolh0m.js} +1 -1
  18. package/dist/{terminal-Cdx9yEVr.js → terminal-DOY_IN5y.js} +2 -2
  19. package/dist/{trayApp-CvBKYwE0.js → trayApp-7pq0tDGE.js} +2 -2
  20. package/dist/trayApp-DAs4YVeV.js +5 -0
  21. package/dist/{ts-BGei8qPL.js → ts-Cet9EhtO.js} +3 -3
  22. package/dist/{versionChecker-DRTopFkA.js → versionChecker-BtYHFqjF.js} +2 -2
  23. package/dist/{widget-a48Lqs0Z.js → widget-DKt2rH9F.js} +3 -3
  24. package/dist/{ws-BuLkRFmu.js → ws-Bs9AVLZR.js} +2 -2
  25. package/dist/{ws-DeDxbSUZ.js → ws-DgZVBJje.js} +2 -2
  26. package/package.json +1 -1
  27. package/ts/subcommands.ts +660 -31
  28. package/ts/untilMatch.bun.spec.ts +173 -0
  29. package/ts/untilMatch.spec.ts +173 -0
  30. package/ts/untilMatch.ts +230 -0
  31. package/dist/trayApp-Dxs6p7R2.js +0 -5
@@ -317,6 +317,140 @@ function toEvent(a, ts) {
317
317
  };
318
318
  }
319
319
 
320
+ //#endregion
321
+ //#region ts/untilMatch.ts
322
+ /**
323
+ * Compile an `--until` spec into a line predicate.
324
+ *
325
+ * Throws on an empty pattern (a match-everything predicate would return on the
326
+ * agent's next line of output, which is never what the caller meant) and on an
327
+ * invalid regex, so the failure surfaces at argument-parse time rather than as
328
+ * an instant bogus exit 0.
329
+ */
330
+ function compileUntil(spec) {
331
+ const { pattern, regex, ignoreCase } = spec;
332
+ if (pattern.length === 0) throw new Error("--until requires a non-empty pattern");
333
+ if (!regex) {
334
+ if (!ignoreCase) return (line) => line.includes(pattern);
335
+ const needle = pattern.toLowerCase();
336
+ return (line) => line.toLowerCase().includes(needle);
337
+ }
338
+ let re;
339
+ try {
340
+ re = new RegExp(pattern, ignoreCase ? "i" : "");
341
+ } catch (e) {
342
+ throw new Error(`invalid --until regex: ${e.message}`);
343
+ }
344
+ return (line) => re.test(line);
345
+ }
346
+ function makeTally(match, needed = 1) {
347
+ if (!Number.isInteger(needed) || needed < 1) throw new Error(`--count must be a positive integer (got ${needed})`);
348
+ let hits = 0;
349
+ let last = null;
350
+ const tally = {
351
+ get hits() {
352
+ return hits;
353
+ },
354
+ needed,
355
+ get last() {
356
+ return last;
357
+ },
358
+ get done() {
359
+ return hits >= needed;
360
+ },
361
+ feed(line) {
362
+ if (hits < needed && match(line)) {
363
+ hits++;
364
+ last = line;
365
+ }
366
+ return hits >= needed;
367
+ },
368
+ feedAll(lines) {
369
+ for (const line of lines) if (tally.feed(line)) return true;
370
+ return hits >= needed;
371
+ }
372
+ };
373
+ return tally;
374
+ }
375
+ /**
376
+ * The lines strictly after the LAST occurrence of `anchor` — the tail of a log
377
+ * that a follower has not seen yet, given the newest line it had seen.
378
+ *
379
+ * Needed because an exiting agent's log is not the file the follower was reading:
380
+ * the runtime renders the scrollback to a new path and unlinks the raw log, so the
381
+ * final lines must be recovered from a file with unknown overlap. Aligning on the
382
+ * newest already-seen line — from the END, so a repeated shell prompt resolves to
383
+ * the most recent one — bounds that overlap without byte offsets.
384
+ *
385
+ * Returns null when there is no anchor or it isn't present: the caller must then
386
+ * skip the drain rather than fall back to scanning everything, which could report
387
+ * a match printed before the wait even began.
388
+ */
389
+ function linesAfterAnchor(lines, anchor) {
390
+ if (anchor === null) return null;
391
+ const at = lines.lastIndexOf(anchor);
392
+ if (at < 0) return null;
393
+ return lines.slice(at + 1);
394
+ }
395
+ function makeJudge(tally, failTally) {
396
+ let outcome = null;
397
+ let matched = null;
398
+ const judge = {
399
+ get settled() {
400
+ return outcome !== null;
401
+ },
402
+ get outcome() {
403
+ return outcome;
404
+ },
405
+ get matched() {
406
+ return matched;
407
+ },
408
+ test(line) {
409
+ if (outcome !== null) return true;
410
+ if (failTally?.feed(line)) {
411
+ outcome = "failed";
412
+ matched = failTally.last;
413
+ return true;
414
+ }
415
+ if (tally.feed(line)) {
416
+ outcome = "match";
417
+ matched = tally.last;
418
+ return true;
419
+ }
420
+ return false;
421
+ },
422
+ testAll(lines) {
423
+ for (const line of lines) if (judge.test(line)) return true;
424
+ return outcome !== null;
425
+ }
426
+ };
427
+ return judge;
428
+ }
429
+ /**
430
+ * Exit code for an `--until` run. Mirrors the code family already established by
431
+ * `ay status --wait` / `ay result --wait`, so an orchestrator can branch on the
432
+ * status without parsing stdout:
433
+ *
434
+ * 0 the pattern appeared
435
+ * 1 the agent exited without ever printing it (it's done; it never will)
436
+ * 2 `--timeout` elapsed, or we were signalled away, with no match
437
+ * 3 a `--fail-on` pattern appeared first — the thing went wrong, and waiting
438
+ * out the timeout would only delay finding out
439
+ *
440
+ * `stopped` (Ctrl-C / SIGTERM, e.g. an outer `timeout(1)`) deliberately shares
441
+ * exit 2 with a timeout rather than the plain-follow 0: for a caller using this
442
+ * as a predicate, "I was killed" must never read as "the pattern matched".
443
+ */
444
+ function untilExitCode(outcome) {
445
+ switch (outcome) {
446
+ case "match": return 0;
447
+ case "exited": return 1;
448
+ case "timeout":
449
+ case "stopped": return 2;
450
+ case "failed": return 3;
451
+ }
452
+ }
453
+
320
454
  //#endregion
321
455
  //#region ts/resultEnvelope.ts
322
456
  /**
@@ -1546,7 +1680,7 @@ async function runSubcommand(argv) {
1546
1680
  switch (sub) {
1547
1681
  case "ls":
1548
1682
  case "list": return await cmdLs(rest);
1549
- case "ps": return await (await import("./cmdPs-CROJ_bxk.js")).cmdPs(rest);
1683
+ case "ps": return await (await import("./cmdPs-CNue6rFE.js")).cmdPs(rest);
1550
1684
  case "status": return await cmdStatus(rest);
1551
1685
  case "whoami": return await cmdWhoami(rest);
1552
1686
  case "result": return await cmdResult(rest);
@@ -1593,35 +1727,35 @@ async function runSubcommand(argv) {
1593
1727
  return cmdCh(rest);
1594
1728
  }
1595
1729
  case "term": {
1596
- const { cmdTerm } = await import("./terminal-Cdx9yEVr.js");
1730
+ const { cmdTerm } = await import("./terminal-DOY_IN5y.js");
1597
1731
  return cmdTerm(rest);
1598
1732
  }
1599
1733
  case "widget": {
1600
- const { cmdWidget } = await import("./widget-a48Lqs0Z.js");
1734
+ const { cmdWidget } = await import("./widget-DKt2rH9F.js");
1601
1735
  return cmdWidget(rest);
1602
1736
  }
1603
1737
  case "mint": {
1604
- const { cmdMint } = await import("./widget-a48Lqs0Z.js");
1738
+ const { cmdMint } = await import("./widget-DKt2rH9F.js");
1605
1739
  return cmdMint(rest);
1606
1740
  }
1607
1741
  case "serve": {
1608
- const { cmdServe } = await import("./serve-GoY2ofRT.js");
1742
+ const { cmdServe } = await import("./serve-cvUh5Oab.js");
1609
1743
  return cmdServe(rest);
1610
1744
  }
1611
1745
  case "tray": {
1612
- const { cmdTray } = await import("./trayApp-Dxs6p7R2.js");
1746
+ const { cmdTray } = await import("./trayApp-DAs4YVeV.js");
1613
1747
  return cmdTray(rest);
1614
1748
  }
1615
1749
  case "setup": {
1616
- const { cmdSetup } = await import("./setup-DwQ-pEQQ.js");
1750
+ const { cmdSetup } = await import("./setup-Ba2SPIhs.js");
1617
1751
  return cmdSetup(rest);
1618
1752
  }
1619
1753
  case "ws": {
1620
- const { cmdWs } = await import("./ws-BuLkRFmu.js");
1754
+ const { cmdWs } = await import("./ws-Bs9AVLZR.js");
1621
1755
  return cmdWs(rest);
1622
1756
  }
1623
1757
  case "schedule": {
1624
- const { cmdSchedule } = await import("./schedule-BZReJzie.js");
1758
+ const { cmdSchedule } = await import("./schedule-2xXpYxCv.js");
1625
1759
  return cmdSchedule(rest);
1626
1760
  }
1627
1761
  case "remote": {
@@ -1633,7 +1767,7 @@ async function runSubcommand(argv) {
1633
1767
  return cmdExpose(rest);
1634
1768
  }
1635
1769
  case "callback": {
1636
- const { cmdCallback } = await import("./callback-B5gNxIT_.js");
1770
+ const { cmdCallback } = await import("./callback-Blg3DkI3.js");
1637
1771
  return cmdCallback(rest);
1638
1772
  }
1639
1773
  case "reap":
@@ -1644,7 +1778,7 @@ async function runSubcommand(argv) {
1644
1778
  return await cmdDsh(rest);
1645
1779
  }
1646
1780
  case "gc": {
1647
- const { gcOldBinaryDirs } = await import("./rustBinary-DC-BbqOQ.js");
1781
+ const { gcOldBinaryDirs } = await import("./rustBinary-D59v2mmy.js");
1648
1782
  const { gcLogs } = await import("./globalPidIndex-DC93JhHQ.js");
1649
1783
  const bins = gcOldBinaryDirs();
1650
1784
  const logs = await gcLogs();
@@ -1705,7 +1839,7 @@ async function cmdHelp(managerCommands = true) {
1705
1839
  const wsLines = managerCommands ? " ay ws ls [--status] list <owner>/<repo>/tree/<branch> workspaces\n ay ws new <owner>/<repo>[@branch] clone/refresh a workspace (ay ws help for more)\n" : ``;
1706
1840
  const self = process.env.AGENT_YES_PID ? await resolveSender() : null;
1707
1841
  const agentSection = self ? await buildAgentContextSection(self) : "";
1708
- process.stdout.write(agentSection + "ay - agent-yes CLI\n\nManagement:\n ay ls [keyword] list running agents\n ay ps [keyword] per-agent CPU/RSS, rolled up over each\n agent's whole process tree, + box vitals\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 hist [-n 6] [--all] [--json] past agent conversations (claude/codex\n transcripts, incl. exited sessions);\n this cwd unless --all\n ay send <keyword> <msg> send a message (keyword '.' = agent in this cwd)\n ay msgs [keyword] [--in|--out] inter-agent message log (sent + received)\n ay ch mk|join|send|read|tail <topic> local-first E2E channels: AI ↔ humans on a topic (ay ch help)\n ay term embed <pid> <script> to embed a live read-only agent terminal in a page (ay term help)\n ay widget ls | read selection|dom read an opted-in page widget's context (selection/DOM) (ay widget help)\n ay mint <target> --caps ... mint a scoped, short-TTL capability token (embeddable in a page)\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 notify watch --unread get notified when sub-agents finish/stuck/crash (writes to inbox)\n ay whoami [--json] (inside an agent) your own registry identity + reply address\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 ay gc reclaim old-version binary cache dirs + stale session logs\n ay dsh-legacy [args...] launch the DeepSeek Harness terminal client (dsh-tui)\n" + wsLines + "\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 expose <port> share localhost:<port> at https://<id>.agent-yes.com (private link)\n ay callback --expires 7d mint an embeddable message-me widget for one agent\n ay ls <token>@<host>:<port> connect inline (no alias needed)\n ay send <token>@<host>:<port>:<kw> <msg>\n\nRun an agent (naming a CLI is what launches one — bare 'ay' shows this help):\n ay <claude|codex|...> [options] -- [prompt]\n ay claude -- \"fix the bug in auth.ts\"\n cy [options] -- [prompt] shortcut for 'ay claude' (bare 'cy' starts claude)\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");
1842
+ process.stdout.write(agentSection + "ay - agent-yes CLI\n\nManagement:\n ay ls [keyword] list running agents\n ay ps [keyword] per-agent CPU/RSS, rolled up over each\n agent's whole process tree, + box vitals\n ay tail [-f] [-n N] <keyword> last N lines (96), -f to follow\n ay tail <keyword> --until TEXT block until TEXT is printed, then exit 0\n [--timeout 10m] [-q] [--regex] (1 = agent exited without it, 2 = no match,\n [--fail-on TEXT] [--count N] 3 = --fail-on hit first)\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 hist [-n 6] [--all] [--json] past agent conversations (claude/codex\n transcripts, incl. exited sessions);\n this cwd unless --all\n ay send <keyword> <msg> send a message (keyword '.' = agent in this cwd)\n ay msgs [keyword] [--in|--out] inter-agent message log (sent + received)\n ay ch mk|join|send|read|tail <topic> local-first E2E channels: AI ↔ humans on a topic (ay ch help)\n ay term embed <pid> <script> to embed a live read-only agent terminal in a page (ay term help)\n ay widget ls | read selection|dom read an opted-in page widget's context (selection/DOM) (ay widget help)\n ay mint <target> --caps ... mint a scoped, short-TTL capability token (embeddable in a page)\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 notify watch --unread get notified when sub-agents finish/stuck/crash (writes to inbox)\n ay whoami [--json] (inside an agent) your own registry identity + reply address\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 ay gc reclaim old-version binary cache dirs + stale session logs\n ay dsh-legacy [args...] launch the DeepSeek Harness terminal client (dsh-tui)\n" + wsLines + "\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 expose <port> share localhost:<port> at https://<id>.agent-yes.com (private link)\n ay callback --expires 7d mint an embeddable message-me widget for one agent\n ay ls <token>@<host>:<port> connect inline (no alias needed)\n ay send <token>@<host>:<port>:<kw> <msg>\n\nRun an agent (naming a CLI is what launches one — bare 'ay' shows this help):\n ay <claude|codex|...> [options] -- [prompt]\n ay claude -- \"fix the bug in auth.ts\"\n cy [options] -- [prompt] shortcut for 'ay claude' (bare 'cy' starts claude)\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");
1709
1843
  return 0;
1710
1844
  }
1711
1845
  function matchKeyword(record, keyword) {
@@ -1895,7 +2029,7 @@ async function runRemoteLs(remote, opts) {
1895
2029
  }
1896
2030
  return 0;
1897
2031
  }
1898
- async function runRemoteRead(remote, mode, follow, n, reconnectTimeoutMs = 12e4, _plain = false) {
2032
+ async function runRemoteRead(remote, mode, follow, n, reconnectTimeoutMs = 12e4, _plain = false, until) {
1899
2033
  const keyword = remote.keyword ?? "";
1900
2034
  if (!keyword) {
1901
2035
  process.stderr.write("remote tail/cat/head requires a keyword (e.g. token@host:port:keyword)\n");
@@ -1903,12 +2037,67 @@ async function runRemoteRead(remote, mode, follow, n, reconnectTimeoutMs = 12e4,
1903
2037
  }
1904
2038
  if (mode === "tail" && follow) {
1905
2039
  const ac = new AbortController();
2040
+ const judge = until ? makeJudge(until.tally, until.failTally) : null;
2041
+ let outcome = "stopped";
2042
+ const settle = (o) => {
2043
+ outcome = o;
2044
+ ac.abort();
2045
+ };
2046
+ const untilTimer = until && until.timeoutMs !== null ? setTimeout(() => settle("timeout"), until.timeoutMs) : null;
2047
+ let remoteDeadSince = null;
2048
+ let sawRemoteLive = false;
2049
+ const statusPoll = until ? setInterval(() => {
2050
+ (async () => {
2051
+ const liveness = await remoteAgentLiveness(remote, keyword);
2052
+ if (liveness === null) return;
2053
+ if (liveness === "live") {
2054
+ sawRemoteLive = true;
2055
+ remoteDeadSince = null;
2056
+ return;
2057
+ }
2058
+ if (!sawRemoteLive) return;
2059
+ if (remoteDeadSince === null) {
2060
+ remoteDeadSince = Date.now();
2061
+ return;
2062
+ }
2063
+ if (!judge?.settled) settle("exited");
2064
+ })();
2065
+ }, UNTIL_REMOTE_STATUS_POLL_MS) : null;
2066
+ statusPoll?.unref?.();
2067
+ /** Reassembles frames into lines; `flush` tests a trailing partial at the end. */
2068
+ let lineBuf = "";
2069
+ const consume = (text, test) => {
2070
+ if (!until?.quiet) {
2071
+ process.stdout.write(text);
2072
+ if (!text.endsWith("\n")) process.stdout.write("\n");
2073
+ }
2074
+ if (!judge) return;
2075
+ lineBuf += text;
2076
+ const lines = lineBuf.split("\n");
2077
+ lineBuf = lines.pop() ?? "";
2078
+ if (!test) return;
2079
+ if (judge.testAll(lines.map((l) => l.trimEnd()))) settle(judge.outcome ?? "match");
2080
+ };
2081
+ const flushLineBuf = () => {
2082
+ if (!judge || lineBuf.length === 0) return;
2083
+ const last = lineBuf.trimEnd();
2084
+ lineBuf = "";
2085
+ if (last && judge.test(last)) settle(judge.outcome ?? "match");
2086
+ };
2087
+ const finishUntil = () => {
2088
+ if (untilTimer) clearTimeout(untilTimer);
2089
+ if (statusPoll) clearInterval(statusPoll);
2090
+ return reportUntil(outcome, judge?.matched ?? null, until.quiet, until.failPattern, {
2091
+ hits: until.tally.hits,
2092
+ needed: until.tally.needed
2093
+ });
2094
+ };
1906
2095
  const disposeSignals = installStreamSignals(() => ac.abort());
1907
2096
  ac.signal.addEventListener("abort", disposeSignals, { once: true });
1908
2097
  const deadline = Date.now() + reconnectTimeoutMs;
1909
2098
  let delay = 1e3;
1910
2099
  let attempt = 0;
1911
- process.stderr.write(`[remote ${remote.label} ${keyword}]\nfollowing... (Ctrl-C to stop, timeout: ${Math.round(reconnectTimeoutMs / 1e3)}s)\n`);
2100
+ process.stderr.write(until ? `[remote ${remote.label} ${keyword}]\n${untilBanner(until)}` : `[remote ${remote.label} ${keyword}]\nfollowing... (Ctrl-C to stop, timeout: ${Math.round(reconnectTimeoutMs / 1e3)}s)\n`);
1912
2101
  while (!ac.signal.aborted) try {
1913
2102
  const res = await fetch(`${remote.url}/api/tail/${encodeURIComponent(keyword)}`, {
1914
2103
  headers: {
@@ -1924,12 +2113,16 @@ async function runRemoteRead(remote, mode, follow, n, reconnectTimeoutMs = 12e4,
1924
2113
  }
1925
2114
  throw new Error(`HTTP ${res.status}`);
1926
2115
  }
1927
- if (attempt > 0) process.stderr.write("remote: reconnected\n");
2116
+ if (attempt > 0) {
2117
+ process.stderr.write("remote: reconnected\n");
2118
+ if (until) process.stderr.write("remote: output during the gap was not tested for --until\n");
2119
+ }
1928
2120
  delay = 1e3;
1929
2121
  const reader = res.body.getReader();
1930
2122
  const dec = new TextDecoder();
1931
2123
  let buf = "";
1932
- while (true) {
2124
+ let firstFrame = true;
2125
+ while (!ac.signal.aborted) {
1933
2126
  const { done, value } = await reader.read();
1934
2127
  if (done) break;
1935
2128
  buf += dec.decode(value, { stream: true });
@@ -1939,16 +2132,39 @@ async function runRemoteRead(remote, mode, follow, n, reconnectTimeoutMs = 12e4,
1939
2132
  if (!line.startsWith("data: ")) continue;
1940
2133
  try {
1941
2134
  const text = JSON.parse(line.slice(6));
1942
- process.stdout.write(text);
1943
- if (!text.endsWith("\n")) process.stdout.write("\n");
2135
+ const isContext = firstFrame;
2136
+ firstFrame = false;
2137
+ consume(text, !isContext || Boolean(until?.matchBacklog));
2138
+ if (ac.signal.aborted) break;
1944
2139
  } catch {}
1945
2140
  }
2141
+ if (ac.signal.aborted) break;
2142
+ }
2143
+ if (until) {
2144
+ if (judge?.settled) return finishUntil();
2145
+ if (ac.signal.aborted) {
2146
+ flushLineBuf();
2147
+ return finishUntil();
2148
+ }
2149
+ flushLineBuf();
2150
+ if (!judge?.settled) outcome = "exited";
2151
+ return finishUntil();
1946
2152
  }
1947
2153
  break;
1948
2154
  } catch (e) {
1949
- if (e.name === "AbortError" || ac.signal.aborted) return 0;
2155
+ if (e.name === "AbortError" || ac.signal.aborted) {
2156
+ if (until) {
2157
+ flushLineBuf();
2158
+ return finishUntil();
2159
+ }
2160
+ return 0;
2161
+ }
1950
2162
  if (Date.now() >= deadline) {
1951
2163
  process.stderr.write(`remote: timeout after ${Math.round(reconnectTimeoutMs / 1e3)}s, giving up\n`);
2164
+ if (until) {
2165
+ outcome = "timeout";
2166
+ return finishUntil();
2167
+ }
1952
2168
  return 1;
1953
2169
  }
1954
2170
  process.stderr.write(`remote: disconnected (${e.message}), retrying in ${delay / 1e3}s…\n`);
@@ -1959,10 +2175,20 @@ async function runRemoteRead(remote, mode, follow, n, reconnectTimeoutMs = 12e4,
1959
2175
  reject(/* @__PURE__ */ new Error("abort"));
1960
2176
  });
1961
2177
  }).catch(() => {});
1962
- if (ac.signal.aborted) return 0;
2178
+ if (ac.signal.aborted) {
2179
+ if (until) {
2180
+ flushLineBuf();
2181
+ return finishUntil();
2182
+ }
2183
+ return 0;
2184
+ }
1963
2185
  delay = Math.min(delay * 2, 3e4);
1964
2186
  attempt++;
1965
2187
  }
2188
+ if (until) {
2189
+ flushLineBuf();
2190
+ return finishUntil();
2191
+ }
1966
2192
  return 0;
1967
2193
  }
1968
2194
  const params = new URLSearchParams({
@@ -2778,12 +3004,54 @@ function truncate(s, n) {
2778
3004
  if (s.length <= n) return s;
2779
3005
  return s.slice(0, n - 1) + "…";
2780
3006
  }
3007
+ /**
3008
+ * Parse a human duration (`30s`, `10m`) to ms, or null when it isn't one.
3009
+ *
3010
+ * `ms()`'s parse overload is typed to a template-literal `StringValue`, which a
3011
+ * CLI flag (a plain `string`) never satisfies, and it returns `undefined` — not a
3012
+ * number — for unparseable input. Both are handled here once so the call sites
3013
+ * just branch on null.
3014
+ */
3015
+ function parseDurationMs(value) {
3016
+ const parsed = ms(value);
3017
+ return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null;
3018
+ }
2781
3019
  async function cmdRead(rest, { mode }) {
2782
- const argv = await yargs(rest).usage("Usage: ay read/cat/tail/head <keyword> [options]\n\nPagination (static read; render the log once, window the rendered lines):\n --last N | --head N last / first N lines\n --range A:B lines A..B (1-indexed, inclusive)\n --before-line L [--limit N] the page of N lines ending just above line L").option("follow", {
3020
+ const argv = await yargs(rest).usage("Usage: ay read/cat/tail/head <keyword> [options]\n\nPagination (static read; render the log once, window the rendered lines):\n --last N | --head N last / first N lines\n --range A:B lines A..B (1-indexed, inclusive)\n --before-line L [--limit N] the page of N lines ending just above line L\n\nWait for output (follow with a predicate; exit 0 matched / 1 exited /\n2 no match / 3 --fail-on hit):\n --until TEXT [--timeout 10m] return as soon as TEXT is printed\n --fail-on TEXT give up early (exit 3) if TEXT shows up first\n --count N require N hits of --until (default 1)\n --regex | -i | -q regex / case-insensitive / only print the match").option("follow", {
2783
3021
  alias: "f",
2784
3022
  type: "boolean",
2785
3023
  default: false,
2786
3024
  description: "Follow log output (Ctrl-C to stop)"
3025
+ }).option("until", {
3026
+ type: "string",
3027
+ description: "Follow until this text appears in the output, then exit. Implies -f. Exit 0 matched, 1 agent exited without it, 2 --timeout/interrupted, 3 --fail-on hit first. Literal substring by default; matches only output arriving from now on."
3028
+ }).option("fail-on", {
3029
+ type: "string",
3030
+ description: "Give up with exit 3 if this text appears before --until does (e.g. --until 'all tests passed' --fail-on 'FAILED')"
3031
+ }).option("count", {
3032
+ type: "number",
3033
+ description: "Require this many --until hits before returning (default 1)"
3034
+ }).option("regex", {
3035
+ type: "boolean",
3036
+ default: false,
3037
+ description: "Treat --until as a regular expression"
3038
+ }).option("ignore-case", {
3039
+ alias: "i",
3040
+ type: "boolean",
3041
+ default: false,
3042
+ description: "Case-insensitive --until match"
3043
+ }).option("timeout", {
3044
+ type: "string",
3045
+ description: "Give up waiting for --until after this long (e.g. 30s, 10m)"
3046
+ }).option("match-backlog", {
3047
+ type: "boolean",
3048
+ default: false,
3049
+ description: "Also test the context window printed before following. Off by default: a hit from a PREVIOUS run would return instantly and wrongly."
3050
+ }).option("quiet", {
3051
+ alias: "q",
3052
+ type: "boolean",
3053
+ default: false,
3054
+ description: "With --until, print only the matching line (no streamed output)"
2787
3055
  }).option("n", {
2788
3056
  type: "number",
2789
3057
  description: "Number of lines (default: 96 for tail/head)"
@@ -2823,7 +3091,35 @@ async function cmdRead(rest, { mode }) {
2823
3091
  description: "Seconds before giving up reconnecting remote SSE (default: 120)"
2824
3092
  }).help(false).version(false).exitProcess(false).parseAsync();
2825
3093
  ensureEpipeExit();
2826
- const plain = Boolean(argv.plain) || !process.stdout.isTTY;
3094
+ const until = typeof argv.until === "string" ? argv.until : null;
3095
+ const plain = Boolean(argv.plain) || until !== null || !process.stdout.isTTY;
3096
+ const failOn = typeof argv["fail-on"] === "string" ? argv["fail-on"] : null;
3097
+ const untilOnlyFlags = [
3098
+ ["--regex", argv.regex],
3099
+ ["--ignore-case", argv["ignore-case"]],
3100
+ ["--match-backlog", argv["match-backlog"]],
3101
+ ["--timeout", typeof argv.timeout === "string" && argv.timeout.length > 0],
3102
+ ["--fail-on", failOn !== null],
3103
+ ["--count", argv.count !== void 0]
3104
+ ];
3105
+ if (until === null) {
3106
+ const stray = untilOnlyFlags.find(([, set]) => set);
3107
+ if (stray) throw new Error(`${stray[0]} requires --until <pattern>`);
3108
+ }
3109
+ const compileSpec = (pattern) => compileUntil({
3110
+ pattern,
3111
+ regex: argv.regex,
3112
+ ignoreCase: argv["ignore-case"]
3113
+ });
3114
+ const untilCount = argv.count === void 0 ? 1 : Number(argv.count);
3115
+ if (until !== null && (!Number.isInteger(untilCount) || untilCount < 1)) throw new Error(`--count must be a positive integer (got ${argv.count})`);
3116
+ const untilMatch = until !== null ? compileSpec(until) : null;
3117
+ const failMatch = failOn !== null ? compileSpec(failOn) : null;
3118
+ let untilTimeoutMs = null;
3119
+ if (typeof argv.timeout === "string" && argv.timeout.length > 0) {
3120
+ untilTimeoutMs = parseDurationMs(argv.timeout);
3121
+ if (untilTimeoutMs === null) throw new Error(`invalid --timeout value: ${argv.timeout}`);
3122
+ }
2827
3123
  const opts = {
2828
3124
  all: argv.all,
2829
3125
  active: false,
@@ -2837,9 +3133,20 @@ async function cmdRead(rest, { mode }) {
2837
3133
  const nFlag2 = argv.n;
2838
3134
  const n2 = nFlag2 !== void 0 && Number.isFinite(nFlag2) && nFlag2 > 0 ? Math.floor(nFlag2) : mode === "cat" ? 0 : 96;
2839
3135
  const reconnectTimeoutMs = (argv["reconnect-timeout"] ?? 120) * 1e3;
2840
- if (remote) return runRemoteRead(remote, mode, argv.follow, n2, reconnectTimeoutMs, plain);
3136
+ if (remote) {
3137
+ if (untilMatch === null) return runRemoteRead(remote, mode, argv.follow, n2, reconnectTimeoutMs, plain);
3138
+ return runRemoteRead(remote, mode, true, n2, reconnectTimeoutMs, plain, {
3139
+ tally: makeTally(untilMatch, untilCount),
3140
+ failTally: failMatch ? makeTally(failMatch, 1) : null,
3141
+ pattern: until,
3142
+ failPattern: failOn,
3143
+ quiet: Boolean(argv.quiet),
3144
+ timeoutMs: untilTimeoutMs,
3145
+ matchBacklog: Boolean(argv["match-backlog"])
3146
+ });
3147
+ }
2841
3148
  }
2842
- const follow = argv.follow;
3149
+ const follow = argv.follow || until !== null;
2843
3150
  const nFlag = argv.n;
2844
3151
  const n = nFlag !== void 0 && Number.isFinite(nFlag) && nFlag > 0 ? Math.floor(nFlag) : mode === "cat" ? 0 : 96;
2845
3152
  const record = await resolveOne(keyword, opts);
@@ -2866,10 +3173,31 @@ async function cmdRead(rest, { mode }) {
2866
3173
  rows: size?.rows
2867
3174
  });
2868
3175
  process.stderr.write(header + "\n");
2869
- process.stdout.write(rendered);
2870
- if (!rendered.endsWith("\n")) process.stdout.write("\n");
3176
+ if (!(untilMatch && argv.quiet)) {
3177
+ process.stdout.write(rendered);
3178
+ if (!rendered.endsWith("\n")) process.stdout.write("\n");
3179
+ }
3180
+ const tally = untilMatch ? makeTally(untilMatch, untilCount) : null;
3181
+ const failTally = failMatch ? makeTally(failMatch, 1) : null;
3182
+ if (tally && argv["match-backlog"]) {
3183
+ const lines = rendered.split("\n");
3184
+ if (failTally?.feedAll(lines)) return reportUntil("failed", failTally.last, Boolean(argv.quiet), failOn);
3185
+ if (tally.feedAll(lines)) return reportUntil("match", tally.last, Boolean(argv.quiet));
3186
+ }
2871
3187
  setInterval(() => void recordRead(reader.key, record.pid), 3e4).unref?.();
2872
- return plain ? followPlainLocal(logPath, buf, stats.size) : followRawLocal(logPath, buf, stats.size);
3188
+ return plain ? followPlainLocal(logPath, buf, stats.size, tally ? {
3189
+ tally,
3190
+ failTally,
3191
+ pattern: until,
3192
+ failPattern: failOn,
3193
+ quiet: Boolean(argv.quiet),
3194
+ timeoutMs: untilTimeoutMs,
3195
+ matchBacklog: Boolean(argv["match-backlog"]),
3196
+ pid: record.pid
3197
+ } : void 0, {
3198
+ cols: size?.cols,
3199
+ rows: size?.rows
3200
+ }) : followRawLocal(logPath, buf, stats.size);
2873
3201
  }
2874
3202
  const allLines = await renderRawLogLines(buf, {
2875
3203
  cols: size?.cols,
@@ -2936,9 +3264,12 @@ function installStreamSignals(stop) {
2936
3264
  * Coalescing file watcher: re-reads `logPath` on every change, hands each newly
2937
3265
  * appended byte range to `onChunk`, and never overlaps reads (a change that
2938
3266
  * arrives mid-read is serviced once the current read finishes). `startOffset`
2939
- * is where the already-emitted prefix ends. Resolves when `stop` is signalled.
3267
+ * is where the already-emitted prefix ends. Resolves when `stop` is signalled,
3268
+ * or when `signal` aborts (how `--until` stops itself on a match / timeout /
3269
+ * the agent's death), and returns the byte offset it stopped at — so a caller
3270
+ * can drain whatever landed after the last watch event.
2940
3271
  */
2941
- async function watchAppend(logPath, startOffset, onChunk, onStop) {
3272
+ async function watchAppend(logPath, startOffset, onChunk, onStop, signal) {
2942
3273
  const { watch } = await import("fs");
2943
3274
  let offset = startOffset;
2944
3275
  let reading = false;
@@ -2952,12 +3283,15 @@ async function watchAppend(logPath, startOffset, onChunk, onStop) {
2952
3283
  watcher.close();
2953
3284
  } catch {}
2954
3285
  dispose();
3286
+ signal?.removeEventListener("abort", finish);
2955
3287
  onStop();
2956
3288
  resolve();
2957
3289
  };
2958
3290
  const dispose = installStreamSignals(finish);
3291
+ if (signal) if (signal.aborted) queueMicrotask(finish);
3292
+ else signal.addEventListener("abort", finish);
2959
3293
  const pump = async () => {
2960
- if (reading) {
3294
+ if (reading || done) {
2961
3295
  pending = true;
2962
3296
  return;
2963
3297
  }
@@ -2970,6 +3304,7 @@ async function watchAppend(logPath, startOffset, onChunk, onStop) {
2970
3304
  } catch {
2971
3305
  break;
2972
3306
  }
3307
+ if (done) break;
2973
3308
  if (full.length > offset) {
2974
3309
  const chunk = full.slice(offset);
2975
3310
  offset = full.length;
@@ -2981,6 +3316,7 @@ async function watchAppend(logPath, startOffset, onChunk, onStop) {
2981
3316
  const watcher = watch(logPath, () => void pump());
2982
3317
  pump();
2983
3318
  });
3319
+ return offset;
2984
3320
  }
2985
3321
  /**
2986
3322
  * Default (interactive) follow: append each new byte range with ANSI/control
@@ -2997,6 +3333,22 @@ async function followRawLocal(logPath, buf, startOffset = buf.length) {
2997
3333
  }, () => {});
2998
3334
  return 0;
2999
3335
  }
3336
+ /**
3337
+ * `@xterm/headless`'s Terminal constructor, across runtimes.
3338
+ *
3339
+ * The package is CJS, so `await import()` resolves to `{ default: { Terminal } }`
3340
+ * under node while bun hoists the named exports — a plain
3341
+ * `const { Terminal } = await import(…)` is therefore `undefined` on node and
3342
+ * every render throws "Terminal is not a constructor". That is the runtime npm
3343
+ * users get from `dist/`, where it silently degraded the static render to the
3344
+ * ANSI-strip fallback and broke `ay tail -f` into a pipe outright.
3345
+ */
3346
+ async function loadXtermTerminal() {
3347
+ const mod = await import("@xterm/headless");
3348
+ const Terminal = mod.Terminal ?? mod.default?.Terminal;
3349
+ if (!Terminal) throw new Error("@xterm/headless: no Terminal export");
3350
+ return Terminal;
3351
+ }
3000
3352
  /** Absolute index (scrollback + viewport row) of the row the cursor sits on. */
3001
3353
  function cursorAbs(term) {
3002
3354
  return term.buffer.active.baseY + term.buffer.active.cursorY;
@@ -3025,12 +3377,11 @@ function finalizedLines(term, fromAbs) {
3025
3377
  * settled, so the output is clean, newline-terminated, line-buffered text a
3026
3378
  * script can read. On stop, flush the line the cursor is still sitting on.
3027
3379
  */
3028
- async function followPlainLocal(logPath, buf, startOffset = buf.length) {
3029
- process.stderr.write(`following... (plain; Ctrl-C / SIGTERM to stop)\n`);
3030
- const { Terminal } = await import("@xterm/headless");
3031
- const term = new Terminal({
3032
- cols: 200,
3033
- rows: 50,
3380
+ async function followPlainLocal(logPath, buf, startOffset = buf.length, until, geom) {
3381
+ process.stderr.write(until ? untilBanner(until) : `following... (plain; Ctrl-C / SIGTERM to stop)\n`);
3382
+ const term = new (await (loadXtermTerminal()))({
3383
+ cols: geom?.cols ?? 200,
3384
+ rows: geom?.rows ?? 50,
3034
3385
  scrollback: 5e4,
3035
3386
  allowProposedApi: true
3036
3387
  });
@@ -3041,19 +3392,197 @@ async function followPlainLocal(logPath, buf, startOffset = buf.length) {
3041
3392
  };
3042
3393
  await feed(buf);
3043
3394
  let emitted = cursorAbs(term);
3395
+ let anchor = null;
3396
+ for (let i = emitted; i >= Math.max(0, emitted - ANCHOR_LOOKBACK_ROWS); i--) {
3397
+ const line = lineAt(i);
3398
+ if (line) {
3399
+ anchor = line;
3400
+ break;
3401
+ }
3402
+ }
3403
+ const ac = new AbortController();
3404
+ let outcome = "stopped";
3405
+ const judge = until ? makeJudge(until.tally, until.failTally) : null;
3406
+ const settled = () => judge?.settled === true;
3407
+ const testLine = (line) => {
3408
+ if (judge && !judge.settled && judge.test(line)) {
3409
+ outcome = judge.outcome ?? "match";
3410
+ ac.abort();
3411
+ }
3412
+ };
3044
3413
  const flushCommitted = () => {
3045
- for (const line of finalizedLines(term, emitted)) process.stdout.write(line + "\n");
3414
+ for (const line of finalizedLines(term, emitted)) {
3415
+ if (!until?.quiet) process.stdout.write(line + "\n");
3416
+ if (!until) continue;
3417
+ if (line) anchor = line;
3418
+ testLine(line);
3419
+ }
3046
3420
  emitted = cursorAbs(term);
3047
3421
  };
3048
- await watchAppend(logPath, startOffset, async (chunk) => {
3422
+ const timer = until && until.timeoutMs !== null ? setTimeout(() => {
3423
+ outcome = "timeout";
3424
+ ac.abort();
3425
+ }, until.timeoutMs) : null;
3426
+ let deadSince = null;
3427
+ const liveness = until ? setInterval(() => {
3428
+ if (isPidAlive(until.pid)) {
3429
+ deadSince = null;
3430
+ return;
3431
+ }
3432
+ if (deadSince === null) {
3433
+ deadSince = Date.now();
3434
+ return;
3435
+ }
3436
+ outcome = "exited";
3437
+ ac.abort();
3438
+ }, UNTIL_LIVENESS_POLL_MS) : null;
3439
+ liveness?.unref?.();
3440
+ /**
3441
+ * Emit the cursor's own row — the line still being written — so the last
3442
+ * partial line isn't lost when we stop mid-stream, and so a pattern printed
3443
+ * without a trailing newline still counts (it IS on screen). Self-guarded to
3444
+ * run at most once: a row printed here may finalize later, and emitting it
3445
+ * again would duplicate the agent's last line of output.
3446
+ */
3447
+ let partialFlushed = false;
3448
+ const flushPartial = () => {
3449
+ if (partialFlushed) return;
3450
+ partialFlushed = true;
3451
+ const last = lineAt(cursorAbs(term));
3452
+ if (!last) return;
3453
+ if (!until?.quiet) process.stdout.write(last + "\n");
3454
+ if (!until) return;
3455
+ anchor = last;
3456
+ testLine(last);
3457
+ };
3458
+ const stoppedAt = await watchAppend(logPath, startOffset, async (chunk) => {
3049
3459
  await feed(chunk);
3050
3460
  flushCommitted();
3051
- }, () => {
3052
- flushCommitted();
3053
- const last = lineAt(cursorAbs(term));
3054
- if (last) process.stdout.write(last + "\n");
3461
+ }, flushCommitted, until ? ac.signal : void 0);
3462
+ if (timer) clearTimeout(timer);
3463
+ if (liveness) clearInterval(liveness);
3464
+ if (!until) {
3465
+ flushPartial();
3466
+ return 0;
3467
+ }
3468
+ if (!settled()) {
3469
+ const tailBytes = await readFile(logPath).catch(() => null);
3470
+ if (tailBytes === null) {
3471
+ flushPartial();
3472
+ await drainFinalLog(until.pid, logPath, anchor, until, testLine);
3473
+ } else if (tailBytes.length > stoppedAt) {
3474
+ await feed(tailBytes.slice(stoppedAt));
3475
+ flushCommitted();
3476
+ }
3477
+ }
3478
+ flushPartial();
3479
+ return reportUntil(outcome, judge?.matched ?? null, until.quiet, until.failPattern, {
3480
+ hits: until.tally.hits,
3481
+ needed: until.tally.needed
3055
3482
  });
3056
- return 0;
3483
+ }
3484
+ /** Cadence of the `--until` liveness poll; two ticks are needed to call it dead. */
3485
+ const UNTIL_LIVENESS_POLL_MS = 500;
3486
+ /**
3487
+ * Cadence of the REMOTE liveness poll; two ticks are needed to call it dead.
3488
+ * Slower than the local one because each tick is an HTTP round trip, and the
3489
+ * stream itself — not this poll — is what a wait normally ends on.
3490
+ */
3491
+ const UNTIL_REMOTE_STATUS_POLL_MS = 2e3;
3492
+ /**
3493
+ * Whether a remote agent is still running: "live", "gone" (the host answered and
3494
+ * the agent is exited or no longer listed), or null when the poll itself failed.
3495
+ *
3496
+ * Asks `/api/ls`, not `/api/status/<kw>`: only the TS server serves the latter,
3497
+ * while the Rust server — the default `ay serve` — has no such route and 404s
3498
+ * every poll, so a status-based probe silently never fires.
3499
+ *
3500
+ * Null is deliberately distinct from "gone": a dropped packet or a restarting
3501
+ * host would otherwise end a wait with "the agent exited", the one verdict a
3502
+ * caller acts on by giving up and reporting the work unfinished.
3503
+ */
3504
+ async function remoteAgentLiveness(remote, keyword) {
3505
+ const params = new URLSearchParams({
3506
+ keyword,
3507
+ all: "1"
3508
+ });
3509
+ try {
3510
+ const res = await remoteGet(remote, `/api/ls?${params}`);
3511
+ if (!res.ok) return null;
3512
+ const records = await res.json();
3513
+ if (!Array.isArray(records)) return null;
3514
+ if (records.length === 0) return "gone";
3515
+ return records.every((r) => r.status === "exited") ? "gone" : "live";
3516
+ } catch {
3517
+ return null;
3518
+ }
3519
+ }
3520
+ /** How far back from the live frontier to look for a non-empty anchor line. */
3521
+ const ANCHOR_LOOKBACK_ROWS = 200;
3522
+ /**
3523
+ * Last-chance `--until` match against the log an exited agent left behind.
3524
+ *
3525
+ * On a clean exit the raw byte log we were following is replaced by a rendered
3526
+ * scrollback dump at a NEW path, and the pid index is repointed at it. A pattern
3527
+ * printed in the agent's last breath can therefore be missing from everything we
3528
+ * saw yet present in that file — so re-resolve the path from the registry and
3529
+ * scan it.
3530
+ *
3531
+ * Only the lines AFTER `anchor` (the newest line we had already seen, matched
3532
+ * from the END of the file) are tested, so the no-backlog-matching promise
3533
+ * survives: a hit from before this command started cannot be reported. When the
3534
+ * anchor can't be located the drain is skipped rather than guessed at — a missed
3535
+ * match surfaces as a loud exit 1, a false one as a silent wrong answer.
3536
+ *
3537
+ * Feeds those lines to `testLine`, which owns the verdict — so a `--count` wait
3538
+ * finishes on hits split across the stream and this drain, and `--fail-on` still
3539
+ * wins over `--until` on the agent's last line.
3540
+ */
3541
+ async function drainFinalLog(pid, followedPath, anchor, until, testLine) {
3542
+ if (anchor === null) return;
3543
+ const advertised = (await readGlobalPids()).find((r) => r.pid === pid)?.log_file;
3544
+ const derived = followedPath.endsWith(".raw.log") ? followedPath.slice(0, -8) + ".log" : null;
3545
+ const finalPath = advertised && advertised !== followedPath ? advertised : derived;
3546
+ if (!finalPath) return;
3547
+ const text = await readFile(finalPath, "utf8").catch(() => null);
3548
+ if (text === null) return;
3549
+ const fresh = linesAfterAnchor(text.split("\n").map((l) => l.trimEnd()), anchor);
3550
+ if (fresh === null) return;
3551
+ for (const line of fresh) {
3552
+ if (!until.quiet) process.stdout.write(line + "\n");
3553
+ testLine(line);
3554
+ }
3555
+ }
3556
+ /** The "waiting for X | fail on Y | timeout Ns…" banner shared by both followers. */
3557
+ function untilBanner(wait) {
3558
+ const parts = [`waiting for ${JSON.stringify(wait.pattern)}`];
3559
+ if (wait.tally.needed > 1) parts.push(`x${wait.tally.needed}`);
3560
+ if (wait.tally.hits > 0) parts.push(`(${wait.tally.hits} already seen)`);
3561
+ if (wait.failPattern !== null) parts.push(`| fail on ${JSON.stringify(wait.failPattern)}`);
3562
+ if (wait.timeoutMs !== null) parts.push(`| timeout ${Math.round(wait.timeoutMs / 1e3)}s`);
3563
+ return `${parts.join(" ")}… (Ctrl-C / SIGTERM to stop)\n`;
3564
+ }
3565
+ /**
3566
+ * Report an `--until` result and return its exit code. In quiet mode the matching
3567
+ * line is the whole of stdout (a caller can capture it); the human-readable
3568
+ * verdict always goes to stderr so it never contaminates that.
3569
+ *
3570
+ * `progress` (hits/needed) is named on the failure paths only when `--count` asked
3571
+ * for more than one: "timed out with 2/3 matches" is the difference between a wait
3572
+ * that was close and one that never started.
3573
+ */
3574
+ function reportUntil(outcome, matched, quiet, failPattern, progress) {
3575
+ const short = progress && progress.needed > 1 ? ` (${progress.hits}/${progress.needed} matches)` : "";
3576
+ if (outcome === "match" && matched !== null) {
3577
+ if (quiet) process.stdout.write(matched + "\n");
3578
+ process.stderr.write(`[until] matched: ${matched}\n`);
3579
+ } else if (outcome === "failed") {
3580
+ if (quiet && matched !== null) process.stdout.write(matched + "\n");
3581
+ process.stderr.write(`[until] --fail-on ${JSON.stringify(failPattern ?? "")} hit first: ${matched ?? ""}\n`);
3582
+ } else if (outcome === "exited") process.stderr.write(`[until] agent exited without printing it${short}\n`);
3583
+ else if (outcome === "timeout") process.stderr.write(`[until] timed out with no match${short}\n`);
3584
+ else process.stderr.write(`[until] stopped before a match${short}\n`);
3585
+ return untilExitCode(outcome);
3057
3586
  }
3058
3587
  /**
3059
3588
  * Read an agent's last-known PTY geometry from `~/.agent-yes/ptysize/<pid>`
@@ -3152,8 +3681,7 @@ async function renderRawLogLines(buf, geom) {
3152
3681
  const rows = geom?.rows && geom.rows > 0 ? geom.rows : 50;
3153
3682
  const scrollback = 5e4;
3154
3683
  try {
3155
- const { Terminal } = await import("@xterm/headless");
3156
- const term = new Terminal({
3684
+ const term = new (await (loadXtermTerminal()))({
3157
3685
  cols,
3158
3686
  rows,
3159
3687
  scrollback,
@@ -4351,7 +4879,7 @@ async function cmdAttach(rest) {
4351
4879
  const winsizeDir = path.join(ayHome, "winsize");
4352
4880
  await mkdir(winsizeDir, { recursive: true });
4353
4881
  const winsizePath = path.join(winsizeDir, String(record.pid));
4354
- const { resolveDaemonHttpBase, loadTokenReadOnly } = await import("./serve-GoY2ofRT.js");
4882
+ const { resolveDaemonHttpBase, loadTokenReadOnly } = await import("./serve-cvUh5Oab.js");
4355
4883
  const daemonBase = await resolveDaemonHttpBase().catch(() => null);
4356
4884
  const daemonToken = daemonBase ? await loadTokenReadOnly().catch(() => null) ?? "" : "";
4357
4885
  const capViewer = `attach:${process.pid}`;
@@ -5078,7 +5606,7 @@ async function cmdNotify(rest) {
5078
5606
  }
5079
5607
  const ensure = async () => {
5080
5608
  if (!argv["ensure-daemon"]) return;
5081
- const { ensureDaemon } = await import("./notifyDaemon-849g16zO.js");
5609
+ const { ensureDaemon } = await import("./notifyDaemon-BPpVmmj0.js");
5082
5610
  await ensureDaemon().catch(() => null);
5083
5611
  };
5084
5612
  await heartbeatWatcher(parent, selfStartedAt);
@@ -5152,7 +5680,7 @@ async function cmdNotifyCursor(args) {
5152
5680
  }
5153
5681
  async function cmdNotifyd(rest) {
5154
5682
  const sub = rest[0] ?? "status";
5155
- const daemon = await import("./notifyDaemon-849g16zO.js");
5683
+ const daemon = await import("./notifyDaemon-BPpVmmj0.js");
5156
5684
  switch (sub) {
5157
5685
  case "run": return daemon.runDaemon();
5158
5686
  case "once": return daemon.runDaemon({ once: true });
@@ -5179,4 +5707,4 @@ async function cmdNotifyd(rest) {
5179
5707
 
5180
5708
  //#endregion
5181
5709
  export { sendPayloadCapError as $, isUnknownManagerToken as A, readLogForRender as B, finalizedLines as C, isPidAlive as D, isExitRequest as E, listRecords as F, renderLogTailLines as G, readPtysize as H, matchKeyword as I, resolveOne as J, renderRawLog as K, menuSelectKeys as L, isUserTyping as M, lastReadAt as N, isSlashCommand as O, lastStdinAt as P, runSubcommand as Q, probeStdinReachable as R, extractTaskCounts as S, isBareManagerInvocation as T, recentMessageEdges as U, readNotes as V, recentReadEdges as W, resolveResumeArgs as X, resolveReadWindow as Y, restartHintLines as Z, envelopeAttribution as _, SEND_BODY_MAX_CHARS as a, writeKeysPaced as at, extractMenu as b, backoffWhileTyping as c, framePaste as ct, cmdHelp as d, buildAgentForest as dt, snapshotStatus as et, confirmStdinUnreachable as f, flattenForest as ft, deriveLiveStatus as g, deriveLiveState as h, READ_WINDOW_MS as i, waitForLogQuiet as it, isUnreachableWriteErrno as j, isSubcommand as k, buildEnvelope as l, shouldFramePaste as lt, cursorAbs as m, MAX_RENDER_BYTES as n, stopTipForCli as nt, SEND_EXIT_UNREACHABLE as o, writeToIpc as ot, controlCodeFromName as p, renderRawLogLines as q, READ_PAGE_DEFAULT as r, submitAndConfirm as rt, TYPING_WINDOW_MS as s, withIpcLock as st, GRACEFUL_EXIT_COMMANDS as t, stdinActivityPath as tt, cliDefaults as u, TYPING_BADGE as ut, envelopeCostFor as v, isAgentStuck as w, extractNeedsInput as x, extractBadges as y, readAgentPtysize as z };
5182
- //# sourceMappingURL=subcommands-BFcfYykF.js.map
5710
+ //# sourceMappingURL=subcommands-DoKUSvaF.js.map