agent-yes 1.300.3 → 1.300.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/{SUPPORTED_CLIS-CWJr4TJH.js → SUPPORTED_CLIS-BIg_X-jE.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-ChSO8FiS.js → SUPPORTED_CLIS-D7lOxOnw.js} +3 -3
  3. package/dist/{agentShare-NkW0b0mg.js → agentShare-BfHUZgk4.js} +4 -4
  4. package/dist/{callback-CjlaFXp1.js → callback-COR0F2eB.js} +3 -3
  5. package/dist/{callback-B8CUyag4.js → callback-yWhCmi4k.js} +3 -3
  6. package/dist/{channels-aYwLMbFV.js → channels-rhxQArqN.js} +2 -2
  7. package/dist/cli.js +5 -5
  8. package/dist/{cmdPs-C8DIl8V6.js → cmdPs-B3jTE4f3.js} +3 -3
  9. package/dist/index.js +2 -2
  10. package/dist/{notifyDaemon-DWIfzEE7.js → notifyDaemon-BIo7Nkk2.js} +3 -3
  11. package/dist/{parentPingLoop-Bh3KuFcW.js → parentPingLoop-9T4Pzp6c.js} +2 -2
  12. package/dist/{remotes-B4v0Nowo.js → remotes-B4CNXwDS.js} +2 -2
  13. package/dist/{remotes-DnMQWaqQ.js → remotes-CQTEY8Gt.js} +1 -1
  14. package/dist/{rustBinary-gI_r3g9K.js → rustBinary-CJXUPUWd.js} +48 -5
  15. package/dist/rustBinary-FhGG3Yfl.js +4 -0
  16. package/dist/{schedule-DryC--yx.js → schedule-DmijwnTA.js} +4 -4
  17. package/dist/{serve-DVoYVgBo.js → serve-F5tXYO0-.js} +99 -32
  18. package/dist/{serveRust-DDQiZmMf.js → serveRust-BAawe83i.js} +16 -2
  19. package/dist/{setup--1zRHaGW.js → setup-mCb1JbTX.js} +2 -2
  20. package/dist/{share-C9oGvjXT.js → share-CLataFnI.js} +18 -12
  21. package/dist/share-nmgSIjYP.js +4 -0
  22. package/dist/{shareCmd-BI3hRWGO.js → shareCmd-aOKimJeT.js} +25 -7
  23. package/dist/{subcommands-BviFOQZE.js → subcommands-DRq0MgSm.js} +2 -2
  24. package/dist/{subcommands-UWLkF5pq.js → subcommands-Ddo2TYZs.js} +404 -77
  25. package/dist/{terminal-DviH1E3C.js → terminal-DEXrWOa9.js} +2 -2
  26. package/dist/trayApp-0RVjo43f.js +5 -0
  27. package/dist/{trayApp-PJrwfyDs.js → trayApp-BUXx7cES.js} +2 -2
  28. package/dist/{ts--JmSY117.js → ts-CRbycTni.js} +5 -5
  29. package/dist/{versionChecker-CCGSbjio.js → versionChecker-DeQEhRGB.js} +2 -2
  30. package/dist/{webrtcRemote-Cd4vTrgv.js → webrtcRemote-Cy5kB8aR.js} +115 -14
  31. package/dist/{widget-DgBDIGRQ.js → widget-DneXqnL8.js} +3 -3
  32. package/dist/{ws-DnBg8-g-.js → ws-DPw1lVTi.js} +3 -3
  33. package/dist/{ws-dc7MP054.js → ws-Dr-0Ckq7.js} +2 -2
  34. package/package.json +1 -1
  35. package/ts/remoteCache.bun.spec.ts +191 -0
  36. package/ts/remoteCache.spec.ts +191 -0
  37. package/ts/remoteCache.ts +193 -0
  38. package/ts/rustBinary.bun.spec.ts +39 -2
  39. package/ts/rustBinary.spec.ts +53 -2
  40. package/ts/rustBinary.ts +61 -3
  41. package/ts/serve.spec.ts +103 -0
  42. package/ts/serve.ts +100 -15
  43. package/ts/serveRust.spec.ts +24 -1
  44. package/ts/serveRust.ts +19 -0
  45. package/ts/share.bun.spec.ts +54 -2
  46. package/ts/share.spec.ts +54 -2
  47. package/ts/share.ts +40 -15
  48. package/ts/shareCmd.ts +33 -2
  49. package/ts/strayServe.ts +1 -0
  50. package/ts/subcommands.spec.ts +36 -3
  51. package/ts/subcommands.ts +384 -98
  52. package/ts/webrtcRemote.ts +66 -20
  53. package/ts/webrtcSignal.bun.spec.ts +120 -0
  54. package/ts/webrtcSignal.spec.ts +120 -0
  55. package/ts/webrtcSignal.ts +143 -0
  56. package/dist/rustBinary-BNmAJEBb.js +0 -4
  57. package/dist/share-Bmnd_qT0.js +0 -4
  58. package/dist/trayApp-BDIb8fOj.js +0 -5
package/ts/subcommands.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { randomBytes } from "crypto";
15
15
  import { closeSync, constants as fsConstants, openSync, realpathSync } from "fs";
