agent-yes 1.84.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
@@ -15,6 +15,8 @@ import { appendFile, mkdir, open, readFile, stat, writeFile } from "fs/promises"
15
15
  import { homedir } from "os";
16
16
  import path from "path";
17
17
  import { type GlobalPidRecord, readGlobalPids } from "./globalPidIndex.ts";
18
+ import yargs from "yargs";
19
+ import { type ResolvedRemote, readRemotes, resolveRemoteSpec } from "./remotes.ts";
18
20
 
19
21
  // ---------------------------------------------------------------------------
20
22
  // notes store (~/.agent-yes/notes.jsonl)
@@ -25,7 +27,7 @@ function notesPath(): string {
25
27
  return path.join(dir, "notes.jsonl");
26
28
  }
27
29
 
28
- async function readNotes(): Promise<Map<number, string>> {
30
+ export async function readNotes(): Promise<Map<number, string>> {
29
31
  let raw: string;
30
32
  try {
31
33
  raw = await readFile(notesPath(), "utf-8");
@@ -135,6 +137,8 @@ const SUBCOMMANDS = new Set([
135
137
  "send",
136
138
  "restart",
137
139
  "note",
140
+ "serve",
141
+ "remote",
138
142
  ]);
139
143
 
140
144
  const IDLE_THRESHOLD_MS = 60 * 1000;
@@ -174,6 +178,14 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
174
178
  return await cmdRestart(rest);
175
179
  case "note":
176
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
+ }
177
189
  default:
178
190
  return null;
179
191
  }
@@ -188,7 +200,7 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
188
200
  // shared helpers
189
201
  // ---------------------------------------------------------------------------
190
202
 
191
- interface CommonOpts {
203
+ export interface CommonOpts {
192
204
  all: boolean;
193
205
  active: boolean;
194
206
  cwdScope: string | null;
@@ -196,65 +208,6 @@ interface CommonOpts {
196
208
  json: boolean;
197
209
  }
198
210
 
199
- interface ParsedArgs {
200
- flags: Record<string, string | boolean>;
201
- positional: string[];
202
- }
203
-
204
- export function parseArgs(rest: string[]): ParsedArgs {
205
- const flags: Record<string, string | boolean> = {};
206
- const positional: string[] = [];
207
- for (let i = 0; i < rest.length; i++) {
208
- const arg = rest[i]!;
209
- if (arg.startsWith("--")) {
210
- const eq = arg.indexOf("=");
211
- if (eq >= 0) {
212
- flags[arg.slice(2, eq)] = arg.slice(eq + 1);
213
- } else {
214
- const key = arg.slice(2);
215
- const next = rest[i + 1];
216
- // Boolean flags: --all, --json, --latest
217
- if (
218
- ["all", "active", "follow", "json", "latest", "watch"].includes(key) ||
219
- !next ||
220
- next.startsWith("-")
221
- ) {
222
- flags[key] = true;
223
- } else {
224
- flags[key] = next;
225
- i++;
226
- }
227
- }
228
- } else if (arg.startsWith("-") && arg.length > 1) {
229
- // -n N short flag
230
- if (arg === "-n") {
231
- flags["n"] = rest[i + 1] ?? "";
232
- i++;
233
- } else {
234
- flags[arg.slice(1)] = true;
235
- }
236
- } else {
237
- positional.push(arg);
238
- }
239
- }
240
- return { flags, positional };
241
- }
242
-
243
- function commonOpts(flags: Record<string, string | boolean>): CommonOpts {
244
- return {
245
- all: !!flags.all,
246
- active: !!flags.active,
247
- cwdScope:
248
- typeof flags.cwd === "string"
249
- ? path.resolve(flags.cwd)
250
- : flags.cwd === true
251
- ? process.cwd()
252
- : null,
253
- latest: !!flags.latest,
254
- json: !!flags.json,
255
- };
256
- }
257
-
258
211
  export function matchKeyword(record: GlobalPidRecord, keyword: string): boolean {
259
212
  if (!keyword) return true;
260
213
  const kw = keyword.toLowerCase();
@@ -269,7 +222,7 @@ export function matchKeyword(record: GlobalPidRecord, keyword: string): boolean
269
222
  return false;
270
223
  }
271
224
 
272
- async function listRecords(
225
+ export async function listRecords(
273
226
  keyword: string | undefined,
274
227
  opts: CommonOpts,
275
228
  ): Promise<GlobalPidRecord[]> {
@@ -298,7 +251,7 @@ async function listRecords(
298
251
  return records;
299
252
  }
300
253
 
301
- function isPidAlive(pid: number): boolean {
254
+ export function isPidAlive(pid: number): boolean {
302
255
  try {
303
256
  process.kill(pid, 0);
304
257
  return true;
@@ -307,7 +260,10 @@ function isPidAlive(pid: number): boolean {
307
260
  }
308
261
  }
309
262
 
310
- 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> {
311
267
  if (!keyword) {
312
268
  throw new Error("keyword required (pid, cwd substring, cli name, or prompt substring)");
313
269
  }
@@ -326,14 +282,390 @@ async function resolveOne(keyword: string | undefined, opts: CommonOpts): Promis
326
282
  );
327
283
  }
328
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
+
329
598
  // ---------------------------------------------------------------------------
330
599
  // ay ls
331
600
  // ---------------------------------------------------------------------------
332
601
 
333
602
  async function cmdLs(rest: string[]): Promise<number> {
334
- const { flags, positional } = parseArgs(rest);
335
- const opts = commonOpts(flags);
336
- const keyword = positional[0];
603
+ const y = yargs(rest)
604
+ .usage(
605
+ "Usage: ay ls [keyword] [options]\n" +
606
+ " ay list [keyword] [options]\n" +
607
+ " ay ps [keyword] [options]\n\n" +
608
+ "List running agents. Optionally filter by keyword (pid, cwd substring, or prompt substring).",
609
+ )
610
+ .option("all", {
611
+ type: "boolean",
612
+ default: false,
613
+ description: "Show all agents including exited ones",
614
+ })
615
+ .option("active", {
616
+ type: "boolean",
617
+ default: false,
618
+ description: "Only show agents with an alive process",
619
+ })
620
+ .option("json", { type: "boolean", default: false, description: "Output as JSON array" })
621
+ .option("latest", {
622
+ type: "boolean",
623
+ default: false,
624
+ description: "Show only the most recent agent",
625
+ })
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
+ })
632
+ .option("help", { alias: "h", type: "boolean", default: false, description: "Show this help" })
633
+ .example("ay ls", "list running agents")
634
+ .example("ay ls --all-remotes", "include all configured remote machines")
635
+ .example("ay ls --all", "include exited agents")
636
+ .example("ay ls --json", "machine-readable output")
637
+ .example("ay ls symval", "filter by cwd/prompt keyword")
638
+ .help(false)
639
+ .version(false)
640
+ .exitProcess(false);
641
+
642
+ const argv = await y.parseAsync();
643
+
644
+ if (argv.help || argv.h) {
645
+ process.stdout.write((await y.getHelp()) + "\n");
646
+ return 0;
647
+ }
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
+
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
+ }
662
+ const opts: CommonOpts = {
663
+ all: argv.all,
664
+ active: argv.active,
665
+ json: argv.json,
666
+ latest: argv.latest,
667
+ cwdScope: typeof argv.cwd === "string" ? path.resolve(argv.cwd) : null,
668
+ };
337
669
  const records = await listRecords(keyword, opts);
338
670
 
339
671
  if (opts.json) {
@@ -488,12 +820,54 @@ interface ReadOpts {
488
820
  }
489
821
 
490
822
  async function cmdRead(rest: string[], { mode }: ReadOpts): Promise<number> {
491
- const { flags, positional } = parseArgs(rest);
492
- const opts = commonOpts(flags);
493
- const keyword = positional[0];
494
- const follow = !!(flags.f || flags.follow);
495
-
496
- const nFlag = typeof flags.n === "string" ? Number(flags.n) : undefined;
823
+ const y = yargs(rest)
824
+ .usage("Usage: ay read/cat/tail/head <keyword> [options]")
825
+ .option("follow", {
826
+ alias: "f",
827
+ type: "boolean",
828
+ default: false,
829
+ description: "Follow log output (Ctrl-C to stop)",
830
+ })
831
+ .option("n", { type: "number", description: "Number of lines (default: 96 for tail/head)" })
832
+ .option("all", { type: "boolean", default: false, description: "Include exited agents" })
833
+ .option("latest", {
834
+ type: "boolean",
835
+ default: false,
836
+ description: "Use most recent match when multiple match",
837
+ })
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
+ })
844
+ .help(false)
845
+ .version(false)
846
+ .exitProcess(false);
847
+
848
+ const argv = await y.parseAsync();
849
+ const opts: CommonOpts = {
850
+ all: argv.all,
851
+ active: false,
852
+ json: false,
853
+ latest: argv.latest,
854
+ cwdScope: typeof argv.cwd === "string" ? path.resolve(argv.cwd) : null,
855
+ };
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
+ }
869
+ const follow = argv.follow;
870
+ const nFlag = argv.n;
497
871
  const n =
498
872
  nFlag !== undefined && Number.isFinite(nFlag) && nFlag > 0
499
873
  ? Math.floor(nFlag)
@@ -567,7 +941,7 @@ async function cmdRead(rest: string[], { mode }: ReadOpts): Promise<number> {
567
941
  * Feed the raw PTY bytes through @xterm/headless and emit plain text.
568
942
  * Same approach as koho's renderTerminalBuffer + agent-yes's XtermProxy.
569
943
  */
570
- async function renderRawLog(
944
+ export async function renderRawLog(
571
945
  buf: Uint8Array,
572
946
  { mode, n }: { mode: "cat" | "tail" | "head"; n: number },
573
947
  ): Promise<string> {
@@ -732,15 +1106,39 @@ function extractActivityFromLines(lines: string[]): string | null {
732
1106
  // ---------------------------------------------------------------------------
733
1107
 
734
1108
  async function cmdSend(rest: string[]): Promise<number> {
735
- const { flags, positional } = parseArgs(rest);
736
- const opts = commonOpts(flags);
737
- const keyword = positional[0];
738
- const rawMessage = positional.slice(1).join(" ");
1109
+ const y = yargs(rest)
1110
+ .usage("Usage: ay send <keyword> <msg|-> [options]")
1111
+ .option("code", {
1112
+ type: "string",
1113
+ default: "enter",
1114
+ description: "Trailing control code (enter|esc|ctrl-c|ctrl-y|tab|none)",
1115
+ })
1116
+ .option("all", { type: "boolean", default: false, description: "Include exited agents" })
1117
+ .option("latest", { type: "boolean", default: false, description: "Use most recent match" })
1118
+ .option("cwd", { type: "string", description: "Restrict to agents under this dir" })
1119
+ .help(false)
1120
+ .version(false)
1121
+ .exitProcess(false);
1122
+
1123
+ const argv = await y.parseAsync();
1124
+ const opts: CommonOpts = {
1125
+ all: argv.all,
1126
+ active: false,
1127
+ json: false,
1128
+ latest: argv.latest,
1129
+ cwdScope: typeof argv.cwd === "string" ? path.resolve(argv.cwd) : null,
1130
+ };
1131
+ const keyword = argv._[0] !== undefined ? String(argv._[0]) : undefined;
1132
+ const rawMessage = argv._.slice(1).map(String).join(" ");
739
1133
 
740
1134
  if (!keyword)
741
1135
  throw new Error("usage: ay send <keyword> <msg|-> [--code=enter|esc|ctrl-c|ctrl-y|tab|none]");
742
1136
 
743
- const codeName = typeof flags.code === "string" ? flags.code.toLowerCase() : "enter";
1137
+ const codeName = argv.code.toLowerCase();
1138
+ {
1139
+ const remote = await resolveRemoteSpec(keyword);
1140
+ if (remote) return runRemoteSend(remote, rawMessage, codeName);
1141
+ }
744
1142
  const trailing = controlCodeFromName(codeName);
745
1143
 
746
1144
  const record = await resolveOne(keyword, opts);
@@ -819,7 +1217,7 @@ export function controlCodeFromName(name: string): string {
819
1217
  }
820
1218
  }
821
1219
 
822
- async function writeToIpc(ipcPath: string, payload: string): Promise<void> {
1220
+ export async function writeToIpc(ipcPath: string, payload: string): Promise<void> {
823
1221
  if (process.platform === "win32") {
824
1222
  const { connect } = await import("net");
825
1223
  await new Promise<void>((resolve, reject) => {
@@ -855,9 +1253,23 @@ async function writeToIpc(ipcPath: string, payload: string): Promise<void> {
855
1253
  // ---------------------------------------------------------------------------
856
1254
 
857
1255
  async function cmdRestart(rest: string[]): Promise<number> {
858
- const { flags, positional } = parseArgs(rest);
859
- const opts = { ...commonOpts(flags), all: true }; // search stopped agents too
860
- const keyword = positional[0];
1256
+ const y = yargs(rest)
1257
+ .usage("Usage: ay restart <keyword>")
1258
+ .option("latest", { type: "boolean", default: false, description: "Use most recent match" })
1259
+ .option("cwd", { type: "string", description: "Restrict to agents under this dir" })
1260
+ .help(false)
1261
+ .version(false)
1262
+ .exitProcess(false);
1263
+
1264
+ const argv = await y.parseAsync();
1265
+ const opts: CommonOpts = {
1266
+ all: true,
1267
+ active: false,
1268
+ json: false,
1269
+ latest: argv.latest,
1270
+ cwdScope: typeof argv.cwd === "string" ? path.resolve(argv.cwd) : null,
1271
+ };
1272
+ const keyword = argv._[0] !== undefined ? String(argv._[0]) : undefined;
861
1273
  const record = await resolveOne(keyword, opts);
862
1274
 
863
1275
  if (isPidAlive(record.pid)) {
@@ -890,14 +1302,25 @@ async function cmdRestart(rest: string[]): Promise<number> {
890
1302
  // ---------------------------------------------------------------------------
891
1303
 
892
1304
  async function cmdNote(rest: string[]): Promise<number> {
893
- const { flags, positional } = parseArgs(rest);
894
- const opts = commonOpts(flags);
895
- const keyword = positional[0];
896
- const note = positional.slice(1).join(" ");
1305
+ const y = yargs(rest)
1306
+ .usage('Usage: ay note <keyword> ["note text"]')
1307
+ .help(false)
1308
+ .version(false)
1309
+ .exitProcess(false);
1310
+
1311
+ const argv = await y.parseAsync();
1312
+ const keyword = argv._[0] !== undefined ? String(argv._[0]) : undefined;
1313
+ const note = argv._.slice(1).map(String).join(" ");
897
1314
 
898
1315
  if (!keyword) throw new Error('usage: ay note <keyword> ["note text"] (omit text to clear)');
899
1316
 
900
- const record = await resolveOne(keyword, { ...opts, all: true });
1317
+ const record = await resolveOne(keyword, {
1318
+ all: true,
1319
+ active: false,
1320
+ json: false,
1321
+ latest: false,
1322
+ cwdScope: null,
1323
+ });
901
1324
 
902
1325
  if (!note) {
903
1326
  // clear
@@ -917,7 +1340,7 @@ async function cmdNote(rest: string[]): Promise<number> {
917
1340
  // ay status
918
1341
  // ---------------------------------------------------------------------------
919
1342
 
920
- interface StatusSnapshot {
1343
+ export interface StatusSnapshot {
921
1344
  pid: number;
922
1345
  cli: string;
923
1346
  cwd: string;
@@ -932,7 +1355,7 @@ interface StatusSnapshot {
932
1355
  log_file: string | null;
933
1356
  }
934
1357
 
935
- async function snapshotStatus(record: GlobalPidRecord): Promise<StatusSnapshot> {
1358
+ export async function snapshotStatus(record: GlobalPidRecord): Promise<StatusSnapshot> {
936
1359
  const alive = isPidAlive(record.pid);
937
1360
  let state: "active" | "idle" | "stopped";
938
1361
  let logMtimeMs: number | null = null;
@@ -967,14 +1390,40 @@ async function snapshotStatus(record: GlobalPidRecord): Promise<StatusSnapshot>
967
1390
  }
968
1391
 
969
1392
  async function cmdStatus(rest: string[]): Promise<number> {
970
- const { flags, positional } = parseArgs(rest);
971
- const opts = { ...commonOpts(flags), all: true };
972
- const keyword = positional[0];
1393
+ const y = yargs(rest)
1394
+ .usage("Usage: ay status <keyword> [options]")
1395
+ .option("watch", {
1396
+ alias: "w",
1397
+ type: "boolean",
1398
+ default: false,
1399
+ description: "Stream changes as JSON",
1400
+ })
1401
+ .option("interval", { type: "number", default: 2, description: "Poll interval in seconds" })
1402
+ .option("latest", { type: "boolean", default: false, description: "Use most recent match" })
1403
+ .option("cwd", { type: "string", description: "Restrict to agents under this dir" })
1404
+ .help(false)
1405
+ .version(false)
1406
+ .exitProcess(false);
1407
+
1408
+ const argv = await y.parseAsync();
1409
+ const opts: CommonOpts = {
1410
+ all: true,
1411
+ active: false,
1412
+ json: false,
1413
+ latest: argv.latest,
1414
+ cwdScope: typeof argv.cwd === "string" ? path.resolve(argv.cwd) : null,
1415
+ };
1416
+ const keyword = argv._[0] !== undefined ? String(argv._[0]) : undefined;
973
1417
 
974
1418
  if (!keyword) throw new Error("usage: ay status <keyword> [--watch] [--interval=N]");
975
1419
 
976
- const watch = !!(flags.watch || flags.w);
977
- const intervalFlag = typeof flags.interval === "string" ? Number(flags.interval) : 2;
1420
+ {
1421
+ const remote = await resolveRemoteSpec(keyword);
1422
+ if (remote) return runRemoteStatus(remote);
1423
+ }
1424
+
1425
+ const watch = argv.watch;
1426
+ const intervalFlag = argv.interval;
978
1427
  const intervalMs = Math.max(500, (Number.isFinite(intervalFlag) ? intervalFlag : 2) * 1000);
979
1428
 
980
1429
  const record = await resolveOne(keyword, opts);