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.
- package/dist/{SUPPORTED_CLIS-CWJr4TJH.js → SUPPORTED_CLIS-BIg_X-jE.js} +2 -2
- package/dist/{SUPPORTED_CLIS-ChSO8FiS.js → SUPPORTED_CLIS-D7lOxOnw.js} +3 -3
- package/dist/{agentShare-NkW0b0mg.js → agentShare-BfHUZgk4.js} +4 -4
- package/dist/{callback-CjlaFXp1.js → callback-COR0F2eB.js} +3 -3
- package/dist/{callback-B8CUyag4.js → callback-yWhCmi4k.js} +3 -3
- package/dist/{channels-aYwLMbFV.js → channels-rhxQArqN.js} +2 -2
- package/dist/cli.js +5 -5
- package/dist/{cmdPs-C8DIl8V6.js → cmdPs-B3jTE4f3.js} +3 -3
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-DWIfzEE7.js → notifyDaemon-BIo7Nkk2.js} +3 -3
- package/dist/{parentPingLoop-Bh3KuFcW.js → parentPingLoop-9T4Pzp6c.js} +2 -2
- package/dist/{remotes-B4v0Nowo.js → remotes-B4CNXwDS.js} +2 -2
- package/dist/{remotes-DnMQWaqQ.js → remotes-CQTEY8Gt.js} +1 -1
- package/dist/{rustBinary-gI_r3g9K.js → rustBinary-CJXUPUWd.js} +48 -5
- package/dist/rustBinary-FhGG3Yfl.js +4 -0
- package/dist/{schedule-DryC--yx.js → schedule-DmijwnTA.js} +4 -4
- package/dist/{serve-DVoYVgBo.js → serve-F5tXYO0-.js} +99 -32
- package/dist/{serveRust-DDQiZmMf.js → serveRust-BAawe83i.js} +16 -2
- package/dist/{setup--1zRHaGW.js → setup-mCb1JbTX.js} +2 -2
- package/dist/{share-C9oGvjXT.js → share-CLataFnI.js} +18 -12
- package/dist/share-nmgSIjYP.js +4 -0
- package/dist/{shareCmd-BI3hRWGO.js → shareCmd-aOKimJeT.js} +25 -7
- package/dist/{subcommands-BviFOQZE.js → subcommands-DRq0MgSm.js} +2 -2
- package/dist/{subcommands-UWLkF5pq.js → subcommands-Ddo2TYZs.js} +404 -77
- package/dist/{terminal-DviH1E3C.js → terminal-DEXrWOa9.js} +2 -2
- package/dist/trayApp-0RVjo43f.js +5 -0
- package/dist/{trayApp-PJrwfyDs.js → trayApp-BUXx7cES.js} +2 -2
- package/dist/{ts--JmSY117.js → ts-CRbycTni.js} +5 -5
- package/dist/{versionChecker-CCGSbjio.js → versionChecker-DeQEhRGB.js} +2 -2
- package/dist/{webrtcRemote-Cd4vTrgv.js → webrtcRemote-Cy5kB8aR.js} +115 -14
- package/dist/{widget-DgBDIGRQ.js → widget-DneXqnL8.js} +3 -3
- package/dist/{ws-DnBg8-g-.js → ws-DPw1lVTi.js} +3 -3
- package/dist/{ws-dc7MP054.js → ws-Dr-0Ckq7.js} +2 -2
- package/package.json +1 -1
- package/ts/remoteCache.bun.spec.ts +191 -0
- package/ts/remoteCache.spec.ts +191 -0
- package/ts/remoteCache.ts +193 -0
- package/ts/rustBinary.bun.spec.ts +39 -2
- package/ts/rustBinary.spec.ts +53 -2
- package/ts/rustBinary.ts +61 -3
- package/ts/serve.spec.ts +103 -0
- package/ts/serve.ts +100 -15
- package/ts/serveRust.spec.ts +24 -1
- package/ts/serveRust.ts +19 -0
- package/ts/share.bun.spec.ts +54 -2
- package/ts/share.spec.ts +54 -2
- package/ts/share.ts +40 -15
- package/ts/shareCmd.ts +33 -2
- package/ts/strayServe.ts +1 -0
- package/ts/subcommands.spec.ts +36 -3
- package/ts/subcommands.ts +384 -98
- package/ts/webrtcRemote.ts +66 -20
- package/ts/webrtcSignal.bun.spec.ts +120 -0
- package/ts/webrtcSignal.spec.ts +120 -0
- package/ts/webrtcSignal.ts +143 -0
- package/dist/rustBinary-BNmAJEBb.js +0 -4
- package/dist/share-Bmnd_qT0.js +0 -4
- 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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
2237
|
-
//
|
|
2238
|
-
//
|
|
2239
|
-
//
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
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
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
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, ...
|
|
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
|
-
...
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
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
|
|
2263
|
-
//
|
|
2264
|
-
// write must never break `ay ls`.
|
|
2265
|
-
{
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
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
|
|
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
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
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
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
|
2890
|
-
//
|
|
2891
|
-
//
|
|
2892
|
-
//
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
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, {
|
|
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 {
|
|
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
|
-
|
|
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
|