agent-yes 1.169.0 → 1.170.1

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 (62) hide show
  1. package/dist/{SUPPORTED_CLIS-D1WUoZA9.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
  3. package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
  4. package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
  5. package/dist/cli.js +10 -212
  6. package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
  7. package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
  8. package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
  9. package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
  10. package/dist/index.js +4 -4
  11. package/dist/notifyDaemon-D5jtpCAR.js +591 -0
  12. package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
  13. package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
  14. package/dist/pidStore-CLxwp0qI.js +5 -0
  15. package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
  16. package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
  17. package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
  18. package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
  19. package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
  20. package/dist/rustBinary-DsO0IlNq.js +228 -0
  21. package/dist/{schedule-DTHFMzDj.js → schedule-ChTPIcXf.js} +7 -7
  22. package/dist/{serve-D-joIHrL.js → serve-De1gAWNI.js} +120 -26
  23. package/dist/{setup-K6VTmirE.js → setup-nlMqyEPB.js} +3 -3
  24. package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
  25. package/dist/spawnGate-BFhva-2F.js +5 -0
  26. package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
  27. package/dist/{subcommands-BZPW0UVn.js → subcommands-BFlpV23b.js} +1049 -44
  28. package/dist/subcommands-DLDpwrfG.js +9 -0
  29. package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
  30. package/dist/{ts-DeahYDN3.js → ts-DsxejviJ.js} +10 -10
  31. package/dist/{versionChecker-C7KP-aoh.js → versionChecker-Czk3aHqy.js} +2 -2
  32. package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
  33. package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
  34. package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
  35. package/lab/ui/console-logic.js +79 -10
  36. package/lab/ui/index.html +174 -8
  37. package/lab/ui/landing.html +11 -1
  38. package/package.json +1 -1
  39. package/scripts/build-rs.ts +9 -5
  40. package/ts/badges.spec.ts +51 -1
  41. package/ts/badges.ts +35 -1
  42. package/ts/index.ts +13 -4
  43. package/ts/notifyDaemon.spec.ts +276 -0
  44. package/ts/notifyDaemon.ts +698 -0
  45. package/ts/notifyInbox.spec.ts +192 -0
  46. package/ts/notifyInbox.ts +294 -0
  47. package/ts/notifyRouter.spec.ts +355 -0
  48. package/ts/notifyRouter.ts +288 -0
  49. package/ts/notifyStore.spec.ts +308 -0
  50. package/ts/notifyStore.ts +439 -0
  51. package/ts/reaper.spec.ts +18 -1
  52. package/ts/reaper.ts +33 -1
  53. package/ts/rustBinary.ts +30 -0
  54. package/ts/serve.ts +180 -14
  55. package/ts/subcommands.spec.ts +341 -0
  56. package/ts/subcommands.ts +764 -29
  57. package/ts/workspaceConfig.spec.ts +48 -0
  58. package/ts/workspaceConfig.ts +46 -3
  59. package/dist/SUPPORTED_CLIS-BFV10E3f.js +0 -8
  60. package/dist/pidStore-5qyLX3Fh.js +0 -5
  61. package/dist/spawnGate-CQ1Il3Xk.js +0 -5
  62. package/dist/subcommands-DuGwtmz5.js +0 -9
package/ts/subcommands.ts CHANGED
@@ -18,7 +18,8 @@ import path from "path";
18
18
  import { type GlobalPidRecord, readGlobalPids, updateGlobalPidStatus } from "./globalPidIndex.ts";
19
19
  import { buildAgentForest, flattenForest } from "./agentTree.ts";
20
20
  import { parseTaskCounts, type TaskCounts } from "./todoParse.ts";
21
- import { matchBadges } from "./badges.ts";
21
+ import { agentYesHome } from "./agentYesHome.ts";
22
+ import { badgeDef, matchBadges, TYPING_BADGE } from "./badges.ts";
22
23
  import {
23
24
  classifyNeedsInput,
24
25
  isWorkingScreen,
@@ -27,6 +28,15 @@ import {
27
28
  type NeedsInput,
28
29
  } from "./needsInput.ts";
29
30
  import { diffLsStates, type LiveState, type LsAgentState } from "./lsWatch.ts";
31
+ import { filterSinceSeq, filterSinceTs, filterUnread, maxSeq, type NotifyEvent } from "./notifyInbox.ts";
32
+ import {
33
+ clearWatcher,
34
+ getCursor,
35
+ heartbeatWatcher,
36
+ hostId,
37
+ readInbox,
38
+ setCursor,
39
+ } from "./notifyStore.ts";
30
40
  import {
31
41
  buildStoredResult,
32
42
  normalizeEnvelope,
@@ -247,12 +257,16 @@ function mergeRecords(...buckets: GlobalPidRecord[][]): GlobalPidRecord[] {
247
257
 
248
258
  // Subcommands EVERY *-yes binary accepts — inspection/messaging over the shared
249
259
  // agent registry (`cy ls`, `cy send`, `cy tail`, …).
260
+ // MIRRORED in rs/src/cli.rs `SUBCOMMANDS` — the Rust runner delegates these to
261
+ // this JS layer; keep the two lists in sync.
250
262
  const SUBCOMMANDS = new Set([
251
263
  "ls",
252
264
  "list",
253
265
  "ps",
254
266
  "status",
255
267
  "result",
268
+ "notify",
269
+ "notifyd",
256
270
  "read",
257
271
  "cat",
258
272
  "tail",
@@ -291,6 +305,25 @@ const STUCK_THRESHOLD_MS = (() => {
291
305
  return Number.isFinite(n) && n > 0 ? n : 5 * 60 * 1000;
292
306
  })();
293
307
 
308
+ // `ay send` submit-confirm tuning. A long/multi-line body pasted via bracketed
309
+ // paste can take longer than any fixed delay to finish rendering — sending the
310
+ // trailing Enter before that settles gets swallowed by the CLI's paste handling
311
+ // (it lands mid-paste instead of submitting). So instead of a blind fixed sleep,
312
+ // we poll the log for actual quiet, then confirm the Enter landed by watching for
313
+ // either a `working` busy marker or a meaningful size bump, retrying if not.
314
+ const SEND_SETTLE_QUIET_MS = 150; // no log growth for this long → paste finished rendering
315
+ const SEND_SETTLE_MAX_MS = 1500; // cap: don't wait forever on a screen that's busy for other reasons
316
+ const SEND_CONFIRM_QUIET_MS = 400; // after Enter, no growth for this long → response has settled
317
+ const SEND_CONFIRM_MAX_MS = 1200; // cap per confirm attempt
318
+ const SEND_CONFIRM_MIN_GROWTH_BYTES = 8; // filters out pure cursor-blink/frame noise
319
+ const SEND_SUBMIT_MAX_RETRIES = 2; // total attempts = 1 + this
320
+ // `ay send` typing-backoff: if the user is actively typing at the target's
321
+ // terminal, injecting our body mid-line would fuse into their text and submit a
322
+ // mangled line. Poll until they pause (activity older than TYPING_WINDOW_MS) or
323
+ // we give up, then send anyway with a warning rather than dropping the message.
324
+ const SEND_TYPING_POLL_MS = 200;
325
+ const SEND_TYPING_MAX_WAIT_MS = 10_000;
326
+
294
327
  /**
295
328
  * Whether `name` is a subcommand. `managerCommands` (default true, for the
296
329
  * generic `ay`/`agent-yes` entry) additionally admits manager-only commands
@@ -326,6 +359,10 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
326
359
  return await cmdStatus(rest);
327
360
  case "result":
328
361
  return await cmdResult(rest);
362
+ case "notify":
363
+ return await cmdNotify(rest);
364
+ case "notifyd":
365
+ return await cmdNotifyd(rest);
329
366
  case "read":
330
367
  case "cat":
331
368
  return await cmdRead(rest, { mode: "cat" });
@@ -1076,14 +1113,57 @@ async function runAllRemotesLs(opts: {
1076
1113
  ),
1077
1114
  ]);
1078
1115
 
1079
- type HostedRow = { host: string; rec: any };
1080
- const rows: HostedRow[] = [];
1116
+ // Group by host in a stable order (local first, then each remote alias in
1117
+ // config order), so the aggregated table reads top-down per machine.
1118
+ const byHost: { host: string; records: any[] }[] = [];
1119
+ // Shared per-invocation caches so local agents in the same repo spawn
1120
+ // `git status` once (see gitStatusOnce).
1121
+ const gitRootCache = new Map<string, string>();
1122
+ const gitInfoCache = new Map<string, GitInfo | null>();
1081
1123
  if (localResult.status === "fulfilled") {
1082
- for (const r of localResult.value.records) rows.push({ host: "local", rec: r });
1124
+ // Local records come from listRecords() RAW unlike the remote /api/ls
1125
+ // payload they carry no derived live-state, last_active_at, task counts,
1126
+ // status badges, or git tag, so left alone they'd render a flat "active"
1127
+ // with no age/badge/git. Enrich them to the same shape the API returns, so
1128
+ // local agents get the same idle/needs_input/stuck status, staleness age,
1129
+ // task badges, status-flag chips, and git dirty/sync tag.
1130
+ const enriched = await Promise.all(
1131
+ localResult.value.records.map(async (r) => {
1132
+ const { state, question } = await deriveLiveState(r);
1133
+ const alive = state !== "stopped";
1134
+ const [tasks, badges, git, typing] = alive
1135
+ ? await Promise.all([
1136
+ r.log_file ? extractTaskCounts(r.log_file) : Promise.resolve(null),
1137
+ r.log_file ? extractBadges(r.log_file) : Promise.resolve([]),
1138
+ gitStatusOnce(r.cwd, gitRootCache, gitInfoCache),
1139
+ isUserTyping(r.pid),
1140
+ ])
1141
+ : [null, [], null, false];
1142
+ return {
1143
+ ...r,
1144
+ status: state,
1145
+ question,
1146
+ last_active_at: await deriveLastActiveAt(r),
1147
+ tasks,
1148
+ badges: typing ? [...(badges as string[]), TYPING_BADGE.id] : badges,
1149
+ git,
1150
+ };
1151
+ }),
1152
+ );
1153
+ byHost.push({ host: "local", records: enriched });
1083
1154
  }
1084
1155
  for (const res of remoteResults) {
1085
- if (res.status === "fulfilled") {
1086
- for (const r of res.value.records) rows.push({ host: res.value.host, rec: r });
1156
+ if (res.status === "fulfilled") byHost.push({ host: res.value.host, records: res.value.records });
1157
+ }
1158
+
1159
+ // Flatten each host's records into its agent>subagent forest (parent_pid links),
1160
+ // carrying the box-drawing tree prefix — the same nesting the console's left
1161
+ // panel shows. Degrades to a flat newest-first list when there are no links.
1162
+ type HostedRow = { host: string; rec: any; prefix: string };
1163
+ const rows: HostedRow[] = [];
1164
+ for (const { host, records } of byHost) {
1165
+ for (const { record, prefix } of flattenForest(buildAgentForest(records))) {
1166
+ rows.push({ host, rec: record, prefix });
1087
1167
  }
1088
1168
  }
1089
1169
 
@@ -1093,12 +1173,19 @@ async function runAllRemotesLs(opts: {
1093
1173
  }
1094
1174
 
1095
1175
  const termWidth = (process.stdout as any).columns ?? 120;
1176
+ const now = Date.now();
1177
+ const ageOf = (rec: any) => humanizeAge(now - (rec.last_active_at ?? rec.started_at));
1178
+ const badgeOf = (rec: any) => (rec.tasks ? `${rec.tasks.done}/${rec.tasks.total} ` : "");
1096
1179
  const hostW = Math.max(4, ...rows.map((r) => r.host.length));
1097
1180
  const pidW = Math.max(3, ...rows.map((r) => String(r.rec.pid).length));
1098
1181
  const cliW = Math.max(3, ...rows.map((r) => String(r.rec.cli).length));
1099
1182
  const statusW = Math.max(6, ...rows.map((r) => String(r.rec.status).length));
1183
+ const ageW = Math.max(3, ...rows.map((r) => ageOf(r.rec).length));
1100
1184
  const cwdW = Math.max(3, ...rows.map((r) => shortenPath(String(r.rec.cwd)).length));
1101
- const promptBudget = Math.max(20, termWidth - hostW - pidW - cliW - statusW - cwdW - 5 * 2 - 1);
1185
+ const promptBudget = Math.max(
1186
+ 20,
1187
+ termWidth - hostW - pidW - cliW - statusW - ageW - cwdW - 6 * 2 - 1,
1188
+ );
1102
1189
 
1103
1190
  process.stdout.write(
1104
1191
  [
@@ -1106,18 +1193,33 @@ async function runAllRemotesLs(opts: {
1106
1193
  "PID".padEnd(pidW),
1107
1194
  "CLI".padEnd(cliW),
1108
1195
  "STATUS".padEnd(statusW),
1196
+ "AGE".padEnd(ageW),
1109
1197
  "CWD".padEnd(cwdW),
1110
1198
  "PROMPT",
1111
1199
  ].join(" ") + "\n",
1112
1200
  );
1113
- for (const { host, rec } of rows) {
1114
- const label = rec.prompt ? truncate(`→ ${rec.prompt}`, promptBudget) : "";
1201
+ for (const { host, rec, prefix } of rows) {
1202
+ // The tree prefix + task badge + flag chips + git tag live inside the PROMPT
1203
+ // column, so they eat into this row's text budget — same as the single-host
1204
+ // table. Both local (enriched above) and remote (/api/ls) records carry
1205
+ // `tasks`, `badges`, and `git` in the same shape.
1206
+ const flagStr = badgeLabels(rec.badges);
1207
+ const branchStr = branchLabel(rec.git);
1208
+ const gitStr = gitLabel(rec.git);
1209
+ const deco =
1210
+ badgeOf(rec) +
1211
+ (flagStr ? flagStr + " " : "") +
1212
+ (branchStr ? branchStr + " " : "") +
1213
+ (gitStr ? gitStr + " " : "");
1214
+ const budget = Math.max(8, promptBudget - prefix.length - deco.length);
1215
+ const label = prefix + deco + (rec.prompt ? truncate(`→ ${rec.prompt}`, budget) : "");
1115
1216
  process.stdout.write(
1116
1217
  [
1117
1218
  host.padEnd(hostW),
1118
1219
  String(rec.pid).padEnd(pidW),
1119
1220
  String(rec.cli).padEnd(cliW),
1120
1221
  String(rec.status).padEnd(statusW),
1222
+ ageOf(rec).padEnd(ageW),
1121
1223
  shortenPath(String(rec.cwd)).padEnd(cwdW),
1122
1224
  label,
1123
1225
  ].join(" ") + "\n",
@@ -1156,7 +1258,7 @@ export async function deriveLiveStatus(r: GlobalPidRecord): Promise<"active" | "
1156
1258
  * consumer. Builds on the cheap deriveLiveStatus, then adds the menu (needs_input)
1157
1259
  * override, which DOES read the log tail.
1158
1260
  */
1159
- async function deriveLiveState(
1261
+ export async function deriveLiveState(
1160
1262
  r: GlobalPidRecord,
1161
1263
  ): Promise<{ state: LiveState; question: string | null }> {
1162
1264
  const base = await deriveLiveStatus(r);
@@ -1176,6 +1278,155 @@ async function deriveLiveState(
1176
1278
  return { state: base, question: null };
1177
1279
  }
1178
1280
 
1281
+ /**
1282
+ * When the agent last wrote stdout — the log file's mtime, falling back to
1283
+ * started_at when there's no log yet (freshly spawned). Mirrors serve.ts's
1284
+ * `last_active_at`, so the `ay ls` AGE column measures STALENESS (time since the
1285
+ * agent last produced output) rather than lifetime — matching the console's
1286
+ * left-panel age. A long-lived but quiet agent then reads as stale, not "new".
1287
+ */
1288
+ async function deriveLastActiveAt(r: GlobalPidRecord): Promise<number> {
1289
+ if (!r.log_file) return r.started_at;
1290
+ return stat(r.log_file)
1291
+ .then((s) => s.mtimeMs)
1292
+ .catch(() => r.started_at);
1293
+ }
1294
+
1295
+ // Git dirty/sync counts for one repo, in the shape serve.ts's /api/ls returns
1296
+ // (so `ay ls` can format LOCAL agents' git the same way it formats remote ones,
1297
+ // whose `git` field already arrives in this shape).
1298
+ interface GitInfo {
1299
+ branch: string | null;
1300
+ dirty: boolean;
1301
+ changed: number; // real file changes (excludes submodule pin-bumps & internal dirt)
1302
+ pins: number; // submodule gitlinks pointing at new commit(s) — pin-bump/drift
1303
+ subDirty: number; // submodule has internal changes but its recorded pin is unchanged
1304
+ ahead: number;
1305
+ behind: number;
1306
+ }
1307
+
1308
+ /**
1309
+ * Format a GitInfo into the console's compact tag: "±3" changed files, "⑂2"
1310
+ * submodule pin-bumps, "⊙1" submodule internal dirt, "↑1" ahead, "↓2" behind.
1311
+ * Mirrors gitLabel() in lab/ui/console-logic.js so `ay ls` and the web panel's
1312
+ * left rail read identically. "" when clean / in sync / not a repo.
1313
+ */
1314
+ function gitLabel(g: GitInfo | null | undefined): string {
1315
+ if (!g) return "";
1316
+ const parts: string[] = [];
1317
+ if (g.changed > 0) parts.push("±" + g.changed);
1318
+ if (g.pins > 0) parts.push("⑂" + g.pins);
1319
+ if (g.subDirty > 0) parts.push("⊙" + g.subDirty);
1320
+ if (g.ahead > 0) parts.push("↑" + g.ahead);
1321
+ if (g.behind > 0) parts.push("↓" + g.behind);
1322
+ return parts.join(" ");
1323
+ }
1324
+
1325
+ /**
1326
+ * The checked-out branch as "⎇<name>" (⎇ = the branch/alt-key glyph). Shows the
1327
+ * ACTUAL git branch, which can differ from the worktree folder in the cwd (a
1328
+ * feature branch checked out in .../tree/main, a detached HEAD, etc.). "" when
1329
+ * detached / not a repo. Kept separate from gitLabel so gitLabel stays a mirror
1330
+ * of the web console's tag.
1331
+ */
1332
+ function branchLabel(g: GitInfo | null | undefined): string {
1333
+ return g?.branch ? "⎇" + g.branch : "";
1334
+ }
1335
+
1336
+ /**
1337
+ * Short status-flag chips ("goal", "retry", "limit") for a list of badge ids —
1338
+ * the same flags the console shows, resolved to their labels via badges.ts.
1339
+ * "" when none. (Remote records carry `badges` from /api/ls; local ones are
1340
+ * matched here via extractBadges.)
1341
+ */
1342
+ function badgeLabels(ids: string[] | null | undefined): string {
1343
+ if (!ids || ids.length === 0) return "";
1344
+ return ids.map((id) => badgeDef(id)?.label ?? id).join(" ");
1345
+ }
1346
+
1347
+ // porcelain=v2 --branch parser — mirrors parseGitStatus in serve.ts (that copy
1348
+ // lives inside the serve closure and is watcher-driven, so it can't be shared
1349
+ // without a refactor; keep the two in sync). Submodule pin-bumps/internal dirt
1350
+ // are split out of `changed` so a submodule-heavy repo doesn't read as dirty.
1351
+ function parseGitStatus(out: string): GitInfo {
1352
+ let branch: string | null = null;
1353
+ let ahead = 0;
1354
+ let behind = 0;
1355
+ let changed = 0;
1356
+ let pins = 0;
1357
+ let subDirty = 0;
1358
+ for (const line of out.split("\n")) {
1359
+ if (line.length === 0) continue;
1360
+ if (line[0] === "#") {
1361
+ const head = /^# branch\.head (.+)$/.exec(line);
1362
+ if (head) {
1363
+ branch = head[1] === "(detached)" ? null : head[1]!;
1364
+ continue;
1365
+ }
1366
+ const ab = /^# branch\.ab \+(\d+) -(\d+)/.exec(line);
1367
+ if (ab) {
1368
+ ahead = Number(ab[1]);
1369
+ behind = Number(ab[2]);
1370
+ }
1371
+ continue;
1372
+ }
1373
+ const type = line[0];
1374
+ if (type === "?" || type === "u") {
1375
+ changed++;
1376
+ } else if (type === "1" || type === "2") {
1377
+ const sub = line.split(" ")[2] ?? "N...";
1378
+ if (sub[0] === "S") {
1379
+ if (sub[1] === "C") pins++;
1380
+ else subDirty++;
1381
+ } else {
1382
+ changed++;
1383
+ }
1384
+ }
1385
+ }
1386
+ return { branch, dirty: changed > 0, changed, pins, subDirty, ahead, behind };
1387
+ }
1388
+
1389
+ async function runGitCli(args: string[], cwd: string): Promise<string | null> {
1390
+ try {
1391
+ const proc = Bun.spawn(["git", ...args], {
1392
+ cwd,
1393
+ stdout: "pipe",
1394
+ stderr: "ignore",
1395
+ signal: AbortSignal.timeout(2000),
1396
+ });
1397
+ const out = await new Response(proc.stdout).text();
1398
+ await proc.exited;
1399
+ return proc.exitCode === 0 ? out : null;
1400
+ } catch {
1401
+ return null; // git missing, not a repo, or timed out
1402
+ }
1403
+ }
1404
+
1405
+ /**
1406
+ * One-shot git status for the `ay ls` CLI. serve.ts keeps a per-repo watcher so
1407
+ * its request path never spawns git; a one-shot CLI has no watcher, so it spawns
1408
+ * `git status` directly — but deduped per repo root via the two caches, so N
1409
+ * agents sharing a repo (or its submodules/subdirs) cost ONE `git status`.
1410
+ */
1411
+ async function gitStatusOnce(
1412
+ cwd: string | null | undefined,
1413
+ rootCache: Map<string, string>,
1414
+ infoCache: Map<string, GitInfo | null>,
1415
+ ): Promise<GitInfo | null> {
1416
+ if (!cwd) return null;
1417
+ let root = rootCache.get(cwd);
1418
+ if (root === undefined) {
1419
+ root = ((await runGitCli(["rev-parse", "--show-toplevel"], cwd)) ?? "").trim();
1420
+ rootCache.set(cwd, root);
1421
+ }
1422
+ if (!root) return null; // not a git repo
1423
+ if (infoCache.has(root)) return infoCache.get(root)!;
1424
+ const out = await runGitCli(["status", "--porcelain=v2", "--branch"], root);
1425
+ const info = out != null ? parseGitStatus(out) : null;
1426
+ infoCache.set(root, info);
1427
+ return info;
1428
+ }
1429
+
1179
1430
  async function cmdLs(rest: string[]): Promise<number> {
1180
1431
  const y = yargs(rest)
1181
1432
  .usage(
@@ -1335,12 +1586,21 @@ async function cmdLs(rest: string[]): Promise<number> {
1335
1586
  // context and users on narrow ones don't get an awkwardly-wrapped table.
1336
1587
  const termWidth = (process.stdout as any).columns ?? 120;
1337
1588
 
1589
+ // AGE is time since last stdout (staleness), not lifetime — same signal the
1590
+ // console's left panel shows. Precomputed here (one stat() per agent) so the
1591
+ // width pass and the row pass agree without stat()ing twice.
1592
+ const now = Date.now();
1593
+ const lastActive = new Map<number, number>(
1594
+ await Promise.all(records.map(async (r) => [r.pid, await deriveLastActiveAt(r)] as const)),
1595
+ );
1596
+ const ageOf = (r: GlobalPidRecord) => humanizeAge(now - (lastActive.get(r.pid) ?? r.started_at));
1597
+
1338
1598
  const rawCwds = records.map((r) => shortenPath(r.cwd));
1339
1599
  const widths = {
1340
1600
  pid: Math.max(3, ...records.map((r) => String(r.pid).length)),
1341
1601
  cli: Math.max(3, ...records.map((r) => r.cli.length)),
1342
1602
  status: Math.max(6, ...records.map((r) => r.status.length)),
1343
- age: Math.max(3, ...records.map((r) => humanizeAge(Date.now() - r.started_at).length)),
1603
+ age: Math.max(3, ...records.map((r) => ageOf(r).length)),
1344
1604
  cwd: Math.max(3, ...rawCwds.map((c) => c.length)),
1345
1605
  };
1346
1606
  const fixedWidth = widths.pid + widths.cli + widths.status + widths.age + widths.cwd + 5 * 2; // 5 separators of " "
@@ -1353,38 +1613,60 @@ async function cmdLs(rest: string[]): Promise<number> {
1353
1613
  const forestRows = flattenForest(buildAgentForest(records));
1354
1614
 
1355
1615
  const notes = await readNotes();
1616
+ // Shared per-invocation caches so agents in the same repo spawn `git status`
1617
+ // once (see gitStatusOnce). One `ay ls` call, not one per agent.
1618
+ const gitRootCache = new Map<string, string>();
1619
+ const gitInfoCache = new Map<string, GitInfo | null>();
1356
1620
  const rows = await Promise.all(
1357
1621
  forestRows.map(async ({ record: r, prefix }) => {
1358
1622
  // Same live-state derivation as the --json path: stopped/idle/active, with
1359
1623
  // needs_input when the agent is parked on an unanswered menu.
1360
1624
  const displayStatus: string = (await deriveLiveState(r)).state;
1625
+ const alive = displayStatus !== "stopped";
1361
1626
  const note = notes.get(r.pid);
1362
- // Task progress ("2/5") parsed from the rendered todo block — same source as
1363
- // the console badge. Omitted when no block is detected.
1364
- const tasks =
1365
- displayStatus !== "stopped" && r.log_file ? await extractTaskCounts(r.log_file) : null;
1366
- const badge = tasks ? `${tasks.done}/${tasks.total} ` : "";
1367
- // The tree branch prefix + badge sit inside the NOTE/PROMPT column, so they
1368
- // eat into this row's text budget.
1369
- const budget = Math.max(8, promptBudget - prefix.length - badge.length);
1627
+ // Task progress ("2/5"), status-flag chips ("goal"/"retry"/"limit"), and the
1628
+ // git dirty/sync tag ("±3 ⑂2 ↓1") the same three decorations the console's
1629
+ // left rail shows. Skipped for stopped agents (screen no longer live).
1630
+ const [tasks, flags, git, typing] = alive
1631
+ ? await Promise.all([
1632
+ r.log_file ? extractTaskCounts(r.log_file) : Promise.resolve(null),
1633
+ r.log_file ? extractBadges(r.log_file) : Promise.resolve([]),
1634
+ gitStatusOnce(r.cwd, gitRootCache, gitInfoCache),
1635
+ isUserTyping(r.pid),
1636
+ ])
1637
+ : [null, [], null, false];
1638
+ const taskBadge = tasks ? `${tasks.done}/${tasks.total} ` : "";
1639
+ const flagStr = badgeLabels(typing ? [...(flags as string[]), TYPING_BADGE.id] : flags);
1640
+ const branchStr = branchLabel(git);
1641
+ const gitStr = gitLabel(git);
1642
+ // task badge, flag chips, then the git group (⎇branch + dirty/sync tag) —
1643
+ // compact, single-spaced.
1644
+ const deco =
1645
+ taskBadge +
1646
+ (flagStr ? flagStr + " " : "") +
1647
+ (branchStr ? branchStr + " " : "") +
1648
+ (gitStr ? gitStr + " " : "");
1649
+ // The tree branch prefix + these decorations sit inside the NOTE/PROMPT
1650
+ // column, so they eat into this row's text budget.
1651
+ const budget = Math.max(8, promptBudget - prefix.length - deco.length);
1370
1652
  let label: string;
1371
1653
  let hasNote = false;
1372
1654
  if (note) {
1373
1655
  label = truncate(note, budget);
1374
1656
  hasNote = true;
1375
- } else if (r.log_file && displayStatus !== "stopped") {
1657
+ } else if (r.log_file && alive) {
1376
1658
  const activity = await extractActivity(r.log_file);
1377
1659
  label = truncate(activity ?? (r.prompt ? `→ ${r.prompt}` : ""), budget);
1378
1660
  } else {
1379
1661
  label = truncate(r.prompt ? `→ ${r.prompt}` : "", budget);
1380
1662
  }
1381
- // Note marker + task badge sit after the branch prefix so the tree aligns.
1382
- label = prefix + (hasNote ? "* " : "") + badge + label;
1663
+ // Note marker + decorations sit after the branch prefix so the tree aligns.
1664
+ label = prefix + (hasNote ? "* " : "") + deco + label;
1383
1665
  return {
1384
1666
  pid: String(r.pid),
1385
1667
  cli: r.cli,
1386
1668
  status: displayStatus,
1387
- age: humanizeAge(Date.now() - r.started_at),
1669
+ age: ageOf(r),
1388
1670
  cwd: shortenPath(r.cwd),
1389
1671
  label,
1390
1672
  hasNote,
@@ -2092,7 +2374,7 @@ function cliDefaults(): Promise<Record<string, AgentCliConfig>> {
2092
2374
  * null on any read/render error or an empty log. Shared by the needs_input and
2093
2375
  * stuck classifiers so they don't each re-implement the tail read.
2094
2376
  */
2095
- async function renderLogTailLines(logPath: string, n = 40): Promise<string[] | null> {
2377
+ export async function renderLogTailLines(logPath: string, n = 40): Promise<string[] | null> {
2096
2378
  const TAIL_BYTES = 32 * 1024;
2097
2379
  let buf: Uint8Array;
2098
2380
  try {
@@ -2140,6 +2422,36 @@ export async function extractBadges(logPath: string): Promise<string[]> {
2140
2422
  return matchBadges(lines);
2141
2423
  }
2142
2424
 
2425
+ // Window within which a recorded human keystroke still counts as "the user is
2426
+ // typing" — lights the chip and makes `ay send` back off. Comfortably longer
2427
+ // than the Rust writer's throttle (STDIN_ACTIVITY_THROTTLE_MS) so continuous
2428
+ // typing never flickers off between writes.
2429
+ export const TYPING_WINDOW_MS = 3000;
2430
+
2431
+ // Path to the Rust runner's per-pid stdin-activity marker — the tiny file it
2432
+ // stamps with the unix-ms of the user's last terminal keystroke (never `ay
2433
+ // send`/FIFO input). Mirrors rs/src/fifo.rs `stdin_activity_path`; a plain file
2434
+ // on all platforms (unlike the FIFO, which is a named pipe on Windows).
2435
+ export function stdinActivityPath(pid: number): string {
2436
+ return path.resolve(agentYesHome(), "activity", `${pid}.stdin`);
2437
+ }
2438
+
2439
+ // Epoch-ms of the user's most recent keystroke at this agent's terminal, or
2440
+ // null if never/at rest. A missing or unparseable marker just means "not
2441
+ // typing" — this is a best-effort liveness hint, never a hard signal.
2442
+ export async function lastStdinAt(pid: number): Promise<number | null> {
2443
+ const raw = await readFile(stdinActivityPath(pid), "utf-8").catch(() => null);
2444
+ if (raw === null) return null;
2445
+ const ms = Number(raw.trim());
2446
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
2447
+ }
2448
+
2449
+ // Whether the user typed at this agent's terminal within `windowMs`.
2450
+ export async function isUserTyping(pid: number, windowMs = TYPING_WINDOW_MS): Promise<boolean> {
2451
+ const at = await lastStdinAt(pid);
2452
+ return at !== null && Date.now() - at <= windowMs;
2453
+ }
2454
+
2143
2455
  /**
2144
2456
  * Whether an alive agent is wedged: its log has been silent for at least
2145
2457
  * STUCK_THRESHOLD_MS yet its screen still shows a `working` busy marker (a live
@@ -2398,6 +2710,99 @@ export async function extractMenu(logPath: string, cli: string): Promise<MenuSta
2398
2710
  return parseMenu(lines, { needsInput: cfg.needsInput, working: cfg.working });
2399
2711
  }
2400
2712
 
2713
+ /**
2714
+ * Poll `logFile`'s size until it goes `quietMs` without changing, or `maxWaitMs`
2715
+ * total elapses (whichever first). Returns the final observed size, or null if
2716
+ * the file can't be stat'd. Used by `ay send` both to wait out a paste's render
2717
+ * (before submitting) and to detect whether a submit actually produced output
2718
+ * (after submitting).
2719
+ */
2720
+ export async function waitForLogQuiet(
2721
+ logFile: string,
2722
+ quietMs: number,
2723
+ maxWaitMs: number,
2724
+ ): Promise<number | null> {
2725
+ const pollMs = 50;
2726
+ let lastSize: number | null = null;
2727
+ let lastChangeAt = Date.now();
2728
+ const deadline = Date.now() + maxWaitMs;
2729
+ while (Date.now() < deadline) {
2730
+ const size = await stat(logFile)
2731
+ .then((s) => s.size)
2732
+ .catch(() => null);
2733
+ if (size === null) return null;
2734
+ if (size !== lastSize) {
2735
+ lastSize = size;
2736
+ lastChangeAt = Date.now();
2737
+ } else if (Date.now() - lastChangeAt >= quietMs) {
2738
+ return lastSize;
2739
+ }
2740
+ await new Promise((r) => setTimeout(r, pollMs));
2741
+ }
2742
+ return lastSize;
2743
+ }
2744
+
2745
+ /**
2746
+ * Block while the user is typing at `pid`'s terminal, so `ay send` doesn't inject
2747
+ * mid-line. Polls the stdin-activity marker every SEND_TYPING_POLL_MS until the
2748
+ * user pauses (last keystroke older than the typing window) or `maxWaitMs`
2749
+ * elapses. Returns `{ clear, waitedMs }`: `clear` is true if they paused, false
2750
+ * if still typing at the deadline (caller sends anyway, with a warning).
2751
+ */
2752
+ export async function backoffWhileTyping(
2753
+ pid: number,
2754
+ maxWaitMs: number,
2755
+ ): Promise<{ clear: boolean; waitedMs: number }> {
2756
+ const start = Date.now();
2757
+ const deadline = start + maxWaitMs;
2758
+ let waited = false;
2759
+ while (Date.now() < deadline) {
2760
+ if (!(await isUserTyping(pid))) return { clear: true, waitedMs: waited ? Date.now() - start : 0 };
2761
+ waited = true;
2762
+ await new Promise((r) => setTimeout(r, SEND_TYPING_POLL_MS));
2763
+ }
2764
+ return { clear: false, waitedMs: Date.now() - start };
2765
+ }
2766
+
2767
+ /**
2768
+ * Send the trailing submit code and confirm the CLI actually acted on it —
2769
+ * either a `working` busy marker appears, or the log grows meaningfully. Retries
2770
+ * (re-sending just the trailing code) up to SEND_SUBMIT_MAX_RETRIES times when
2771
+ * neither shows, since a swallowed Enter looks identical to a slow one until we
2772
+ * check. Returns whether submission was confirmed, plus the final rendered tail
2773
+ * (for a caller to show the user when it wasn't).
2774
+ */
2775
+ export async function submitAndConfirm(
2776
+ record: GlobalPidRecord,
2777
+ fifoPath: string,
2778
+ trailing: string,
2779
+ ): Promise<{ confirmed: boolean; screen: string[] }> {
2780
+ const logFile = record.log_file!;
2781
+ const cfg = (await cliDefaults())[record.cli];
2782
+ let screen: string[] = [];
2783
+ for (let attempt = 0; attempt <= SEND_SUBMIT_MAX_RETRIES; attempt++) {
2784
+ const sizeBefore =
2785
+ (await stat(logFile)
2786
+ .then((s) => s.size)
2787
+ .catch(() => null)) ?? 0;
2788
+ // A working marker already on screen BEFORE this attempt (e.g. a busy agent
2789
+ // that queues typed input) proves nothing about whether THIS Enter landed —
2790
+ // it could just be leftover from whatever the agent was already doing. Only
2791
+ // a working marker that WASN'T there before, or actual log growth, counts.
2792
+ const wasAlreadyWorking = isWorkingScreen(
2793
+ (await renderLogTailLines(logFile, 40)) ?? [],
2794
+ cfg?.working,
2795
+ );
2796
+ await writeToIpc(fifoPath, trailing);
2797
+ const sizeAfter = await waitForLogQuiet(logFile, SEND_CONFIRM_QUIET_MS, SEND_CONFIRM_MAX_MS);
2798
+ screen = (await renderLogTailLines(logFile, 40)) ?? [];
2799
+ const grew = sizeAfter !== null && sizeAfter >= sizeBefore + SEND_CONFIRM_MIN_GROWTH_BYTES;
2800
+ const nowWorking = isWorkingScreen(screen, cfg?.working);
2801
+ if ((nowWorking && !wasAlreadyWorking) || grew) return { confirmed: true, screen };
2802
+ }
2803
+ return { confirmed: false, screen };
2804
+ }
2805
+
2401
2806
  /** Poll until the agent is no longer parked on a menu (selection accepted → it
2402
2807
  * resumed / moved on) or the deadline passes. Returns true if it cleared. */
2403
2808
  async function waitForNeedsInputClear(
@@ -2415,6 +2820,12 @@ async function waitForNeedsInputClear(
2415
2820
 
2416
2821
  async function cmdSend(rest: string[]): Promise<number> {
2417
2822
  const y = yargs(rest)
2823
+ // Disable yargs' `--no-<flag>` negation: without this, `--no-wait` is parsed
2824
+ // as negating a phantom `wait` option (argv.wait=false) instead of setting our
2825
+ // explicitly-defined `no-wait`/`noWait` flag — so `--no-wait` silently did
2826
+ // nothing and still ran the (blocking) submit-confirm. The `--async` alias
2827
+ // masked this. No option here has a meaningful `--no-` form to lose.
2828
+ .parserConfiguration({ "boolean-negation": false })
2418
2829
  .usage("Usage: ay send <keyword> <msg|-> [options]")
2419
2830
  .option("code", {
2420
2831
  type: "string",
@@ -2427,7 +2838,15 @@ async function cmdSend(rest: string[]): Promise<number> {
2427
2838
  .option("force", {
2428
2839
  type: "boolean",
2429
2840
  default: false,
2430
- description: "Skip the 'tailed recently' safety check (also: AGENT_YES_FORCE_SEND=1)",
2841
+ description:
2842
+ "Skip the 'tailed recently' safety check and the wait-while-user-typing backoff (also: AGENT_YES_FORCE_SEND=1)",
2843
+ })
2844
+ .option("no-wait", {
2845
+ type: "boolean",
2846
+ default: false,
2847
+ alias: "async",
2848
+ description:
2849
+ "Fire-and-forget: skip the paste-settle wait and submit confirmation, don't retry a swallowed Enter (also: AGENT_YES_SEND_NO_WAIT=1)",
2431
2850
  })
2432
2851
  .help(false)
2433
2852
  .version(false)
@@ -2502,15 +2921,66 @@ async function cmdSend(rest: string[]): Promise<number> {
2502
2921
  : "";
2503
2922
 
2504
2923
  const fullBody = prefix + body;
2924
+ const noWait = Boolean(argv.noWait) || process.env.AGENT_YES_SEND_NO_WAIT === "1";
2925
+
2926
+ // Back off while the user is typing at the target's terminal — injecting our
2927
+ // body mid-line fuses into their text and submits a mangled line. Only for a
2928
+ // real text body; skipped for --force (caller means it), --no-wait
2929
+ // (fire-and-forget), and empty bodies (a bare esc/ctrl-c interrupt is usually
2930
+ // intentional and time-sensitive). Sends anyway after the deadline so a
2931
+ // message is never silently dropped.
2932
+ if (fullBody && !noWait && !force) {
2933
+ const { clear, waitedMs } = await backoffWhileTyping(record.pid, SEND_TYPING_MAX_WAIT_MS);
2934
+ if (!clear) {
2935
+ process.stderr.write(
2936
+ `warning: user still typing at pid ${record.pid} after ${Math.round(waitedMs / 1000)}s — ` +
2937
+ `sending anyway (may interleave with their line). Use --force to skip this wait.\n`,
2938
+ );
2939
+ } else if (waitedMs > 0) {
2940
+ process.stderr.write(
2941
+ `waited ${Math.round(waitedMs / 1000)}s for the user to pause typing before sending.\n`,
2942
+ );
2943
+ }
2944
+ }
2945
+ // Submit-confirm only applies to an actual submit (Enter/CR) with a body and a
2946
+ // log to watch — other trailing codes (esc/ctrl-c/tab/none) don't have a "did
2947
+ // it land" signal in the same sense, and retrying e.g. ctrl-c could
2948
+ // double-interrupt. Checked against the resolved byte, not the code NAME, so
2949
+ // every alias that resolves to Enter (--code=enter or --code=cr) is covered.
2950
+ const canConfirm = trailing === "\r" && Boolean(fullBody) && !noWait;
2951
+ let confirmed = true;
2952
+ let lastScreen: string[] = [];
2505
2953
  if (fullBody && trailing) {
2506
2954
  await writeToIpc(fifoPath, fullBody);
2507
- await new Promise((r) => setTimeout(r, 200));
2508
- await writeToIpc(fifoPath, trailing);
2955
+ if (canConfirm && record.log_file) {
2956
+ // Wait for the paste to actually finish rendering — a long/multi-line body
2957
+ // can take longer than any fixed guess, and sending Enter mid-paste gets
2958
+ // swallowed by the CLI's bracketed-paste handling instead of submitting.
2959
+ await waitForLogQuiet(record.log_file, SEND_SETTLE_QUIET_MS, SEND_SETTLE_MAX_MS);
2960
+ ({ confirmed, screen: lastScreen } = await submitAndConfirm(record, fifoPath, trailing));
2961
+ } else {
2962
+ await new Promise((r) => setTimeout(r, 200));
2963
+ await writeToIpc(fifoPath, trailing);
2964
+ }
2509
2965
  } else {
2510
2966
  await writeToIpc(fifoPath, fullBody + trailing);
2511
2967
  }
2512
2968
  const payload = body + trailing;
2513
- process.stdout.write(`sent to pid ${record.pid} (${record.cli}): ${truncate(payload, 80)}\n`);
2969
+ const status = confirmed ? "sent" : "sent but NOT confirmed submitted";
2970
+ process.stdout.write(
2971
+ `${status} to pid ${record.pid} (${record.cli}): ${truncate(payload, 80)}\n`,
2972
+ );
2973
+ if (!confirmed) {
2974
+ process.stderr.write(
2975
+ `\nwarning: couldn't confirm the CLI acted on it after ${SEND_SUBMIT_MAX_RETRIES + 1} attempt(s) — ` +
2976
+ `it may still be sitting unsubmitted in the prompt. Last screen:\n` +
2977
+ lastScreen
2978
+ .slice(-8)
2979
+ .map((l) => ` ${l}`)
2980
+ .join("\n") +
2981
+ "\n",
2982
+ );
2983
+ }
2514
2984
 
2515
2985
  const replyHint = sender.agent
2516
2986
  ? ` ay send ${sender.agent.pid} "..." # reply to sender\n`
@@ -2525,7 +2995,7 @@ async function cmdSend(rest: string[]): Promise<number> {
2525
2995
  const tip = stopTipForCli(record.cli, record.pid);
2526
2996
  if (tip) process.stderr.write(tip);
2527
2997
  }
2528
- return 0;
2998
+ return confirmed ? 0 : 1;
2529
2999
  }
2530
3000
 
2531
3001
  // Resolve a keyword to one agent and return it with a writable FIFO, or throw
@@ -3878,3 +4348,268 @@ async function cmdResultSet(rest: string[]): Promise<number> {
3878
4348
  process.stdout.write(`result envelope written for pid ${pid}\n`);
3879
4349
  return 0;
3880
4350
  }
4351
+
4352
+ // ---------------------------------------------------------------------------
4353
+ // ay notify / ay notifyd — subagent→parent status-transition notifications.
4354
+ //
4355
+ // See docs/subagent-notify.md. `ay notifyd` is the detection engine (query-layer
4356
+ // watcher, runtime-agnostic); `ay notify` is the parent-facing inbox reader. A
4357
+ // parent typically runs ONE command in its Monitor loop:
4358
+ //
4359
+ // ay notify watch --unread # tail its inbox, ensure the daemon
4360
+ //
4361
+ // and gets every child's needs_input / sustained-idle / exited edge, each with a
4362
+ // payload (question / tail / git head) so it can act without tailing the child.
4363
+ // ---------------------------------------------------------------------------
4364
+
4365
+ /** Resolve the parent pid a `ay notify` invocation is draining. */
4366
+ function resolveParentPid(explicit: number | undefined): number {
4367
+ if (Number.isFinite(explicit) && (explicit as number) > 0) return explicit as number;
4368
+ const self = Number(process.env.AGENT_YES_PID);
4369
+ if (Number.isFinite(self) && self > 0) return self;
4370
+ throw new Error(
4371
+ "ay notify: not running inside an agent (no AGENT_YES_PID) — pass --parent <pid>",
4372
+ );
4373
+ }
4374
+
4375
+ function printNotifyEvents(events: NotifyEvent[], json: boolean): void {
4376
+ if (json) {
4377
+ for (const e of events) process.stdout.write(JSON.stringify(e) + "\n");
4378
+ return;
4379
+ }
4380
+ for (const e of events) {
4381
+ // Plain ASCII tag (no emoji): stays legible over the Rust/CLI path and old
4382
+ // terminals, and is easy to grep for consumers that log-parse the stream.
4383
+ const tag = `[${e.edge}]`;
4384
+ const head = `[${e.seq}] ${tag} pid ${e.child_pid} (${e.cli}) ${e.cwd}`;
4385
+ process.stdout.write(head + "\n");
4386
+ if (e.git_head) process.stdout.write(` HEAD ${e.git_head}\n`);
4387
+ if (e.question) process.stdout.write(` Q: ${e.question}\n`);
4388
+ if (e.tail)
4389
+ process.stdout.write(
4390
+ e.tail
4391
+ .split("\n")
4392
+ .map((l) => ` | ${l}`)
4393
+ .join("\n") + "\n",
4394
+ );
4395
+ }
4396
+ }
4397
+
4398
+ async function cmdNotify(rest: string[]): Promise<number> {
4399
+ const verb = rest[0];
4400
+ const args = rest.slice(1);
4401
+
4402
+ if (verb === "cursor") return cmdNotifyCursor(args);
4403
+ if (verb !== "read" && verb !== "watch") {
4404
+ process.stderr.write(
4405
+ "usage: ay notify <read|watch|cursor> [--parent <pid>] [--since <seq>] [--since-ts <ms>] [--unread] [--ack] [--json]\n",
4406
+ );
4407
+ return 1;
4408
+ }
4409
+
4410
+ const y = yargs(args)
4411
+ .option("parent", { type: "number", description: "Parent pid whose inbox to drain (default: $AGENT_YES_PID)" })
4412
+ .option("since", { type: "number", description: "Only edges with seq greater than this" })
4413
+ .option("since-ts", { type: "number", description: "Only edges at/after this epoch-ms" })
4414
+ .option("unread", { type: "boolean", default: false, description: "Only edges past the saved cursor" })
4415
+ .option("ack", { type: "boolean", default: false, description: "Advance the cursor past what's shown (at-least-once: off by default)" })
4416
+ .option("json", { type: "boolean", default: false, description: "Emit raw NDJSON events" })
4417
+ .option("consumer", { type: "string", default: "parent", description: "Cursor identity (for multiple readers)" })
4418
+ .option("interval", { type: "number", default: 2, description: "Poll interval in seconds (watch)" })
4419
+ .option("ensure-daemon", { type: "boolean", default: true, description: "Start the notifyd singleton if not running (watch)" })
4420
+ .help(false)
4421
+ .version(false)
4422
+ .exitProcess(false);
4423
+ const argv = await y.parseAsync();
4424
+
4425
+ const parent = resolveParentPid(argv.parent as number | undefined);
4426
+ const host = hostId();
4427
+ const consumer = String(argv.consumer);
4428
+ // The reader's own start time — used to reject inbox events addressed to a
4429
+ // PRIOR incarnation of this pid (pid reuse). FAIL-CLOSED: if we can't resolve
4430
+ // the parent's identity (no live registry record → started_at 0), we refuse to
4431
+ // open the notification path at all rather than fail-open and risk delivering a
4432
+ // recycled pid's inbox to an unrelated agent (or registering a 0-identity
4433
+ // watcher). "If we don't know who the parent is, don't open the path."
4434
+ const selfStartedAt = await resolveParentStartedAt(parent);
4435
+ if (selfStartedAt <= 0)
4436
+ throw new Error(
4437
+ `cannot resolve identity for pid ${parent} (no live agent record) — ` +
4438
+ `refusing to open the notification path (pass --parent for a live agent).`,
4439
+ );
4440
+
4441
+ const drain = async (sinceSeqOverride?: number): Promise<number> => {
4442
+ let events = await readInbox(host, parent);
4443
+ // Parent pid-reuse guard (fail-safe): when we know our own start time, deliver
4444
+ // ONLY events whose parent_started_at EXACTLY matches it — a mismatched OR
4445
+ // missing parent identity is dropped, never fail-open-delivered to a possibly-
4446
+ // recycled pid. The daemon always stamps parent_started_at from the watcher's
4447
+ // heartbeat (the same value this reader resolves), so every legitimate event
4448
+ // matches; only truly-legacy identity-less events fall out.
4449
+ if (selfStartedAt > 0) events = events.filter((e) => e.parent_started_at === selfStartedAt);
4450
+ if (argv.unread) {
4451
+ const cursor = await getCursor(host, parent, consumer);
4452
+ events = filterUnread(events, sinceSeqOverride ?? cursor);
4453
+ } else {
4454
+ if (sinceSeqOverride !== undefined) events = filterSinceSeq(events, sinceSeqOverride);
4455
+ else if (Number.isFinite(argv.since)) events = filterSinceSeq(events, argv.since as number);
4456
+ if (Number.isFinite(argv["since-ts"])) events = filterSinceTs(events, argv["since-ts"] as number);
4457
+ }
4458
+ printNotifyEvents(events, argv.json);
4459
+ return maxSeq(events);
4460
+ };
4461
+
4462
+ // Advance the cursor MONOTONICALLY — never below its current value, and never
4463
+ // regressing on an empty batch. This is what makes `watch --ack` safe across a
4464
+ // consumer restart: the high-water of what we've shown is always persisted.
4465
+ const ackTo = async (seq: number) => {
4466
+ const cur = await getCursor(host, parent, consumer);
4467
+ if (seq > cur) await setCursor(host, parent, seq, consumer);
4468
+ };
4469
+
4470
+ if (verb === "read") {
4471
+ const top = await drain();
4472
+ if (argv.ack && top > 0) await ackTo(top);
4473
+ return 0;
4474
+ }
4475
+
4476
+ // watch: tail -f the inbox. Default no-ack (at-least-once) so a consumer that
4477
+ // crashes mid-handling re-reads on restart; pass --ack to advance the cursor.
4478
+ const ensure = async () => {
4479
+ if (!argv["ensure-daemon"]) return;
4480
+ const { ensureDaemon } = await import("./notifyDaemon.ts");
4481
+ await ensureDaemon().catch(() => null);
4482
+ };
4483
+ // Register this parent as a live watcher BEFORE the first poll and ensure a
4484
+ // daemon exists — so a parent that watches *before* spawning any child (or
4485
+ // across a fan-out gap) still has a running, correctly-scoped daemon.
4486
+ await heartbeatWatcher(parent, selfStartedAt);
4487
+ await ensure();
4488
+ const intervalMs = Math.max(500, (Number.isFinite(argv.interval) ? argv.interval : 2) * 1000);
4489
+ // Baseline: from the cursor (unread) or the caller's --since, else from now
4490
+ // (only new edges). Track high-water seq in-memory between polls.
4491
+ let lastSeq = argv.unread
4492
+ ? await getCursor(host, parent, consumer)
4493
+ : Number.isFinite(argv.since)
4494
+ ? (argv.since as number)
4495
+ : maxSeq(await readInbox(host, parent));
4496
+ let acked = lastSeq; // high-water already persisted to the cursor
4497
+ let stop = false;
4498
+ // On signal: drop our heartbeat and exit promptly. (Even if this is missed on
4499
+ // a hard kill, the watcher's TTL makes the stale heartbeat non-live, so the
4500
+ // daemon's scope/self-exit stays correct — this is just prompt cleanup.)
4501
+ const onSig = () => {
4502
+ stop = true;
4503
+ void clearWatcher(parent).finally(() => process.exit(0));
4504
+ };
4505
+ process.on("SIGINT", onSig);
4506
+ process.on("SIGTERM", onSig);
4507
+ try {
4508
+ while (!stop) {
4509
+ // Refresh our heartbeat and keep the daemon alive every tick — it self-
4510
+ // exits after a grace window with no watchers, so a long watch must renew.
4511
+ await heartbeatWatcher(parent, selfStartedAt);
4512
+ await ensure();
4513
+ const top = await drain(lastSeq);
4514
+ if (top > lastSeq) lastSeq = top;
4515
+ // Persist the high-water monotonically (only when it advanced), so a batch
4516
+ // that showed events is acked even if the NEXT poll is empty — a restarted
4517
+ // `watch --ack` then resumes past what it already delivered, not from the
4518
+ // stale cursor.
4519
+ if (argv.ack && lastSeq > acked) {
4520
+ await ackTo(lastSeq);
4521
+ acked = lastSeq;
4522
+ }
4523
+ await new Promise((r) => setTimeout(r, intervalMs));
4524
+ }
4525
+ } finally {
4526
+ await clearWatcher(parent);
4527
+ }
4528
+ return 0;
4529
+ }
4530
+
4531
+ /**
4532
+ * Resolve the started_at of the LIVE agent whose wrapper pid is `parent`. Returns
4533
+ * 0 (→ the caller fails closed) when there is no such record, when the matching
4534
+ * record is stale (exited / pid not alive), or when the match is ambiguous
4535
+ * (>1 live record) — so a leftover stale record can't make a recycled parent pid
4536
+ * resolve to a PRIOR incarnation's start time and fail-open the identity guard.
4537
+ */
4538
+ async function resolveParentStartedAt(parent: number): Promise<number> {
4539
+ const records = await listRecords(undefined, {
4540
+ all: true,
4541
+ active: false,
4542
+ json: false,
4543
+ latest: false,
4544
+ cwdScope: null,
4545
+ }).catch(() => [] as GlobalPidRecord[]);
4546
+ const live = records.filter(
4547
+ (r) =>
4548
+ (r.wrapper_pid === parent || r.pid === parent) &&
4549
+ r.status !== "exited" &&
4550
+ isPidAlive(r.pid),
4551
+ );
4552
+ // Exactly one live match, or fail closed.
4553
+ if (live.length !== 1) return 0;
4554
+ return live[0]!.started_at ?? 0;
4555
+ }
4556
+
4557
+ async function cmdNotifyCursor(args: string[]): Promise<number> {
4558
+ const action = args[0];
4559
+ const y = yargs(args.slice(1))
4560
+ .option("parent", { type: "number" })
4561
+ .option("consumer", { type: "string", default: "parent" })
4562
+ .help(false)
4563
+ .version(false)
4564
+ .exitProcess(false);
4565
+ const argv = await y.parseAsync();
4566
+ const parent = resolveParentPid(argv.parent as number | undefined);
4567
+ const host = hostId();
4568
+ const consumer = String(argv.consumer);
4569
+ if (action === "get") {
4570
+ process.stdout.write(String(await getCursor(host, parent, consumer)) + "\n");
4571
+ return 0;
4572
+ }
4573
+ if (action === "set") {
4574
+ const seq = Number(argv._[0]);
4575
+ if (!Number.isFinite(seq) || seq < 0) throw new Error("ay notify cursor set <seq>");
4576
+ await setCursor(host, parent, seq, consumer);
4577
+ return 0;
4578
+ }
4579
+ process.stderr.write("usage: ay notify cursor <get|set <seq>> [--parent <pid>]\n");
4580
+ return 1;
4581
+ }
4582
+
4583
+ async function cmdNotifyd(rest: string[]): Promise<number> {
4584
+ const sub = rest[0] ?? "status";
4585
+ const daemon = await import("./notifyDaemon.ts");
4586
+ switch (sub) {
4587
+ case "run":
4588
+ return daemon.runDaemon();
4589
+ case "once":
4590
+ return daemon.runDaemon({ once: true });
4591
+ case "start": {
4592
+ const pid = await daemon.ensureDaemon();
4593
+ process.stdout.write(pid ? `notifyd running (pid ${pid})\n` : "notifyd: failed to start\n");
4594
+ return pid ? 0 : 1;
4595
+ }
4596
+ case "status": {
4597
+ const pid = await daemon.daemonStatus();
4598
+ process.stdout.write(pid ? `notifyd running (pid ${pid})\n` : "notifyd: not running\n");
4599
+ return pid ? 0 : 1;
4600
+ }
4601
+ case "stop": {
4602
+ // Cooperative, non-destructive stop: remove the daemon's lock and let it
4603
+ // exit itself on the next tick — never SIGTERM a pid that may have been
4604
+ // recycled onto an unrelated process.
4605
+ const pid = await daemon.requestDaemonStop();
4606
+ process.stdout.write(
4607
+ pid ? `notifyd: stop requested (pid ${pid} will exit shortly)\n` : "notifyd: not running\n",
4608
+ );
4609
+ return 0;
4610
+ }
4611
+ default:
4612
+ process.stderr.write("usage: ay notifyd <run|once|start|status|stop>\n");
4613
+ return 1;
4614
+ }
4615
+ }