16
- import { execFileSync } from "node:child_process";
16
+ import { execFileSync, spawn as spawnChild } from "node:child_process";
17
17
  import { appendFile, mkdir, open, readFile, rm, stat, writeFile } from "fs/promises";
18
18
  import ms from "ms";
19
19
  import { homedir } from "os";
@@ -88,12 +88,29 @@ import {
88
88
  type SenderVia,
89
89
  } from "./senderAncestry.ts";
90
90
  import yargs from "yargs";
91
- import { type ResolvedRemote, readRemotes, resolveRemoteSpec } from "./remotes.ts";
91
+ import {
92
+ type RemoteConfig,
93
+ type ResolvedRemote,
94
+ readRemotes,
95
+ resolveRemoteSpec,
96
+ } from "./remotes.ts";
97
+ import {
98
+ cacheAgeLabel,
99
+ hhmm,
100
+ humanizeAge,
101
+ isStale,
102
+ jsonRemote,
103
+ noteSnapshotResult,
104
+ parseWaitFor,
105
+ readRemoteSnapshot,
106
+ type RemoteSnapshot,
107
+ type WaitFor,
108
+ writeRemoteSnapshot,
109
+ } from "./remoteCache.ts";
92
110
  import {
93
111
  noteRemoteResult,
94
112
  pruneRemoteHealth,
95
113
  readRemoteHealth,
96
- remoteBackoffMs,
97
114
  shouldSkipRemote,
98
115
  writeRemoteHealth,
99
116
  } from "./remoteHealth.ts";
