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