agent-yes 1.107.0 → 1.108.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.
@@ -0,0 +1,8 @@
1
+ import "./ts-D_sIq4Yv.js";
2
+ import "./logger-B9h0djqx.js";
3
+ import "./versionChecker-osmGP6ly.js";
4
+ import "./pidStore-DBjlqzo8.js";
5
+ import "./globalPidIndex-yVd3mbsV.js";
6
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BzuJvKuH.js";
7
+
8
+ export { SUPPORTED_CLIS };
@@ -1,8 +1,8 @@
1
- import { t as CLIS_CONFIG } from "./ts-BKUqrF11.js";
1
+ import { t as CLIS_CONFIG } from "./ts-D_sIq4Yv.js";
2
2
 
3
3
  //#region ts/SUPPORTED_CLIS.ts
4
4
  const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG);
5
5
 
6
6
  //#endregion
7
7
  export { SUPPORTED_CLIS as t };
8
- //# sourceMappingURL=SUPPORTED_CLIS-DM5uuAHJ.js.map
8
+ //# sourceMappingURL=SUPPORTED_CLIS-BzuJvKuH.js.map
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { n as logger } from "./logger-B9h0djqx.js";
3
- import { i as versionString, n as displayVersion, r as getInstalledPackage, t as checkAndAutoUpdate } from "./versionChecker-B7wWbGoR.js";
3
+ import { i as versionString, n as displayVersion, r as getInstalledPackage, t as checkAndAutoUpdate } from "./versionChecker-osmGP6ly.js";
4
4
  import { argv } from "process";
5
5
  import { execFileSync, spawn } from "child_process";
6
6
  import ms from "ms";
