agent-yes 1.85.0 → 1.86.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.
package/ts/subcommands.ts CHANGED
@@ -16,6 +16,7 @@ import { homedir } from "os";
16
16
  import path from "path";
17
17
  import { type GlobalPidRecord, readGlobalPids } from "./globalPidIndex.ts";
18
18
  import yargs from "yargs";
19
+ import { type ResolvedRemote, readRemotes, resolveRemoteSpec } from "./remotes.ts";
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // notes store (~/.agent-yes/notes.jsonl)
@@ -26,7 +27,7 @@ function notesPath(): string {
26
27
  return path.join(dir, "notes.jsonl");
27
28
  }
28
29
 
29
- async function readNotes(): Promise<Map<number, string>> {
30
+ export async function readNotes(): Promise<Map<number, string>> {
30
31
  let raw: string;
31
32
  try {
32
33
  raw = await readFile(notesPath(), "utf-8");
@@ -136,6 +137,8 @@ const SUBCOMMANDS = new Set([
136
137
  "send",
137
138
  "restart",
138
139
  "note",
140
+ "serve",
141
+ "remote",
139
142
  ]);
140
143
 
141
144
  const IDLE_THRESHOLD_MS = 60 * 1000;
@@ -175,6 +178,14 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
175
178
  return await cmdRestart(rest);
176
179
  case "note":
177
180
  return await cmdNote(rest);
181
+ case "serve": {
182
+ const { cmdServe } = await import("./serve.ts");
183
+ return cmdServe(rest);
184
+ }
185
+ case "remote": {
186
+ const { cmdRemote } = await import("./remotes.ts");
187
+ return cmdRemote(rest);
188
+ }
178
189
  default:
179
190
  return null;
180
191
  }
@@ -189,7 +200,7 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
189
200
  // shared helpers
190
201
  // ---------------------------------------------------------------------------
191
202
 
192
- interface CommonOpts {
203
+ export interface CommonOpts {
193
204
  all: boolean;
194
205
  active: boolean;
195
206
  cwdScope: string | null;
@@ -211,7 +222,7 @@ export function matchKeyword(record: GlobalPidRecord, keyword: string): boolean
211
222
  return false;
212
223
  }
213
224
 
214
- async function listRecords(
225
+ export async function listRecords(
215
226
  keyword: string | undefined,
216
227
  opts: CommonOpts,
217
228
  ): Promise<GlobalPidRecord[]> {
@@ -240,7 +251,7 @@ async function listRecords(
240
251
  return records;
241
252
  }
242
253
 
243
- function isPidAlive(pid: number): boolean {
254
+ export function isPidAlive(pid: number): boolean {
244
255
  try {
245
256
  process.kill(pid, 0);
246
257
  return true;
@@ -249,7 +260,10 @@ function isPidAlive(pid: number): boolean {
249
260
  }
250
261
  }
251
262
 
252
- async function resolveOne(keyword: string | undefined, opts: CommonOpts): Promise<GlobalPidRecord> {
263
+ export async function resolveOne(
264
+ keyword: string | undefined,
265
+ opts: CommonOpts,
266
+ ): Promise<GlobalPidRecord> {
253
267
  if (!keyword) {
254
268
  throw new Error("keyword required (pid, cwd substring, cli name, or prompt substring)");
255
269
  }
@@ -268,6 +282,319 @@ async function resolveOne(keyword: string | undefined, opts: CommonOpts): Promis
268
282
  );
269
283
  }
270
284
 
285
+ // ---------------------------------------------------------------------------
286
+ // remote routing helpers
287
+ // ---------------------------------------------------------------------------
288
+
289
+ async function remoteGet(remote: ResolvedRemote, pathname: string): Promise<Response> {
290
+ return fetch(`${remote.url}${pathname}`, {
291
+ headers: { Authorization: `Bearer ${remote.token}` },
292
+ });
293
+ }
294
+
295
+ async function remotePost(
296
+ remote: ResolvedRemote,
297
+ pathname: string,
298
+ body: unknown,
299
+ ): Promise<Response> {
300
+ return fetch(`${remote.url}${pathname}`, {
301
+ method: "POST",
302
+ headers: { Authorization: `Bearer ${remote.token}`, "Content-Type": "application/json" },
303
+ body: JSON.stringify(body),
304
+ });
305
+ }
306
+
307
+ async function runRemoteLs(
308
+ remote: ResolvedRemote,
309
+ opts: { all: boolean; active: boolean },
310
+ ): Promise<number> {
311
+ const params = new URLSearchParams();
312
+ if (remote.keyword) params.set("keyword", remote.keyword);
313
+ if (opts.all) params.set("all", "1");
314
+ if (opts.active) params.set("active", "1");
315
+ const res = await remoteGet(remote, `/api/ls?${params}`);
316
+ if (!res.ok) {
317
+ process.stderr.write(`remote error ${res.status}: ${await res.text()}\n`);
318
+ return 1;
319
+ }
320
+ const records = (await res.json()) as any[];
321
+ if (records.length === 0) {
322
+ process.stderr.write(
323
+ remote.keyword
324
+ ? `no agents matched "${remote.keyword}" on ${remote.url}\n`
325
+ : `no running agents on ${remote.url}\n`,
326
+ );
327
+ return 0;
328
+ }
329
+ process.stderr.write(`[remote ${remote.url}]\n`);
330
+ const termWidth = (process.stdout as any).columns ?? 120;
331
+ const widths = {
332
+ pid: Math.max(3, ...records.map((r: any) => String(r.pid).length)),
333
+ cli: Math.max(3, ...records.map((r: any) => String(r.cli).length)),
334
+ status: Math.max(6, ...records.map((r: any) => String(r.status).length)),
335
+ cwd: Math.max(3, ...records.map((r: any) => String(r.cwd).length)),
336
+ };
337
+ const fixedWidth = widths.pid + widths.cli + widths.status + widths.cwd + 4 * 2;
338
+ const promptBudget = Math.max(20, termWidth - fixedWidth - 1);
339
+ const header =
340
+ [
341
+ "PID".padEnd(widths.pid),
342
+ "CLI".padEnd(widths.cli),
343
+ "STATUS".padEnd(widths.status),
344
+ "CWD".padEnd(widths.cwd),
345
+ "PROMPT",
346
+ ].join(" ") + "\n";
347
+ process.stdout.write(header);
348
+ for (const r of records) {
349
+ const label = r.prompt ? truncate(`→ ${r.prompt}`, promptBudget) : "";
350
+ process.stdout.write(
351
+ [
352
+ String(r.pid).padEnd(widths.pid),
353
+ String(r.cli).padEnd(widths.cli),
354
+ String(r.status).padEnd(widths.status),
355
+ String(r.cwd).padEnd(widths.cwd),
356
+ label,
357
+ ].join(" ") + "\n",
358
+ );
359
+ }
360
+ return 0;
361
+ }
362
+
363
+ async function runRemoteRead(
364
+ remote: ResolvedRemote,
365
+ mode: "cat" | "tail" | "head",
366
+ follow: boolean,
367
+ n: number,
368
+ reconnectTimeoutMs = 120_000,
369
+ ): Promise<number> {
370
+ const keyword = remote.keyword ?? "";
371
+ if (!keyword) {
372
+ process.stderr.write(
373
+ "remote tail/cat/head requires a keyword (e.g. token@host:port:keyword)\n",
374
+ );
375
+ return 1;
376
+ }
377
+
378
+ if (mode === "tail" && follow) {
379
+ const ac = new AbortController();
380
+ process.on("SIGINT", () => ac.abort());
381
+ const deadline = Date.now() + reconnectTimeoutMs;
382
+ let delay = 1_000;
383
+ let attempt = 0;
384
+
385
+ process.stderr.write(
386
+ `[remote ${remote.url} ${keyword}]\nfollowing... (Ctrl-C to stop, timeout: ${Math.round(reconnectTimeoutMs / 1000)}s)\n`,
387
+ );
388
+
389
+ while (!ac.signal.aborted) {
390
+ try {
391
+ const res = await fetch(`${remote.url}/api/tail/${encodeURIComponent(keyword)}`, {
392
+ headers: { Authorization: `Bearer ${remote.token}`, Accept: "text/event-stream" },
393
+ signal: ac.signal,
394
+ });
395
+ if (!res.ok) {
396
+ // 401/404 are permanent failures — no point retrying
397
+ if (res.status === 401 || res.status === 404) {
398
+ process.stderr.write(`remote error ${res.status}: ${await res.text()}\n`);
399
+ return 1;
400
+ }
401
+ throw new Error(`HTTP ${res.status}`);
402
+ }
403
+
404
+ if (attempt > 0) process.stderr.write("remote: reconnected\n");
405
+ delay = 1_000; // reset backoff on successful connect
406
+
407
+ const reader = res.body!.getReader();
408
+ const dec = new TextDecoder();
409
+ let buf = "";
410
+ while (true) {
411
+ const { done, value } = await reader.read();
412
+ if (done) break;
413
+ buf += dec.decode(value, { stream: true });
414
+ const lines = buf.split("\n");
415
+ buf = lines.pop() ?? "";
416
+ for (const line of lines) {
417
+ if (!line.startsWith("data: ")) continue;
418
+ try {
419
+ const text = JSON.parse(line.slice(6)) as string;
420
+ process.stdout.write(text);
421
+ if (!text.endsWith("\n")) process.stdout.write("\n");
422
+ } catch {
423
+ /* skip non-JSON */
424
+ }
425
+ }
426
+ }
427
+ break; // stream ended cleanly
428
+ } catch (e: any) {
429
+ if (e.name === "AbortError" || ac.signal.aborted) return 0;
430
+ if (Date.now() >= deadline) {
431
+ process.stderr.write(
432
+ `remote: timeout after ${Math.round(reconnectTimeoutMs / 1000)}s, giving up\n`,
433
+ );
434
+ return 1;
435
+ }
436
+ process.stderr.write(
437
+ `remote: disconnected (${e.message}), retrying in ${delay / 1000}s…\n`,
438
+ );
439
+ await new Promise<void>((resolve, reject) => {
440
+ const t = setTimeout(resolve, delay);
441
+ ac.signal.addEventListener("abort", () => {
442
+ clearTimeout(t);
443
+ reject(new Error("abort"));
444
+ });
445
+ }).catch(() => {});
446
+ if (ac.signal.aborted) return 0;
447
+ delay = Math.min(delay * 2, 30_000);
448
+ attempt++;
449
+ }
450
+ }
451
+ return 0;
452
+ }
453
+
454
+ // Static read (cat/head/tail without -f)
455
+ const params = new URLSearchParams({ mode, n: String(n) });
456
+ const res = await remoteGet(remote, `/api/read/${encodeURIComponent(keyword)}?${params}`);
457
+ if (!res.ok) {
458
+ process.stderr.write(`remote error ${res.status}: ${await res.text()}\n`);
459
+ return 1;
460
+ }
461
+ const text = await res.text();
462
+ process.stderr.write(`[remote ${remote.url} ${keyword}]\n`);
463
+ process.stdout.write(text);
464
+ if (!text.endsWith("\n")) process.stdout.write("\n");
465
+ return 0;
466
+ }
467
+
468
+ async function runRemoteSend(remote: ResolvedRemote, msg: string, code: string): Promise<number> {
469
+ const keyword = remote.keyword ?? "";
470
+ if (!keyword) {
471
+ process.stderr.write("remote send requires a keyword (e.g. token@host:port:keyword)\n");
472
+ return 1;
473
+ }
474
+ const res = await remotePost(remote, "/api/send", { keyword, msg, code });
475
+ if (!res.ok) {
476
+ process.stderr.write(`remote error ${res.status}: ${await res.text()}\n`);
477
+ return 1;
478
+ }
479
+ const data = (await res.json()) as any;
480
+ process.stdout.write(`sent to remote pid ${data.pid} (${remote.url} ${keyword})\n`);
481
+ return 0;
482
+ }
483
+
484
+ async function runRemoteStatus(remote: ResolvedRemote): Promise<number> {
485
+ const keyword = remote.keyword ?? "";
486
+ if (!keyword) {
487
+ process.stderr.write("remote status requires a keyword (e.g. token@host:port:keyword)\n");
488
+ return 1;
489
+ }
490
+ const res = await remoteGet(remote, `/api/status/${encodeURIComponent(keyword)}`);
491
+ if (!res.ok) {
492
+ process.stderr.write(`remote error ${res.status}: ${await res.text()}\n`);
493
+ return 1;
494
+ }
495
+ process.stdout.write(JSON.stringify(await res.json(), null, 2) + "\n");
496
+ return 0;
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // --all-remotes helpers
501
+ // ---------------------------------------------------------------------------
502
+
503
+ async function fetchRemoteRecordsRaw(
504
+ url: string,
505
+ token: string,
506
+ opts: { all: boolean; active: boolean; keyword?: string },
507
+ ): Promise<any[]> {
508
+ const params = new URLSearchParams();
509
+ if (opts.all) params.set("all", "1");
510
+ if (opts.active) params.set("active", "1");
511
+ if (opts.keyword) params.set("keyword", opts.keyword);
512
+ try {
513
+ const res = await fetch(`${url}/api/ls?${params}`, {
514
+ headers: { Authorization: `Bearer ${token}` },
515
+ signal: AbortSignal.timeout(5000),
516
+ });
517
+ if (!res.ok) return [];
518
+ return (await res.json()) as any[];
519
+ } catch {
520
+ return [];
521
+ }
522
+ }
523
+
524
+ async function runAllRemotesLs(opts: {
525
+ all: boolean;
526
+ active: boolean;
527
+ keyword?: string;
528
+ }): Promise<number> {
529
+ const remotes = await readRemotes();
530
+ const localOpts: CommonOpts = {
531
+ all: opts.all,
532
+ active: opts.active,
533
+ json: true,
534
+ latest: false,
535
+ cwdScope: null,
536
+ };
537
+
538
+ const [localResult, ...remoteResults] = await Promise.allSettled([
539
+ listRecords(opts.keyword, localOpts).then((recs) => ({
540
+ host: "local",
541
+ records: recs as any[],
542
+ })),
543
+ ...Array.from(remotes.entries()).map(([alias, cfg]) =>
544
+ fetchRemoteRecordsRaw(cfg.url, cfg.token, opts).then((records) => ({ host: alias, records })),
545
+ ),
546
+ ]);
547
+
548
+ type HostedRow = { host: string; rec: any };
549
+ const rows: HostedRow[] = [];
550
+ if (localResult.status === "fulfilled") {
551
+ for (const r of localResult.value.records) rows.push({ host: "local", rec: r });
552
+ }
553
+ for (const res of remoteResults) {
554
+ if (res.status === "fulfilled") {
555
+ for (const r of res.value.records) rows.push({ host: res.value.host, rec: r });
556
+ }
557
+ }
558
+
559
+ if (rows.length === 0) {
560
+ process.stderr.write("no running agents\n");
561
+ return 0;
562
+ }
563
+
564
+ const termWidth = (process.stdout as any).columns ?? 120;
565
+ const hostW = Math.max(4, ...rows.map((r) => r.host.length));
566
+ const pidW = Math.max(3, ...rows.map((r) => String(r.rec.pid).length));
567
+ const cliW = Math.max(3, ...rows.map((r) => String(r.rec.cli).length));
568
+ const statusW = Math.max(6, ...rows.map((r) => String(r.rec.status).length));
569
+ const cwdW = Math.max(3, ...rows.map((r) => shortenPath(String(r.rec.cwd)).length));
570
+ const promptBudget = Math.max(20, termWidth - hostW - pidW - cliW - statusW - cwdW - 5 * 2 - 1);
571
+
572
+ process.stdout.write(
573
+ [
574
+ "HOST".padEnd(hostW),
575
+ "PID".padEnd(pidW),
576
+ "CLI".padEnd(cliW),
577
+ "STATUS".padEnd(statusW),
578
+ "CWD".padEnd(cwdW),
579
+ "PROMPT",
580
+ ].join(" ") + "\n",
581
+ );
582
+ for (const { host, rec } of rows) {
583
+ const label = rec.prompt ? truncate(`→ ${rec.prompt}`, promptBudget) : "";
584
+ process.stdout.write(
585
+ [
586
+ host.padEnd(hostW),
587
+ String(rec.pid).padEnd(pidW),
588
+ String(rec.cli).padEnd(cliW),
589
+ String(rec.status).padEnd(statusW),
590
+ shortenPath(String(rec.cwd)).padEnd(cwdW),
591
+ label,
592
+ ].join(" ") + "\n",
593
+ );
594
+ }
595
+ return 0;
596
+ }
597
+
271
598
  // ---------------------------------------------------------------------------
272
599
  // ay ls
273
600
  // ---------------------------------------------------------------------------
@@ -297,8 +624,14 @@ async function cmdLs(rest: string[]): Promise<number> {
297
624
  description: "Show only the most recent agent",
298
625
  })
299
626
  .option("cwd", { type: "string", description: "Restrict to agents whose cwd starts with dir" })
627
+ .option("all-remotes", {
628
+ type: "boolean",
629
+ default: false,
630
+ description: "Include agents from all configured remotes (remotes.yaml)",
631
+ })
300
632
  .option("help", { alias: "h", type: "boolean", default: false, description: "Show this help" })
301
633
  .example("ay ls", "list running agents")
634
+ .example("ay ls --all-remotes", "include all configured remote machines")
302
635
  .example("ay ls --all", "include exited agents")
303
636
  .example("ay ls --json", "machine-readable output")
304
637
  .example("ay ls symval", "filter by cwd/prompt keyword")
@@ -313,7 +646,19 @@ async function cmdLs(rest: string[]): Promise<number> {
313
646
  return 0;
314
647
  }
315
648
 
649
+ if (argv["all-remotes"]) {
650
+ return runAllRemotesLs({
651
+ all: argv.all,
652
+ active: argv.active,
653
+ keyword: argv._[0] !== undefined ? String(argv._[0]) : undefined,
654
+ });
655
+ }
656
+
316
657
  const keyword = argv._[0] !== undefined ? String(argv._[0]) : undefined;
658
+ if (keyword) {
659
+ const remote = await resolveRemoteSpec(keyword);
660
+ if (remote) return runRemoteLs(remote, { all: argv.all, active: argv.active });
661
+ }
317
662
  const opts: CommonOpts = {
318
663
  all: argv.all,
319
664
  active: argv.active,
@@ -491,6 +836,11 @@ async function cmdRead(rest: string[], { mode }: ReadOpts): Promise<number> {
491
836
  description: "Use most recent match when multiple match",
492
837
  })
493
838
  .option("cwd", { type: "string", description: "Restrict to agents under this dir" })
839
+ .option("reconnect-timeout", {
840
+ type: "number",
841
+ default: 120,
842
+ description: "Seconds before giving up reconnecting remote SSE (default: 120)",
843
+ })
494
844
  .help(false)
495
845
  .version(false)
496
846
  .exitProcess(false);
@@ -504,6 +854,18 @@ async function cmdRead(rest: string[], { mode }: ReadOpts): Promise<number> {
504
854
  cwdScope: typeof argv.cwd === "string" ? path.resolve(argv.cwd) : null,
505
855
  };
506
856
  const keyword = argv._[0] !== undefined ? String(argv._[0]) : undefined;
857
+ if (keyword) {
858
+ const remote = await resolveRemoteSpec(keyword);
859
+ const nFlag2 = argv.n;
860
+ const n2 =
861
+ nFlag2 !== undefined && Number.isFinite(nFlag2) && nFlag2 > 0
862
+ ? Math.floor(nFlag2)
863
+ : mode === "cat"
864
+ ? 0
865
+ : 96;
866
+ const reconnectTimeoutMs = ((argv["reconnect-timeout"] as number) ?? 120) * 1000;
867
+ if (remote) return runRemoteRead(remote, mode, argv.follow, n2, reconnectTimeoutMs);
868
+ }
507
869
  const follow = argv.follow;
508
870
  const nFlag = argv.n;
509
871
  const n =
@@ -579,7 +941,7 @@ async function cmdRead(rest: string[], { mode }: ReadOpts): Promise<number> {
579
941
  * Feed the raw PTY bytes through @xterm/headless and emit plain text.
580
942
  * Same approach as koho's renderTerminalBuffer + agent-yes's XtermProxy.
581
943
  */
582
- async function renderRawLog(
944
+ export async function renderRawLog(
583
945
  buf: Uint8Array,
584
946
  { mode, n }: { mode: "cat" | "tail" | "head"; n: number },
585
947
  ): Promise<string> {
@@ -773,6 +1135,10 @@ async function cmdSend(rest: string[]): Promise<number> {
773
1135
  throw new Error("usage: ay send <keyword> <msg|-> [--code=enter|esc|ctrl-c|ctrl-y|tab|none]");
774
1136
 
775
1137
  const codeName = argv.code.toLowerCase();
1138
+ {
1139
+ const remote = await resolveRemoteSpec(keyword);
1140
+ if (remote) return runRemoteSend(remote, rawMessage, codeName);
1141
+ }
776
1142
  const trailing = controlCodeFromName(codeName);
777
1143
 
778
1144
  const record = await resolveOne(keyword, opts);
@@ -851,7 +1217,7 @@ export function controlCodeFromName(name: string): string {
851
1217
  }
852
1218
  }
853
1219
 
854
- async function writeToIpc(ipcPath: string, payload: string): Promise<void> {
1220
+ export async function writeToIpc(ipcPath: string, payload: string): Promise<void> {
855
1221
  if (process.platform === "win32") {
856
1222
  const { connect } = await import("net");
857
1223
  await new Promise<void>((resolve, reject) => {
@@ -974,7 +1340,7 @@ async function cmdNote(rest: string[]): Promise<number> {
974
1340
  // ay status
975
1341
  // ---------------------------------------------------------------------------
976
1342
 
977
- interface StatusSnapshot {
1343
+ export interface StatusSnapshot {
978
1344
  pid: number;
979
1345
  cli: string;
980
1346
  cwd: string;
@@ -989,7 +1355,7 @@ interface StatusSnapshot {
989
1355
  log_file: string | null;
990
1356
  }
991
1357
 
992
- async function snapshotStatus(record: GlobalPidRecord): Promise<StatusSnapshot> {
1358
+ export async function snapshotStatus(record: GlobalPidRecord): Promise<StatusSnapshot> {
993
1359
  const alive = isPidAlive(record.pid);
994
1360
  let state: "active" | "idle" | "stopped";
995
1361
  let logMtimeMs: number | null = null;
@@ -1051,6 +1417,11 @@ async function cmdStatus(rest: string[]): Promise<number> {
1051
1417
 
1052
1418
  if (!keyword) throw new Error("usage: ay status <keyword> [--watch] [--interval=N]");
1053
1419
 
1420
+ {
1421
+ const remote = await resolveRemoteSpec(keyword);
1422
+ if (remote) return runRemoteStatus(remote);
1423
+ }
1424
+
1054
1425
  const watch = argv.watch;
1055
1426
  const intervalFlag = argv.interval;
1056
1427
  const intervalMs = Math.max(500, (Number.isFinite(intervalFlag) ? intervalFlag : 2) * 1000);