@@ -642,6 +659,7 @@ function mergeRecords(...buckets: GlobalPidRecord[][]): GlobalPidRecord[] {
642
659
  // this JS layer; keep the two lists in sync.
643
660
  const SUBCOMMANDS = new Set([
644
661
  "ls",
662
+ "ls-refresh",
645
663
  "list",
646
664
  "ps",
647
665
  "status",
@@ -1061,6 +1079,8 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
1061
1079
  return await cmdSend(rest);
1062
1080
  case "send-drain":
1063
1081
  return await cmdSendDrain(rest);
1082
+ case "ls-refresh":
1083
+ return await cmdLsRefresh(rest);
1064
1084
  case "msgs":
1065
1085
  return await cmdMsgs(rest);
1066
1086
  case "key":
@@ -1322,7 +1342,8 @@ export async function cmdHelp(managerCommands = true): Promise<number> {
1322
1342
  `ay - agent-yes CLI\n` +
1323
1343
  `\n` +
1324
1344
  `Management:\n` +
1325
- ` ay ls [keyword] list running agents\n` +
1345
+ ` ay ls [keyword] list running agents (remotes from cache, refreshed\n` +
1346
+ ` [--waitFor=all|local|<alias>] in the background; --waitFor blocks for fresh rows)\n` +
1326
1347
  ` ay ps [keyword] per-agent CPU/RSS, rolled up over each\n` +
1327
1348
  ` agent's whole process tree, + box vitals\n` +
1328
1349
  ` ay tail [-f] [-n N] <keyword> last N lines (96), -f to follow\n` +
@@ -1703,6 +1724,16 @@ async function runRemoteLs(
1703
1724
  return 1;
1704
1725
  }
1705
1726
  const records = (await res.json()) as any[];
1727
+ // A direct `ay ls <alias>` is the freshest data there is — fold it into the
1728
+ // cached-first fleet listing's snapshot for that alias (no keyword filter,
1729
+ // or the cache would hold a subset and print it as the whole machine).
1730
+ if (!remote.keyword && (await readRemotes()).has(remote.label)) {
1731
+ const now = Date.now();
1732
+ const prev = await readRemoteSnapshot(remote.label);
1733
+ await writeRemoteSnapshot(
1734
+ noteSnapshotResult(prev, remote.label, { ok: true, records }, now),
1735
+ ).catch(() => {});
1736
+ }
1706
1737
  if (records.length === 0) {
1707
1738
  process.stderr.write(
1708
1739
  remote.keyword
@@ -2185,7 +2216,7 @@ async function fetchRemoteRecordsRaw(
2185
2216
  * would mark a live-but-empty host failed and hide it for up to 32 minutes,
2186
2217
  * which is the failure this whole change exists to avoid.
2187
2218
  */
2188
- onReachable?: (ok: boolean) => void,
2219
+ onReachable?: (ok: boolean, why?: string) => void,
2189
2220
  ): Promise<any[]> {
2190
2221
  const params = new URLSearchParams();
2191
2222
  if (opts.all) params.set("all", "1");
@@ -2211,18 +2242,109 @@ async function fetchRemoteRecordsRaw(
2211
2242
  onReachable?.(true);
2212
2243
  if (!res.ok) return [];
2213
2244
  return (await res.json()) as any[];
2214
- } catch {
2215
- onReachable?.(false);
2245
+ } catch (e) {
2246
+ // First line only: the WebRTC viewer's error is a multi-line explanation
2247
+ // (ts/webrtcSignal.ts); the snapshot keeps the headline for the age tag.
2248
+ onReachable?.(false, String((e as Error)?.message ?? e).split("\n")[0]);
2216
2249
  return [];
2217
2250
  } finally {
2218
2251
  bridge?.close();
2219
2252
  }
2220
2253
  }
2221
2254
 
2255
+ /** Probe one remote and fold the outcome into its snapshot. Never throws. */
2256
+ async function refreshRemoteSnapshot(
2257
+ alias: string,
2258
+ cfg: RemoteConfig,
2259
+ opts: { all: boolean; active: boolean; keyword?: string },
2260
+ now: number,
2261
+ ): Promise<{ snap: RemoteSnapshot; ok: boolean }> {
2262
+ let ok = false;
2263
+ let why = "did not answer";
2264
+ const records = await fetchRemoteRecordsRaw(cfg.url, cfg.token, opts, (r, w) => {
2265
+ ok = r;
2266
+ if (w) why = w;
2267
+ });
2268
+ const prev = await readRemoteSnapshot(alias);
2269
+ const snap = noteSnapshotResult(
2270
+ prev,
2271
+ alias,
2272
+ ok ? { ok: true, records } : { ok: false, message: why },
2273
+ now,
2274
+ );
2275
+ await writeRemoteSnapshot(snap).catch(() => {});
2276
+ return { snap, ok };
2277
+ }
2278
+
2279
+ /** Run `ay <args…>` detached from this process (same launcher as the send drainer). */
2280
+ function spawnDetachedAy(args: string[]): number | null {
2281
+ const launcher = drainerLauncher(process.execPath, process.argv[1], Bun.which("ay"));
2282
+ if (!launcher) return null;
2283
+ const [cmd, ...pre] = launcher;
2284
+ try {
2285
+ const child = spawnChild(cmd!, [...pre, ...args], { detached: true, stdio: "ignore" });
2286
+ child.on("error", () => {});
2287
+ child.unref();
2288
+ return child.pid ?? null;
2289
+ } catch {
2290
+ return null;
2291
+ }
2292
+ }
2293
+
2294
+ /**
2295
+ * `ay ls-refresh <alias…> [--all] [--active] [--keyword k]` — hidden: the
2296
+ * background half of cached-first `ay ls`. Probes each alias once, writes its
2297
+ * snapshot and health, and exits. One refresher per alias at a time (pid
2298
+ * lock), so a burst of listings does not fan out into a burst of probes.
2299
+ */
2300
+ async function cmdLsRefresh(rest: string[]): Promise<number> {
2301
+ const aliases: string[] = [];
2302
+ const opts = { all: false, active: false, keyword: undefined as string | undefined };
2303
+ for (let i = 0; i < rest.length; i++) {
2304
+ const a = rest[i]!;
2305
+ if (a === "--all") opts.all = true;
2306
+ else if (a === "--active") opts.active = true;
2307
+ else if (a === "--keyword") opts.keyword = rest[++i];
2308
+ else if (a.startsWith("--keyword=")) opts.keyword = a.slice("--keyword=".length);
2309
+ else if (!a.startsWith("-")) aliases.push(a);
2310
+ }
2311
+ const remotes = await readRemotes();
2312
+ const now = Date.now();
2313
+ await Promise.all(
2314
+ aliases.map(async (alias) => {
2315
+ const cfg = remotes.get(alias);
2316
+ if (!cfg) return;
2317
+ const lock = path.join(
2318
+ agentYesHome(),
2319
+ "remotes",
2320
+ `${encodeURIComponent(alias)}.refresh.lock`,
2321
+ );
2322
+ const owner = Number((await readFile(lock, "utf-8").catch(() => "")).trim());
2323
+ if (owner > 0 && owner !== process.pid && pidAlive(owner)) return;
2324
+ await mkdir(path.dirname(lock), { recursive: true }).catch(() => {});
2325
+ await writeFile(lock, String(process.pid)).catch(() => {});
2326
+ try {
2327
+ const { ok } = await refreshRemoteSnapshot(alias, cfg, opts, now);
2328
+ const health = await readRemoteHealth();
2329
+ health[alias] = noteRemoteResult(health[alias], ok, now);
2330
+ await writeRemoteHealth(health).catch(() => {});
2331
+ } finally {
2332
+ await rm(lock, { force: true }).catch(() => {});
2333
+ }
2334
+ }),
2335
+ );
2336
+ return 0;
2337
+ }
2338
+
2222
2339
  async function runAllRemotesLs(opts: {
2223
2340
  all: boolean;
2224
2341
  active: boolean;
2225
2342
  keyword?: string;
2343
+ /** Aliases to refresh in the foreground before printing; null = cached-first. */
2344
+ waitFor: WaitFor | null;
2345
+ /** Per-remote budget for a foreground refresh. */
2346
+ timeoutMs: number;
2347
+ json: boolean;
2226
2348
  }): Promise<number> {
2227
2349
  const remotes = await readRemotes();
2228
2350
  const localOpts: CommonOpts = {
@@ -2233,50 +2355,96 @@ async function runAllRemotesLs(opts: {
2233
2355
  cwdScope: null,
2234
2356
  };
2235
2357
 
2236
- // A remote that is not answering costs a full connect timeout (25s for a
2237
- // WebRTC spec) and returns nothing, and this gathers them together — so two
2238
- // dead entries made every `ay ls` here take 27s instead of 5s. Skip a host
2239
- // that has been failing, on a doubling backoff, and keep LISTING it as
2240
- // unreachable so the config stays honest and the operator can see it is being
2241
- // skipped rather than silently dropped. See ts/remoteHealth.ts.
2358
+ // Cached-first. Remote rows come from each alias's last snapshot
2359
+ // (ts/remoteCache.ts) and print at once; a stale one is refreshed by a
2360
+ // detached `ay ls-refresh` so the NEXT listing is fresh. Only the aliases
2361
+ // named by --waitFor are fetched in the foreground, each on its own budget,
2362
+ // so one dead machine cannot hold the fleet view hostage (a WebRTC remote
2363
+ // takes 25 s to give up; two of them made every `ay ls` here take 27 s).
2364
+ // The backoff in ts/remoteHealth.ts still governs the background refreshers,
2365
+ // so a host that keeps failing is not re-probed on every listing. --waitFor
2366
+ // forces the probe — that is what it is for.
2242
2367
  const health = await readRemoteHealth();
2243
2368
  const healthNow = Date.now();
2244
- const skipped = new Set(
2245
- [...remotes.keys()].filter((alias) => shouldSkipRemote(health[alias], healthNow)),
2246
- );
2247
- const probed = [...remotes.entries()].filter(([alias]) => !skipped.has(alias));
2369
+ const waiting = new Set(opts.waitFor?.remotes ?? []);
2370
+ const fetchOpts = { all: opts.all, active: opts.active, keyword: opts.keyword };
2371
+ type Section = {
2372
+ alias: string;
2373
+ snap: RemoteSnapshot | null;
2374
+ live: boolean;
2375
+ refreshing: boolean;
2376
+ failure: string | null;
2377
+ };
2248
2378
 
2249
- const [localResult, ...remoteResults] = await Promise.allSettled([
2379
+ const [localResult, ...waited] = await Promise.allSettled([
2250
2380
  listRecords(opts.keyword, localOpts).then((recs) => ({
2251
2381
  host: "local",
2252
2382
  records: recs as any[],
2253
2383
  })),
2254
- ...probed.map(([alias, cfg]) => {
2255
- let reachable = false;
2256
- return fetchRemoteRecordsRaw(cfg.url, cfg.token, opts, (ok) => {
2257
- reachable = ok;
2258
- }).then((records) => ({ host: alias, records, ok: reachable }));
2259
- }),
2384
+ ...[...remotes.entries()]
2385
+ .filter(([alias]) => waiting.has(alias))
2386
+ .map(async ([alias, cfg]) => {
2387
+ const budget = new Promise<"timeout">((resolve) => {
2388
+ const t = setTimeout(() => resolve("timeout"), opts.timeoutMs);
2389
+ (t as any).unref?.();
2390
+ });
2391
+ const res = await Promise.race([
2392
+ refreshRemoteSnapshot(alias, cfg, fetchOpts, healthNow),
2393
+ budget,
2394
+ ]);
2395
+ if (res === "timeout")
2396
+ return {
2397
+ alias,
2398
+ snap: await readRemoteSnapshot(alias),
2399
+ ok: false,
2400
+ failure: `timed out after ${Math.round(opts.timeoutMs / 1000)}s`,
2401
+ };
2402
+ const failure = res.ok
2403
+ ? null
2404
+ : res.snap.error
2405
+ ? `unreachable since ${hhmm(res.snap.error.since)} (${res.snap.error.message})`
2406
+ : "unreachable";
2407
+ return { alias, snap: res.snap, ok: res.ok, failure };
2408
+ }),
2260
2409
  ]);
2261
2410
 
2262
- // Record what we learned, then persist once. A host that answered clears its
2263
- // streak; one that did not extends it. Best-effort: a health file we cannot
2264
- // write must never break `ay ls`.
2265
- {
2266
- // Prune first, so an alias removed from remotes.yaml stops being carried
2267
- // forever. `remotes` is the live configured set.
2268
- const next = pruneRemoteHealth({ ...health }, remotes.keys());
2269
- for (const [i, [alias]] of probed.entries()) {
2270
- const res = remoteResults[i];
2271
- const ok = res?.status === "fulfilled" && (res.value as any).ok === true;
2272
- next[alias] = noteRemoteResult(health[alias], ok, healthNow);
2411
+ // Record what the foreground probes learned, then persist once (pruned, so
2412
+ // an alias removed from remotes.yaml stops being carried forever). A health
2413
+ // file we cannot write must never break `ay ls`.
2414
+ const nextHealth = pruneRemoteHealth({ ...health }, remotes.keys());
2415
+ const waitedByAlias = new Map<
2416
+ string,
2417
+ { snap: RemoteSnapshot | null; ok: boolean; failure: string | null }
2418
+ >();
2419
+ for (const r of waited) if (r.status === "fulfilled") waitedByAlias.set(r.value.alias, r.value);
2420
+ const sections: Section[] = [];
2421
+ const background: string[] = [];
2422
+ for (const [alias] of remotes) {
2423
+ const w = waitedByAlias.get(alias);
2424
+ if (w) {
2425
+ nextHealth[alias] = noteRemoteResult(health[alias], w.ok, healthNow);
2426
+ sections.push({ alias, snap: w.snap, live: w.ok, refreshing: false, failure: w.failure });
2427
+ continue;
2273
2428
  }
2274
- await writeRemoteHealth(next).catch(() => {});
2275
- }
2429
+ const snap = await readRemoteSnapshot(alias);
2430
+ const refreshing = isStale(snap, healthNow) && !shouldSkipRemote(health[alias], healthNow);
2431
+ if (refreshing) background.push(alias);
2432
+ sections.push({ alias, snap, live: false, refreshing, failure: null });
2433
+ }
2434
+ await writeRemoteHealth(nextHealth).catch(() => {});
2435
+ if (background.length)
2436
+ spawnDetachedAy([
2437
+ "ls-refresh",
2438
+ ...background,
2439
+ ...(opts.all ? ["--all"] : []),
2440
+ ...(opts.active ? ["--active"] : []),
2441
+ ...(opts.keyword ? ["--keyword", opts.keyword] : []),
2442
+ ]);
2443
+ const failed = sections.some((s) => waiting.has(s.alias) && s.failure);
2276
2444
 
2277
2445
  // Group by host in a stable order (local first, then each remote alias in
2278
2446
  // config order), so the aggregated table reads top-down per machine.
2279
- const byHost: { host: string; records: any[] }[] = [];
2447
+ const byHost: { host: string; records: any[]; cached?: boolean }[] = [];
2280
2448
  // Shared per-invocation caches so local agents in the same repo spawn
2281
2449
  // `git status` once (see gitStatusOnce).
2282
2450
  const gitRootCache = new Map<string, string>();
@@ -2313,47 +2481,67 @@ async function runAllRemotesLs(opts: {
2313
2481
  );
2314
2482
  byHost.push({ host: "local", records: enriched });
2315
2483
  }
2316
- // A skipped host is REPORTED, not dropped. Silently omitting it would make a
2317
- // machine that can come back disappear from the fleet view with nothing to
2318
- // notice — the same failure as deleting it from the config, which is why we
2319
- // did not do that. One line to stderr per skipped host, so `--json` and any
2320
- // parser downstream stay unaffected.
2321
- for (const alias of skipped) {
2322
- const h = health[alias];
2323
- // Time REMAINING, not the window length: a host with one second left would
2324
- // otherwise report "retrying in up to 32m", which is true of the window and
2325
- // false of the wait, and the operator is reading this to decide whether to
2326
- // wait or force a probe.
2327
- const leftMs = Math.max(
2328
- 0,
2329
- remoteBackoffMs(h?.streak ?? 1) - (healthNow - (h?.lastFailedAt ?? healthNow)),
2330
- );
2331
- const left =
2332
- leftMs >= 60_000 ? `${Math.round(leftMs / 60_000)}m` : `${Math.ceil(leftMs / 1000)}s`;
2333
- process.stderr.write(
2334
- `${alias}: unreachable — skipped after ${h?.streak ?? 0} failed attempt(s), ` +
2335
- `retrying in ${left} (ay ls ${alias} to force a probe now)\n`,
2336
- );
2484
+ for (const s of sections)
2485
+ byHost.push({ host: s.alias, records: s.snap?.records ?? [], cached: !s.live });
2486
+
2487
+ if (opts.json) {
2488
+ // Aggregated shape (only when remotes are asked for — plain `ay ls --json`
2489
+ // stays this box's flat array): per remote, where the rows came from and
2490
+ // how old they are, so an orchestrator can decide instead of guessing.
2491
+ const out = {
2492
+ local: byHost.find((h) => h.host === "local")?.records ?? [],
2493
+ remotes: Object.fromEntries(
2494
+ sections.map((s) => [
2495
+ s.alias,
2496
+ {
2497
+ ...jsonRemote(s.snap, healthNow),
2498
+ live: s.live,
2499
+ refreshing: s.refreshing,
2500
+ failure: s.failure,
2501
+ },
2502
+ ]),
2503
+ ),
2504
+ };
2505
+ await writeStdoutFlushed(JSON.stringify(out, null, 2) + "\n");
2506
+ return failed ? 1 : 0;
2337
2507
  }
2338
- for (const res of remoteResults) {
2339
- if (res.status === "fulfilled")
2340
- byHost.push({ host: res.value.host, records: res.value.records });
2508
+
2509
+ // One line per remote FIRST — where its rows came from, how old they are, and
2510
+ // (under --waitFor) what went wrong — so nothing cached can pass for live.
2511
+ if (sections.length) {
2512
+ const aliasW = Math.max(...sections.map((s) => s.alias.length));
2513
+ for (const s of sections)
2514
+ process.stdout.write(
2515
+ `${s.alias.padEnd(aliasW)} ${s.failure ?? (s.live ? "live" : cacheAgeLabel(s.snap, healthNow, s.refreshing))}\n`,
2516
+ );
2517
+ process.stdout.write("\n");
2341
2518
  }
2342
2519
 
2343
2520
  // Flatten each host's records into its agent>subagent forest (parent_pid links),
2344
2521
  // carrying the box-drawing tree prefix — the same nesting the console's left
2345
2522
  // panel shows. Degrades to a flat newest-first list when there are no links.
2346
- type HostedRow = { host: string; rec: any; prefix: string };
2523
+ type HostedRow = { host: string; rec: any; prefix: string; cached: boolean };
2347
2524
  const rows: HostedRow[] = [];
2348
- for (const { host, records } of byHost) {
2525
+ for (const { host, records, cached } of byHost) {
2349
2526
  for (const { record, prefix } of flattenForest(buildAgentForest(records))) {
2350
- rows.push({ host, rec: record, prefix });
2527
+ rows.push({ host, rec: record, prefix, cached: cached === true });
2351
2528
  }
2352
2529
  }
2353
2530
 
2531
+ const anyCached = sections.some((s) => !s.live);
2532
+ const footer = () => {
2533
+ if (rows.some((r) => r.cached))
2534
+ process.stderr.write(`* = from cache (AGE is real; STATUS may have changed since)\n`);
2535
+ if (anyCached)
2536
+ process.stderr.write(
2537
+ `ay ls --waitFor=all # block until fresh data from every local+remote machine\n`,
2538
+ );
2539
+ };
2540
+
2354
2541
  if (rows.length === 0) {
2355
2542
  process.stderr.write("no running agents\n");
2356
- return 0;
2543
+ footer();
2544
+ return failed ? 1 : 0;
2357
2545
  }
2358
2546
 
2359
2547
  const termWidth = (process.stdout as any).columns ?? 120;
@@ -2363,7 +2551,8 @@ async function runAllRemotesLs(opts: {
2363
2551
  const hostW = Math.max(4, ...rows.map((r) => r.host.length));
2364
2552
  const pidW = Math.max(3, ...rows.map((r) => String(r.rec.pid).length));
2365
2553
  const cliW = Math.max(3, ...rows.map((r) => String(r.rec.cli).length));
2366
- const statusW = Math.max(6, ...rows.map((r) => String(r.rec.status).length));
2554
+ const statusOf = (r: HostedRow) => String(r.rec.status) + (r.cached ? "*" : "");
2555
+ const statusW = Math.max(6, ...rows.map((r) => statusOf(r).length));
2367
2556
  const ageW = Math.max(3, ...rows.map((r) => ageOf(r.rec).length));
2368
2557
  const cwdW = Math.max(3, ...rows.map((r) => cwdLabel(r.rec).length));
2369
2558
  const promptBudget = Math.max(
@@ -2382,7 +2571,8 @@ async function runAllRemotesLs(opts: {
2382
2571
  "PROMPT",
2383
2572
  ].join(" ") + "\n",
2384
2573
  );
2385
- for (const { host, rec, prefix } of rows) {
2574
+ for (const row of rows) {
2575
+ const { host, rec, prefix } = row;
2386
2576
  // The tree prefix + task badge + flag chips + git tag live inside the PROMPT
2387
2577
  // column, so they eat into this row's text budget — same as the single-host
2388
2578
  // table. Both local (enriched above) and remote (/api/ls) records carry
@@ -2402,14 +2592,15 @@ async function runAllRemotesLs(opts: {
2402
2592
  host.padEnd(hostW),
2403
2593
  String(rec.pid).padEnd(pidW),
2404
2594
  String(rec.cli).padEnd(cliW),
2405
- String(rec.status).padEnd(statusW),
2595
+ statusOf(row).padEnd(statusW),
2406
2596
  ageOf(rec).padEnd(ageW),
2407
2597
  cwdLabel(rec).padEnd(cwdW),
2408
2598
  label,
2409
2599
  ].join(" ") + "\n",
2410
2600
  );
2411
2601
  }
2412
- return 0;
2602
+ footer();
2603
+ return failed ? 1 : 0;
2413
2604
  }
2414
2605
 
2415
2606
  // ---------------------------------------------------------------------------
@@ -2815,8 +3006,21 @@ async function cmdLs(rest: string[]): Promise<number> {
2815
3006
  description:
2816
3007
  "Only this machine's agents — skip configured remotes (the pre-default behaviour)",
2817
3008
  })
3009
+ .option("waitFor", {
3010
+ type: "string",
3011
+ description:
3012
+ "Block until fresh rows from this set instead of printing cached remote rows: " +
3013
+ "all | local | <alias>[,<alias>…]. Each remote gets --timeout; a failure is reported per remote",
3014
+ })
3015
+ .option("timeout", {
3016
+ type: "number",
3017
+ default: 30,
3018
+ description: "Seconds to wait per remote with --waitFor",
3019
+ })
2818
3020
  .option("help", { alias: "h", type: "boolean", default: false, description: "Show this help" })
2819
- .example("ay ls", "list local + all configured remotes")
3021
+ .example("ay ls", "local live + remotes from their last snapshot (refreshed in the background)")
3022
+ .example("ay ls --waitFor=all", "block until every machine answered (or timed out)")
3023
+ .example("ay ls --json --all-remotes", "{local, remotes:{alias:{fetchedAt, stale, records…}}}")
2820
3024
  .example("ay ls --local", "only this machine's agents")
2821
3025
  .example("ay ls --all", "include exited agents")
2822
3026
  .example("ay ls --json", "machine-readable output")
@@ -2886,14 +3090,30 @@ async function cmdLs(rest: string[]): Promise<number> {
2886
3090
  // The human table now spans local + every configured remote by DEFAULT (one
2887
3091
  // fleet view across machines). `--local` opts back to this machine only, and
2888
3092
  // a single-machine box (no remotes configured) keeps the richer local-only
2889
- // table automatically. The programmatic paths above (--watch) and the --json
2890
- // path below stay LOCAL-only on purpose: orchestrators parse them and expect
2891
- // this box's pids, and the aggregated view is a flat human table without the
2892
- // forest/notes/badges those consumers don't need.
2893
- if (!argv.local && !opts.json && !argv.latest) {
2894
- const remotes = await readRemotes();
2895
- if (argv["all-remotes"] || remotes.size > 0) {
2896
- return runAllRemotesLs({ all: argv.all, active: argv.active, keyword });
3093
+ // table automatically. The programmatic paths (--watch, plain --json) stay
3094
+ // LOCAL-only on purpose: orchestrators parse them and expect this box's
3095
+ // pids. `--json` opts into the aggregated shape with --all-remotes or
3096
+ // --waitFor, where every remote carries fetchedAt/stale for the parser.
3097
+ const remotesCfg = await readRemotes();
3098
+ let waitFor: WaitFor | null = null;
3099
+ if (typeof argv.waitFor === "string") {
3100
+ const parsed = parseWaitFor(argv.waitFor, [...remotesCfg.keys()]);
3101
+ if ("error" in parsed) {
3102
+ process.stderr.write(parsed.error + "\n");
3103
+ return 2;
3104
+ }
3105
+ waitFor = parsed;
3106
+ }
3107
+ if (!argv.local && !argv.latest && (!opts.json || argv["all-remotes"] || waitFor)) {
3108
+ if (argv["all-remotes"] || waitFor || remotesCfg.size > 0) {
3109
+ return runAllRemotesLs({
3110
+ all: argv.all,
3111
+ active: argv.active,
3112
+ keyword,
3113
+ waitFor,
3114
+ timeoutMs: Math.max(1, Number(argv.timeout) || 30) * 1000,
3115
+ json: opts.json,
3116
+ });
2897
3117
  }
2898
3118
  }
2899
3119
 
@@ -3068,18 +3288,6 @@ async function cmdLs(rest: string[]): Promise<number> {
3068
3288
  return 0;
3069
3289
  }
3070
3290
 
3071
- function humanizeAge(ms: number): string {
3072
- if (ms < 1000) return "0s";
3073
- const s = Math.floor(ms / 1000);
3074
- if (s < 60) return `${s}s`;
3075
- const m = Math.floor(s / 60);
3076
- if (m < 60) return `${m}m`;
3077
- const h = Math.floor(m / 60);
3078
- if (h < 24) return `${h}h`;
3079
- const d = Math.floor(h / 24);
3080
- return `${d}d`;
3081
- }
3082
-
3083
3291
  function shortenPath(p: string): string {
3084
3292
  const home = homedir();
3085
3293
  return p.startsWith(home) ? "~" + p.slice(home.length) : p;
@@ -4385,7 +4593,13 @@ export async function readComposerState(
4385
4593
  });
4386
4594
  await new Promise<void>((resolve) => term.write(buf, resolve));
4387
4595
  dropComposerTerm(logPath);
4388
- composerTerms.set(logPath, { term, key, window: w, offset: from + buf.length, whole: from === 0 });
4596
+ composerTerms.set(logPath, {
4597
+ term,
4598
+ key,
4599
+ window: w,
4600
+ offset: from + buf.length,
4601
+ whole: from === 0,
4602
+ });
4389
4603
  if (composerTerms.size > COMPOSER_TERMS_MAX)
4390
4604
  dropComposerTerm(composerTerms.keys().next().value!); // oldest first
4391
4605
  rows = rowsFromXterm(term as never, 40);
@@ -4399,7 +4613,10 @@ export async function readComposerState(
4399
4613
  return state;
4400
4614
  } catch (e) {
4401
4615
  dropComposerTerm(logPath);
4402
- return { kind: "unknown", reason: `log unreadable or render failed: ${(e as Error)?.message ?? e}` };
4616
+ return {
4617
+ kind: "unknown",
4618
+ reason: `log unreadable or render failed: ${(e as Error)?.message ?? e}`,
4619
+ };
4403
4620
  }
4404
4621
  }
4405
4622
 
@@ -4824,15 +5041,41 @@ export function inspectSubmission(
4824
5041
  composerVisible: false,
4825
5042
  inComposer: false,
4826
5043
  };
4827
- // The same boundary the draft check uses: a quoted `› line` inside our own
4828
- // unsent message must not be taken for the prompt (that would count the
4829
- // message's nonce as transcript evidence — codex review).
4830
- const prompt = composerPromptRow(screen);
4831
- if (prompt < 0 || !identity.trim()) return unknown;
5044
+ if (!identity.trim()) return unknown;
4832
5045
  // Strip whitespace to tolerate terminal wrapping of the nonce/header or body.
4833
5046
  const compact = (text: string) => text.replace(/\s+/gu, "");
4834
5047
  const needle = compact(identity);
4835
5048
  const wrapped = identity.startsWith("<ay-msg ");
5049
+ // The same boundary the draft check uses: a quoted `› line` inside our own
5050
+ // unsent message must not be taken for the prompt (that would count the
5051
+ // message's nonce as transcript evidence — codex review).
5052
+ const prompt = composerPromptRow(screen);
5053
+ if (prompt < 0) {
5054
+ // No composer on screen: a plain shell (cmd, bash, an ssh session) has no
5055
+ // `❯` row to inspect. It echoes the submitted line after its prompt and
5056
+ // then prints a NEW prompt below, so a line ending in our raw message that
5057
+ // is not the last line on screen is the evidence. The same line as the
5058
+ // last one is the command still sitting typed-but-unsent. A wrapped
5059
+ // (<ay-msg …>) identity never renders that way, so it stays unknown; so
5060
+ // does a raw send whose text is still only on the final line. Without
5061
+ // this, every `ay send --raw` to a shell reported NOT SUBMITTED (exit 1)
5062
+ // after the command had visibly run.
5063
+ if (wrapped) return unknown;
5064
+ let last = screen.length - 1;
5065
+ while (last >= 0 && !screen[last]!.trim()) last--;
5066
+ const echoed = screen
5067
+ .slice(0, Math.max(0, last))
5068
+ .filter((line) => compact(line).endsWith(needle)).length;
5069
+ return echoed
5070
+ ? {
5071
+ submission: "submitted",
5072
+ retry: false,
5073
+ transcriptMatches: echoed,
5074
+ composerVisible: false,
5075
+ inComposer: false,
5076
+ }
5077
+ : unknown;
5078
+ }
4836
5079
  const transcriptLines = screen.slice(0, prompt);
4837
5080
  const transcript = compact(transcriptLines.join("\n"));
4838
5081
  // For raw sends, a short body such as "x" must not match "Codex" or a
@@ -6633,6 +6876,22 @@ async function cmdAttach(rest: string[]): Promise<number> {
6633
6876
  // untouched). Poll briefly for a real size before the first report.
6634
6877
  for (let i = 0; i < 15 && (!process.stdout.columns || !process.stdout.rows); i++)
6635
6878
  await new Promise((r) => setTimeout(r, 100));
6879
+ // Windows: ConPTY only ever emits DIFFS against its own model of the screen,
6880
+ // so our terminal must hold exactly that screen before we start relaying
6881
+ // bytes — the text snapshot printed above is not it. A size CHANGE makes
6882
+ // ConPTY repaint the whole viewport with absolute addressing, which is the
6883
+ // one way to get in sync; a same-size report repaints nothing. So nudge the
6884
+ // PTY one row short, let the 250 ms poll apply it, then report the real
6885
+ // size. (Observed without this: typed characters doubled, the remote prompt
6886
+ // repeated down the screen.)
6887
+ if (process.platform === "win32" && !capMode) {
6888
+ const cols = process.stdout.columns ?? 0;
6889
+ const rows = process.stdout.rows ?? 0;
6890
+ if (cols > 0 && rows > 1) {
6891
+ await writeWinsizeDirect(cols, rows - 1);
6892
+ await new Promise((r) => setTimeout(r, 400));
6893
+ }
6894
+ }
6636
6895
  await sendResize();
6637
6896
  // A one-shot cap fades on the daemon's ~12s TTL, so renew it while attached (cap
6638
6897
  // mode only — a direct winsize write persists and needs no heartbeat).
@@ -6641,10 +6900,37 @@ async function cmdAttach(rest: string[]): Promise<number> {
6641
6900
  if (capMode) void sendResize();
6642
6901
  }, 5000)
6643
6902
  : null;
6903
+ // The direct winsize file is ignored by the agent once it is 30 s old
6904
+ // (rs/src/pty_spawner.rs WINSIZE_STALE_MS — so a dead attach cannot pin the
6905
+ // size forever). On Windows the agent polls that file every 250 ms, so a
6906
+ // one-shot write meant the PTY silently snapped back to the agent's own
6907
+ // console size 30 s into every session: the remote shell got a resize, redrew
6908
+ // its prompt, and from then on every ConPTY diff targeted a geometry this
6909
+ // terminal did not have. Keep the file fresh while attached — timestamp only,
6910
+ // no SIGWINCH, so an unchanged size never triggers a redraw.
6911
+ const directHeartbeat = !capMode
6912
+ ? setInterval(() => {
6913
+ const cols = process.stdout.columns ?? 0;
6914
+ const rows = process.stdout.rows ?? 0;
6915
+ if (cols > 0 && rows > 0)
6916
+ void writeFile(winsizePath, `${cols} ${rows} ${Date.now()}\n`).catch(() => {});
6917
+ }, 10_000)
6918
+ : null;
6644
6919
  // Release the cap promptly on detach (don't wait out the TTL) so the agent
6645
6920
  // re-negotiates back to the remaining viewers / its real tty immediately.
6646
6921
  const withdrawAttachCap = async () => {
6647
6922
  if (capHeartbeat) clearInterval(capHeartbeat);
6923
+ if (directHeartbeat) clearInterval(directHeartbeat);
6924
+ // Direct mode: drop our size override now rather than letting it age out,
6925
+ // so the agent returns to its own console size at once.
6926
+ if (!capMode) {
6927
+ await rm(winsizePath, { force: true }).catch(() => {});
6928
+ try {
6929
+ process.kill(record.pid, "SIGWINCH");
6930
+ } catch {
6931
+ /* agent gone, or no SIGWINCH on this platform — the poll/TTL handles it */
6932
+ }
6933
+ }
6648
6934
  if (!daemonBase) return; // "" token is valid — gate on the base, not the token
6649
6935
  try {
6650
6936
  // Withdraw by reporting presence for THIS pid with NO cap: the daemon then