@@ -482,7 +482,7 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
482
482
  {
483
483
  const rawArg = process.argv[2];
484
484
  const isHelpFlag = rawArg === "-h" || rawArg === "--help";
485
- const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-DBaESqFT.js");
485
+ const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-4tanf24s.js");
486
486
  if (isHelpFlag && process.argv.length === 3) {
487
487
  cmdHelp();
488
488
  process.exit(0);
@@ -515,7 +515,7 @@ if (config.useRust) {
515
515
  }
516
516
  }
517
517
  if (rustBinary) {
518
- const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-DeoP1wM3.js");
518
+ const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-B_gPkhav.js");
519
519
  const rustArgs = buildRustArgs(process.argv, config.cli, SUPPORTED_CLIS);
520
520
  if (config.verbose) {
521
521
  console.log(`[rust] Using binary: ${rustBinary}`);
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-BKUqrF11.js";
1
+ import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-D_sIq4Yv.js";
2
2
  import "./logger-B9h0djqx.js";
3
- import "./versionChecker-B7wWbGoR.js";
3
+ import "./versionChecker-osmGP6ly.js";
4
4
  import "./pidStore-DBjlqzo8.js";
5
5
  import "./globalPidIndex-yVd3mbsV.js";
6
6
 
@@ -1,11 +1,11 @@
1
- import "./ts-BKUqrF11.js";
1
+ import "./ts-D_sIq4Yv.js";
2
2
  import "./logger-B9h0djqx.js";
3
- import "./versionChecker-B7wWbGoR.js";
3
+ import "./versionChecker-osmGP6ly.js";
4
4
  import "./pidStore-DBjlqzo8.js";
5
5
  import "./globalPidIndex-yVd3mbsV.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DM5uuAHJ.js";
6
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BzuJvKuH.js";
7
7
  import "./remotes-C3xPRtfg.js";
8
- import { c as readNotes, f as snapshotStatus, l as renderRawLog, m as writeToIpc, o as listRecords, r as controlCodeFromName, u as resolveOne } from "./subcommands-CkJFz7eu.js";
8
+ import { c as listRecords, d as renderRawLog, f as resolveOne, g as writeToIpc, m as snapshotStatus, r as controlCodeFromName, u as readNotes } from "./subcommands-BLPtg1xN.js";
9
9
  import yargs from "yargs";
10
10
  import { mkdir, open, readFile, writeFile } from "fs/promises";
11
11
  import { homedir } from "os";
@@ -552,4 +552,4 @@ Options:
552
552
 
553
553
  //#endregion
554
554
  export { cmdServe };
555
- //# sourceMappingURL=serve-DLuHQ8jM.js.map
555
+ //# sourceMappingURL=serve-D4puI0b2.js.map
@@ -0,0 +1,6 @@
1
+ import "./logger-B9h0djqx.js";
2
+ import "./globalPidIndex-yVd3mbsV.js";
3
+ import "./remotes-C3xPRtfg.js";
4
+ import { a as finalizedLines, c as listRecords, d as renderRawLog, f as resolveOne, g as writeToIpc, h as stopTipForCli, i as cursorAbs, l as matchKeyword, m as snapshotStatus, n as cmdHelp, o as isPidAlive, p as runSubcommand, r as controlCodeFromName, s as isSubcommand, t as GRACEFUL_EXIT_COMMANDS, u as readNotes } from "./subcommands-BLPtg1xN.js";
5
+
6
+ export { cmdHelp, isSubcommand, runSubcommand };
@@ -163,7 +163,7 @@ async function runSubcommand(argv) {
163
163
  case "restart": return await cmdRestart(rest);
164
164
  case "note": return await cmdNote(rest);
165
165
  case "serve": {
166
- const { cmdServe } = await import("./serve-DLuHQ8jM.js");
166
+ const { cmdServe } = await import("./serve-D4puI0b2.js");
167
167
  return cmdServe(rest);
168
168
  }
169
169
  case "setup": {
@@ -361,7 +361,7 @@ async function runRemoteLs(remote, opts) {
361
361
  }
362
362
  return 0;
363
363
  }
364
- async function runRemoteRead(remote, mode, follow, n, reconnectTimeoutMs = 12e4) {
364
+ async function runRemoteRead(remote, mode, follow, n, reconnectTimeoutMs = 12e4, _plain = false) {
365
365
  const keyword = remote.keyword ?? "";
366
366
  if (!keyword) {
367
367
  process.stderr.write("remote tail/cat/head requires a keyword (e.g. token@host:port:keyword)\n");
@@ -369,7 +369,8 @@ async function runRemoteRead(remote, mode, follow, n, reconnectTimeoutMs = 12e4)
369
369
  }
370
370
  if (mode === "tail" && follow) {
371
371
  const ac = new AbortController();
372
- process.on("SIGINT", () => ac.abort());
372
+ const disposeSignals = installStreamSignals(() => ac.abort());
373
+ ac.signal.addEventListener("abort", disposeSignals, { once: true });
373
374
  const deadline = Date.now() + reconnectTimeoutMs;
374
375
  let delay = 1e3;
375
376
  let attempt = 0;
@@ -718,6 +719,10 @@ async function cmdRead(rest, { mode }) {
718
719
  }).option("n", {
719
720
  type: "number",
720
721
  description: "Number of lines (default: 96 for tail/head)"
722
+ }).option("plain", {
723
+ type: "boolean",
724
+ default: false,
725
+ description: "Line-buffered plain text for pipes/scripts (no ANSI redraws or spinner). Auto-enabled when stdout is not a TTY."
721
726
  }).option("all", {
722
727
  type: "boolean",
723
728
  default: false,
@@ -734,6 +739,8 @@ async function cmdRead(rest, { mode }) {
734
739
  default: 120,
735
740
  description: "Seconds before giving up reconnecting remote SSE (default: 120)"
736
741
  }).help(false).version(false).exitProcess(false).parseAsync();
742
+ ensureEpipeExit();
743
+ const plain = Boolean(argv.plain) || !process.stdout.isTTY;
737
744
  const opts = {
738
745
  all: argv.all,
739
746
  active: false,
@@ -747,7 +754,7 @@ async function cmdRead(rest, { mode }) {
747
754
  const nFlag2 = argv.n;
748
755
  const n2 = nFlag2 !== void 0 && Number.isFinite(nFlag2) && nFlag2 > 0 ? Math.floor(nFlag2) : mode === "cat" ? 0 : 96;
749
756
  const reconnectTimeoutMs = (argv["reconnect-timeout"] ?? 120) * 1e3;
750
- if (remote) return runRemoteRead(remote, mode, argv.follow, n2, reconnectTimeoutMs);
757
+ if (remote) return runRemoteRead(remote, mode, argv.follow, n2, reconnectTimeoutMs, plain);
751
758
  }
752
759
  const follow = argv.follow;
753
760
  const nFlag = argv.n;
@@ -772,34 +779,170 @@ async function cmdRead(rest, { mode }) {
772
779
  process.stderr.write(header + "\n");
773
780
  process.stdout.write(rendered);
774
781
  if (!rendered.endsWith("\n")) process.stdout.write("\n");
775
- if (follow) {
776
- process.stderr.write(`following... (Ctrl-C to stop)\n`);
777
- let offset = buf.length;
778
- const { watch } = await import("fs");
779
- const ansiRe = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
780
- const ctrlRe = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
781
- await new Promise((resolve) => {
782
- const watcher = watch(logPath, async () => {
783
- const full = await readFile(logPath);
784
- if (full.length <= offset) return;
785
- const chunk = full.slice(offset);
786
- offset = full.length;
787
- const text = new TextDecoder().decode(chunk).replace(ansiRe, "").replace(ctrlRe, "");
788
- if (text.trim()) process.stdout.write(text.trimStart());
789
- });
790
- process.on("SIGINT", () => {
791
- watcher.close();
792
- resolve();
793
- });
794
- });
795
- return 0;
796
- }
782
+ if (follow) return plain ? followPlainLocal(logPath, buf) : followRawLocal(logPath, buf);
797
783
  process.stderr.write(`
798
784
  ay ls # list all agents
799
785
  ay tail -f ${record.pid} # follow live output\n ay send ${record.pid} "next: ..." # send a prompt\n ay send ${record.pid} "" --code=ctrl-c # interrupt\n`);
800
786
  return 0;
801
787
  }
802
788
  /**
789
+ * Exit cleanly when stdout's downstream closes (EPIPE). Node ignores SIGPIPE and
790
+ * surfaces a broken pipe as a stream 'error'; with no listener it throws, and in
791
+ * follow mode the watch loop would otherwise hang. Idempotent — one listener for
792
+ * the life of the process, tagged on stdout so repeated calls (and module
793
+ * reloads in tests) don't pile up listeners.
794
+ */
795
+ function ensureEpipeExit() {
796
+ const TAG = "__ayEpipeExit";
797
+ if (process.stdout[TAG]) return;
798
+ process.stdout[TAG] = true;
799
+ process.stdout.on("error", (e) => {
800
+ if (e?.code === "EPIPE") process.exit(0);
801
+ });
802
+ }
803
+ /**
804
+ * Install signal handlers for a streaming follower so it terminates promptly
805
+ * under automation, not just on an interactive Ctrl-C. SIGINT/SIGTERM/SIGHUP all
806
+ * run `stop` (so `timeout … ay tail -f` and `kill` both work); a closed stdout
807
+ * (EPIPE) exits cleanly via ensureEpipeExit. Returns a disposer that removes the
808
+ * signal listeners.
809
+ */
810
+ function installStreamSignals(stop) {
811
+ ensureEpipeExit();
812
+ const onSig = () => stop();
813
+ process.on("SIGINT", onSig);
814
+ process.on("SIGTERM", onSig);
815
+ process.on("SIGHUP", onSig);
816
+ return () => {
817
+ process.off("SIGINT", onSig);
818
+ process.off("SIGTERM", onSig);
819
+ process.off("SIGHUP", onSig);
820
+ };
821
+ }
822
+ /**
823
+ * Coalescing file watcher: re-reads `logPath` on every change, hands each newly
824
+ * appended byte range to `onChunk`, and never overlaps reads (a change that
825
+ * arrives mid-read is serviced once the current read finishes). `startOffset`
826
+ * is where the already-emitted prefix ends. Resolves when `stop` is signalled.
827
+ */
828
+ async function watchAppend(logPath, startOffset, onChunk, onStop) {
829
+ const { watch } = await import("fs");
830
+ let offset = startOffset;
831
+ let reading = false;
832
+ let pending = false;
833
+ await new Promise((resolve) => {
834
+ let done = false;
835
+ const finish = () => {
836
+ if (done) return;
837
+ done = true;
838
+ try {
839
+ watcher.close();
840
+ } catch {}
841
+ dispose();
842
+ onStop();
843
+ resolve();
844
+ };
845
+ const dispose = installStreamSignals(finish);
846
+ const pump = async () => {
847
+ if (reading) {
848
+ pending = true;
849
+ return;
850
+ }
851
+ reading = true;
852
+ do {
853
+ pending = false;
854
+ let full;
855
+ try {
856
+ full = await readFile(logPath);
857
+ } catch {
858
+ break;
859
+ }
860
+ if (full.length > offset) {
861
+ const chunk = full.slice(offset);
862
+ offset = full.length;
863
+ await onChunk(chunk);
864
+ }
865
+ } while (pending && !done);
866
+ reading = false;
867
+ };
868
+ const watcher = watch(logPath, () => void pump());
869
+ pump();
870
+ });
871
+ }
872
+ /**
873
+ * Default (interactive) follow: append each new byte range with ANSI/control
874
+ * sequences stripped. Mirrors the historical behaviour, plus prompt signal /
875
+ * pipe-close handling.
876
+ */
877
+ async function followRawLocal(logPath, buf) {
878
+ process.stderr.write(`following... (Ctrl-C to stop)\n`);
879
+ const ansiRe = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
880
+ const ctrlRe = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
881
+ await watchAppend(logPath, buf.length, (chunk) => {
882
+ const text = new TextDecoder().decode(chunk).replace(ansiRe, "").replace(ctrlRe, "");
883
+ if (text.trim()) process.stdout.write(text.trimStart());
884
+ }, () => {});
885
+ return 0;
886
+ }
887
+ /** Absolute index (scrollback + viewport row) of the row the cursor sits on. */
888
+ function cursorAbs(term) {
889
+ return term.buffer.active.baseY + term.buffer.active.cursorY;
890
+ }
891
+ /**
892
+ * The lines in [fromAbs, cursorRow) — rows the cursor has moved PAST, i.e.
893
+ * finalized text. A row still being rewritten in place (spinner, progress bar,
894
+ * TUI repaint) is the cursor's own row and is excluded until the cursor leaves
895
+ * it, which is what keeps redraw churn out of the plain stream.
896
+ */
897
+ function finalizedLines(term, fromAbs) {
898
+ const a = term.buffer.active;
899
+ const cur = a.baseY + a.cursorY;
900
+ const out = [];
901
+ for (let i = Math.max(0, fromAbs); i < cur; i++) {
902
+ const l = a.getLine(i);
903
+ out.push(l ? l.translateToString(false).trimEnd() : "");
904
+ }
905
+ return out;
906
+ }
907
+ /**
908
+ * Plain (pipe/script) follow: feed the live PTY stream through @xterm/headless
909
+ * and emit each line only once it's finalized — i.e. once the cursor has moved
910
+ * off it. In-place redraws (spinners, progress bars that rewrite the current
911
+ * line, full-screen TUI repaints) churn the cursor's row and never emit until
912
+ * settled, so the output is clean, newline-terminated, line-buffered text a
913
+ * script can read. On stop, flush the line the cursor is still sitting on.
914
+ */
915
+ async function followPlainLocal(logPath, buf) {
916
+ process.stderr.write(`following... (plain; Ctrl-C / SIGTERM to stop)\n`);
917
+ const { Terminal } = await import("@xterm/headless");
918
+ const term = new Terminal({
919
+ cols: 200,
920
+ rows: 50,
921
+ scrollback: 5e4,
922
+ allowProposedApi: true
923
+ });
924
+ const feed = (b) => new Promise((r) => term.write(b, () => r()));
925
+ const lineAt = (i) => {
926
+ const l = term.buffer.active.getLine(i);
927
+ return l ? l.translateToString(false).trimEnd() : "";
928
+ };
929
+ await feed(buf);
930
+ let emitted = cursorAbs(term);
931
+ const flushCommitted = () => {
932
+ for (const line of finalizedLines(term, emitted)) process.stdout.write(line + "\n");
933
+ emitted = cursorAbs(term);
934
+ };
935
+ await watchAppend(logPath, buf.length, async (chunk) => {
936
+ await feed(chunk);
937
+ flushCommitted();
938
+ }, () => {
939
+ flushCommitted();
940
+ const last = lineAt(cursorAbs(term));
941
+ if (last) process.stdout.write(last + "\n");
942
+ });
943
+ return 0;
944
+ }
945
+ /**
803
946
  * Feed the raw PTY bytes through @xterm/headless and emit plain text.
804
947
  * Same approach as koho's renderTerminalBuffer + agent-yes's XtermProxy.
805
948
  */
@@ -1451,5 +1594,5 @@ async function cmdStatus(rest) {
1451
1594
  }
1452
1595
 
1453
1596
  //#endregion
1454
- export { isSubcommand as a, readNotes as c, runSubcommand as d, snapshotStatus as f, isPidAlive as i, renderRawLog as l, writeToIpc as m, cmdHelp as n, listRecords as o, stopTipForCli as p, controlCodeFromName as r, matchKeyword as s, GRACEFUL_EXIT_COMMANDS as t, resolveOne as u };
1455
- //# sourceMappingURL=subcommands-CkJFz7eu.js.map
1597
+ export { finalizedLines as a, listRecords as c, renderRawLog as d, resolveOne as f, writeToIpc as g, stopTipForCli as h, cursorAbs as i, matchKeyword as l, snapshotStatus as m, cmdHelp as n, isPidAlive as o, runSubcommand as p, controlCodeFromName as r, isSubcommand as s, GRACEFUL_EXIT_COMMANDS as t, readNotes as u };
1598
+ //# sourceMappingURL=subcommands-BLPtg1xN.js.map
@@ -1,5 +1,5 @@
1
1
  import { n as logger, t as addTransport } from "./logger-B9h0djqx.js";
2
- import { r as getInstalledPackage } from "./versionChecker-B7wWbGoR.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-osmGP6ly.js";
3
3
  import { n as agentYesHome, t as PidStore } from "./pidStore-DBjlqzo8.js";
4
4
  import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-CJxsoGdb.js";
5
5
  import { i as readGlobalPids } from "./globalPidIndex-yVd3mbsV.js";
@@ -1714,4 +1714,4 @@ function sleep(ms) {
1714
1714
 
1715
1715
  //#endregion
1716
1716
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1717
- //# sourceMappingURL=ts-BKUqrF11.js.map
1717
+ //# sourceMappingURL=ts-D_sIq4Yv.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.107.0";
10
+ var version = "1.108.0";
11
11
 
12
12
  //#endregion
13
13
  //#region ts/versionChecker.ts
@@ -221,4 +221,4 @@ async function displayVersion() {
221
221
 
222
222
  //#endregion
223
223
  export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
224
- //# sourceMappingURL=versionChecker-B7wWbGoR.js.map
224
+ //# sourceMappingURL=versionChecker-osmGP6ly.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.107.0",
3
+ "version": "1.108.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",
@@ -458,6 +458,45 @@ describe("subcommands.cmdRead renders raw log via xterm-headless", () => {
458
458
  });
459
459
  });
460
460
 
461
+ // The plain (pipe/script) follow mode emits a line only once the cursor has
462
+ // moved off it, so in-place redraws (spinners, progress bars, TUI repaints) stay
463
+ // out of the stream. finalizedLines() is that rule; drive it with a real
464
+ // @xterm/headless terminal so the assertions reflect actual PTY semantics.
465
+ describe("subcommands.finalizedLines (plain follow line discipline)", () => {
466
+ async function newTerm() {
467
+ const { Terminal } = await import("@xterm/headless");
468
+ return new Terminal({ cols: 80, rows: 10, scrollback: 1000, allowProposedApi: true });
469
+ }
470
+ const feed = (term: any, s: string) =>
471
+ new Promise<void>((r) => term.write(new TextEncoder().encode(s), () => r()));
472
+
473
+ it("emits newline-finalized lines and suppresses in-place redraws", async () => {
474
+ const { finalizedLines, cursorAbs } = await loadModule();
475
+ const term = await newTerm();
476
+
477
+ await feed(term, "line A\r\nline B\r\n");
478
+ // Cursor is now on the empty row 2; rows 0–1 are finalized.
479
+ expect(finalizedLines(term as any, 0)).toEqual(["line A", "line B"]);
480
+
481
+ // A spinner rewrites the current row in place (CR, no newline) — not finalized.
482
+ let mark = cursorAbs(term as any);
483
+ await feed(term, "\x1b[33mWorking |\x1b[0m");
484
+ expect(finalizedLines(term as any, mark)).toEqual([]);
485
+ await feed(term, "\rWorking /"); // redraw same row
486
+ expect(finalizedLines(term as any, mark)).toEqual([]);
487
+
488
+ // Once the line is overwritten with real content AND terminated, it commits.
489
+ await feed(term, "\rdownload complete\r\n");
490
+ expect(finalizedLines(term as any, mark)).toEqual(["download complete"]);
491
+
492
+ // Advancing the high-water mark, nothing new is finalized until more arrives.
493
+ mark = cursorAbs(term as any);
494
+ expect(finalizedLines(term as any, mark)).toEqual([]);
495
+ await feed(term, "next line\r\n");
496
+ expect(finalizedLines(term as any, mark)).toEqual(["next line"]);
497
+ });
498
+ });
499
+
461
500
  describe("subcommands.cmdSend writes bytes to FIFO", () => {
462
501
  // Skip on non-unix because FIFO creation requires mkfifo
463
502
  const itUnix = process.platform === "linux" || process.platform === "darwin";
package/ts/subcommands.ts CHANGED
@@ -524,6 +524,7 @@ async function runRemoteRead(
524
524
  follow: boolean,
525
525
  n: number,
526
526
  reconnectTimeoutMs = 120_000,
527
+ _plain = false,
527
528
  ): Promise<number> {
528
529
  const keyword = remote.keyword ?? "";
529
530
  if (!keyword) {
@@ -535,7 +536,12 @@ async function runRemoteRead(
535
536
 
536
537
  if (mode === "tail" && follow) {
537
538
  const ac = new AbortController();
538
- process.on("SIGINT", () => ac.abort());
539
+ // SIGINT/SIGTERM/SIGHUP and a closed pipe all abort the stream, so
540
+ // `timeout … ay tail -f` and `kill` terminate promptly (was SIGINT-only,
541
+ // which let `timeout` run the full --reconnect-timeout window). The server
542
+ // already sends rendered, newline-delimited text, so the wire is plain.
543
+ const disposeSignals = installStreamSignals(() => ac.abort());
544
+ ac.signal.addEventListener("abort", disposeSignals, { once: true });
539
545
  const deadline = Date.now() + reconnectTimeoutMs;
540
546
  let delay = 1_000;
541
547
  let attempt = 0;
@@ -988,6 +994,13 @@ async function cmdRead(rest: string[], { mode }: ReadOpts): Promise<number> {
988
994
  description: "Follow log output (Ctrl-C to stop)",
989
995
  })
990
996
  .option("n", { type: "number", description: "Number of lines (default: 96 for tail/head)" })
997
+ .option("plain", {
998
+ type: "boolean",
999
+ default: false,
1000
+ description:
1001
+ "Line-buffered plain text for pipes/scripts (no ANSI redraws or spinner). " +
1002
+ "Auto-enabled when stdout is not a TTY.",
1003
+ })
991
1004
  .option("all", { type: "boolean", default: false, description: "Include exited agents" })
992
1005
  .option("latest", {
993
1006
  type: "boolean",
@@ -1005,6 +1018,12 @@ async function cmdRead(rest: string[], { mode }: ReadOpts): Promise<number> {
1005
1018
  .exitProcess(false);
1006
1019
 
1007
1020
  const argv = await y.parseAsync();
1021
+ // A closed downstream pipe (e.g. `… | head -3`) makes stdout writes fail with
1022
+ // EPIPE. Treat it as a clean exit — the reader is gone, our job is done.
1023
+ ensureEpipeExit();
1024
+ // Pipes/scripts get line-buffered plain text by default; an explicit --plain
1025
+ // forces it even on a TTY. See followPlainLocal / runRemoteRead.
1026
+ const plain = Boolean(argv.plain) || !process.stdout.isTTY;
1008
1027
  const opts: CommonOpts = {
1009
1028
  all: argv.all,
1010
1029
  active: false,
@@ -1023,7 +1042,7 @@ async function cmdRead(rest: string[], { mode }: ReadOpts): Promise<number> {
1023
1042
  ? 0
1024
1043
  : 96;
1025
1044
  const reconnectTimeoutMs = ((argv["reconnect-timeout"] as number) ?? 120) * 1000;
1026
- if (remote) return runRemoteRead(remote, mode, argv.follow, n2, reconnectTimeoutMs);
1045
+ if (remote) return runRemoteRead(remote, mode, argv.follow, n2, reconnectTimeoutMs, plain);
1027
1046
  }
1028
1047
  const follow = argv.follow;
1029
1048
  const nFlag = argv.n;
@@ -1062,28 +1081,7 @@ async function cmdRead(rest: string[], { mode }: ReadOpts): Promise<number> {
1062
1081
  if (!rendered.endsWith("\n")) process.stdout.write("\n");
1063
1082
 
1064
1083
  if (follow) {
1065
- process.stderr.write(`following... (Ctrl-C to stop)\n`);
1066
- let offset = buf.length;
1067
- const { watch } = await import("fs");
1068
- // oxlint-disable-next-line no-control-regex -- intentional: strip ANSI/control
1069
- const ansiRe = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
1070
- // oxlint-disable-next-line no-control-regex -- intentional: strip control chars
1071
- const ctrlRe = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
1072
- await new Promise<void>((resolve) => {
1073
- const watcher = watch(logPath, async () => {
1074
- const full = await readFile(logPath);
1075
- if (full.length <= offset) return;
1076
- const chunk = full.slice(offset);
1077
- offset = full.length;
1078
- const text = new TextDecoder().decode(chunk).replace(ansiRe, "").replace(ctrlRe, "");
1079
- if (text.trim()) process.stdout.write(text.trimStart());
1080
- });
1081
- process.on("SIGINT", () => {
1082
- watcher.close();
1083
- resolve();
1084
- });
1085
- });
1086
- return 0;
1084
+ return plain ? followPlainLocal(logPath, buf) : followRawLocal(logPath, buf);
1087
1085
  }
1088
1086
 
1089
1087
  process.stderr.write(
@@ -1096,6 +1094,206 @@ async function cmdRead(rest: string[], { mode }: ReadOpts): Promise<number> {
1096
1094
  return 0;
1097
1095
  }
1098
1096
 
1097
+ /**
1098
+ * Exit cleanly when stdout's downstream closes (EPIPE). Node ignores SIGPIPE and
1099
+ * surfaces a broken pipe as a stream 'error'; with no listener it throws, and in
1100
+ * follow mode the watch loop would otherwise hang. Idempotent — one listener for
1101
+ * the life of the process, tagged on stdout so repeated calls (and module
1102
+ * reloads in tests) don't pile up listeners.
1103
+ */
1104
+ function ensureEpipeExit(): void {
1105
+ const TAG = "__ayEpipeExit";
1106
+ if ((process.stdout as unknown as Record<string, boolean>)[TAG]) return;
1107
+ (process.stdout as unknown as Record<string, boolean>)[TAG] = true;
1108
+ process.stdout.on("error", (e: NodeJS.ErrnoException) => {
1109
+ if (e?.code === "EPIPE") process.exit(0);
1110
+ });
1111
+ }
1112
+
1113
+ /**
1114
+ * Install signal handlers for a streaming follower so it terminates promptly
1115
+ * under automation, not just on an interactive Ctrl-C. SIGINT/SIGTERM/SIGHUP all
1116
+ * run `stop` (so `timeout … ay tail -f` and `kill` both work); a closed stdout
1117
+ * (EPIPE) exits cleanly via ensureEpipeExit. Returns a disposer that removes the
1118
+ * signal listeners.
1119
+ */
1120
+ function installStreamSignals(stop: () => void): () => void {
1121
+ ensureEpipeExit();
1122
+ const onSig = () => stop();
1123
+ process.on("SIGINT", onSig);
1124
+ process.on("SIGTERM", onSig);
1125
+ process.on("SIGHUP", onSig);
1126
+ return () => {
1127
+ process.off("SIGINT", onSig);
1128
+ process.off("SIGTERM", onSig);
1129
+ process.off("SIGHUP", onSig);
1130
+ };
1131
+ }
1132
+
1133
+ /**
1134
+ * Coalescing file watcher: re-reads `logPath` on every change, hands each newly
1135
+ * appended byte range to `onChunk`, and never overlaps reads (a change that
1136
+ * arrives mid-read is serviced once the current read finishes). `startOffset`
1137
+ * is where the already-emitted prefix ends. Resolves when `stop` is signalled.
1138
+ */
1139
+ async function watchAppend(
1140
+ logPath: string,
1141
+ startOffset: number,
1142
+ onChunk: (chunk: Uint8Array) => Promise<void> | void,
1143
+ onStop: () => void,
1144
+ ): Promise<void> {
1145
+ const { watch } = await import("fs");
1146
+ let offset = startOffset;
1147
+ let reading = false;
1148
+ let pending = false;
1149
+ await new Promise<void>((resolve) => {
1150
+ let done = false;
1151
+ const finish = () => {
1152
+ if (done) return;
1153
+ done = true;
1154
+ try {
1155
+ watcher.close();
1156
+ } catch {}
1157
+ dispose();
1158
+ onStop();
1159
+ resolve();
1160
+ };
1161
+ const dispose = installStreamSignals(finish);
1162
+ const pump = async () => {
1163
+ if (reading) {
1164
+ pending = true;
1165
+ return;
1166
+ }
1167
+ reading = true;
1168
+ do {
1169
+ pending = false;
1170
+ let full: Uint8Array;
1171
+ try {
1172
+ full = await readFile(logPath);
1173
+ } catch {
1174
+ break;
1175
+ }
1176
+ if (full.length > offset) {
1177
+ const chunk = full.slice(offset);
1178
+ offset = full.length;
1179
+ await onChunk(chunk);
1180
+ }
1181
+ } while (pending && !done);
1182
+ reading = false;
1183
+ };
1184
+ const watcher = watch(logPath, () => void pump());
1185
+ // The file may have grown between our initial read and the watch starting.
1186
+ void pump();
1187
+ });
1188
+ }
1189
+
1190
+ /**
1191
+ * Default (interactive) follow: append each new byte range with ANSI/control
1192
+ * sequences stripped. Mirrors the historical behaviour, plus prompt signal /
1193
+ * pipe-close handling.
1194
+ */
1195
+ async function followRawLocal(logPath: string, buf: Uint8Array): Promise<number> {
1196
+ process.stderr.write(`following... (Ctrl-C to stop)\n`);
1197
+ // oxlint-disable-next-line no-control-regex -- intentional: strip ANSI/control
1198
+ const ansiRe = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
1199
+ // oxlint-disable-next-line no-control-regex -- intentional: strip control chars
1200
+ const ctrlRe = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
1201
+ await watchAppend(
1202
+ logPath,
1203
+ buf.length,
1204
+ (chunk) => {
1205
+ const text = new TextDecoder().decode(chunk).replace(ansiRe, "").replace(ctrlRe, "");
1206
+ if (text.trim()) process.stdout.write(text.trimStart());
1207
+ },
1208
+ () => {},
1209
+ );
1210
+ return 0;
1211
+ }
1212
+
1213
+ /**
1214
+ * Minimal view of an @xterm/headless buffer — just what the line-finalization
1215
+ * logic needs, so it can be unit-tested against a real Terminal or a stub.
1216
+ */
1217
+ export interface PlainTermView {
1218
+ buffer: {
1219
+ active: {
1220
+ baseY: number;
1221
+ cursorY: number;
1222
+ getLine(i: number): { translateToString(trim: boolean): string } | undefined;
1223
+ };
1224
+ };
1225
+ }
1226
+
1227
+ /** Absolute index (scrollback + viewport row) of the row the cursor sits on. */
1228
+ export function cursorAbs(term: PlainTermView): number {
1229
+ return term.buffer.active.baseY + term.buffer.active.cursorY;
1230
+ }
1231
+
1232
+ /**
1233
+ * The lines in [fromAbs, cursorRow) — rows the cursor has moved PAST, i.e.
1234
+ * finalized text. A row still being rewritten in place (spinner, progress bar,
1235
+ * TUI repaint) is the cursor's own row and is excluded until the cursor leaves
1236
+ * it, which is what keeps redraw churn out of the plain stream.
1237
+ */
1238
+ export function finalizedLines(term: PlainTermView, fromAbs: number): string[] {
1239
+ const a = term.buffer.active;
1240
+ const cur = a.baseY + a.cursorY;
1241
+ const out: string[] = [];
1242
+ for (let i = Math.max(0, fromAbs); i < cur; i++) {
1243
+ const l = a.getLine(i);
1244
+ out.push(l ? l.translateToString(false).trimEnd() : "");
1245
+ }
1246
+ return out;
1247
+ }
1248
+
1249
+ /**
1250
+ * Plain (pipe/script) follow: feed the live PTY stream through @xterm/headless
1251
+ * and emit each line only once it's finalized — i.e. once the cursor has moved
1252
+ * off it. In-place redraws (spinners, progress bars that rewrite the current
1253
+ * line, full-screen TUI repaints) churn the cursor's row and never emit until
1254
+ * settled, so the output is clean, newline-terminated, line-buffered text a
1255
+ * script can read. On stop, flush the line the cursor is still sitting on.
1256
+ */
1257
+ async function followPlainLocal(logPath: string, buf: Uint8Array): Promise<number> {
1258
+ process.stderr.write(`following... (plain; Ctrl-C / SIGTERM to stop)\n`);
1259
+ const { Terminal } = await import("@xterm/headless");
1260
+ const term = new Terminal({ cols: 200, rows: 50, scrollback: 50000, allowProposedApi: true });
1261
+ const feed = (b: Uint8Array) => new Promise<void>((r) => term.write(b, () => r()));
1262
+ const lineAt = (i: number) => {
1263
+ const l = term.buffer.active.getLine(i);
1264
+ return l ? l.translateToString(false).trimEnd() : "";
1265
+ };
1266
+
1267
+ // Seed with the existing log so we start streaming from the live frontier —
1268
+ // the recent context was already printed by the static tail above.
1269
+ await feed(buf);
1270
+ let emitted = cursorAbs(term);
1271
+
1272
+ // `emitted` only advances, so a redraw that moves the cursor back up doesn't
1273
+ // re-emit lines it then rewrites.
1274
+ const flushCommitted = () => {
1275
+ for (const line of finalizedLines(term, emitted)) process.stdout.write(line + "\n");
1276
+ emitted = cursorAbs(term);
1277
+ };
1278
+
1279
+ await watchAppend(
1280
+ logPath,
1281
+ buf.length,
1282
+ async (chunk) => {
1283
+ await feed(chunk);
1284
+ flushCommitted();
1285
+ },
1286
+ () => {
1287
+ // Final flush: include the cursor's own row if it has content, so the last
1288
+ // partial line isn't lost when we're killed mid-stream.
1289
+ flushCommitted();
1290
+ const last = lineAt(cursorAbs(term));
1291
+ if (last) process.stdout.write(last + "\n");
1292
+ },
1293
+ );
1294
+ return 0;
1295
+ }
1296
+
1099
1297
  /**
1100
1298
  * Feed the raw PTY bytes through @xterm/headless and emit plain text.
1101
1299
  * Same approach as koho's renderTerminalBuffer + agent-yes's XtermProxy.
@@ -1,8 +0,0 @@
1
- import "./ts-BKUqrF11.js";
2
- import "./logger-B9h0djqx.js";
3
- import "./versionChecker-B7wWbGoR.js";
4
- import "./pidStore-DBjlqzo8.js";
5
- import "./globalPidIndex-yVd3mbsV.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DM5uuAHJ.js";
7
-
8
- export { SUPPORTED_CLIS };
@@ -1,6 +0,0 @@
1
- import "./logger-B9h0djqx.js";
2
- import "./globalPidIndex-yVd3mbsV.js";
3
- import "./remotes-C3xPRtfg.js";
4
- import { a as isSubcommand, c as readNotes, d as runSubcommand, f as snapshotStatus, i as isPidAlive, l as renderRawLog, m as writeToIpc, n as cmdHelp, o as listRecords, p as stopTipForCli, r as controlCodeFromName, s as matchKeyword, t as GRACEFUL_EXIT_COMMANDS, u as resolveOne } from "./subcommands-CkJFz7eu.js";
5
-
6
- export { cmdHelp, isSubcommand, runSubcommand };