agent-yes 1.290.7 → 1.291.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/SUPPORTED_CLIS-BZP767UR.js +12 -0
  2. package/dist/{SUPPORTED_CLIS-hTRegnm5.js → SUPPORTED_CLIS-TiOdGi1h.js} +2 -2
  3. package/dist/{agentShare-C_LTEKEb.js → agentShare-D4N8StHO.js} +8 -8
  4. package/dist/{askCli-DR9vhdcV.js → askCli-DFsWcgpF.js} +3 -3
  5. package/dist/{browser-oqC3Ln2a.js → browser-wkzxw6lx.js} +3 -2
  6. package/dist/{callback-DAQ8wCtJ.js → callback-Bc2gnr_f.js} +3 -3
  7. package/dist/{callback-BEvLUUBR.js → callback-yCpK6Qpg.js} +7 -7
  8. package/dist/{channels-hOG3-R_3.js → channels-aYwLMbFV.js} +2 -2
  9. package/dist/cli.js +9 -9
  10. package/dist/{cmdPs-BAaLB4Pz.js → cmdPs-D5L3Eyh5.js} +7 -7
  11. package/dist/{cwdConflictWarn-BgXN56Jc.js → cwdConflictWarn-sQUONAye.js} +2 -2
  12. package/dist/{forkNested-CJ7lQQCe.js → forkNested-xtEeYpBQ.js} +3 -3
  13. package/dist/{globalPidIndex-DoaKEUkO.js → globalPidIndex-CRH9L9tn.js} +139 -12
  14. package/dist/globalPidIndex-DC93JhHQ.js +4 -0
  15. package/dist/index.js +5 -5
  16. package/dist/{messageLog-CVRSTLza.js → messageLog-Brx3w6zT.js} +2 -1
  17. package/dist/{notifyDaemon-Dnbe66Fl.js → notifyDaemon-CvUtRnAB.js} +7 -7
  18. package/dist/{parentLink-fdiuB13i.js → parentLink-BILJ32nA.js} +2 -2
  19. package/dist/{parentLink-bpDGuQi6.js → parentLink-CfbxykCv.js} +2 -2
  20. package/dist/{parentPingLoop-DWv-xJoi.js → parentPingLoop-CVOCiQp2.js} +4 -4
  21. package/dist/pidStore-Bpe8i_oU.js +6 -0
  22. package/dist/{pidStore-B73xnAeT.js → pidStore-DXWqzTSz.js} +2 -2
  23. package/dist/{remotes-DTXPW8U1.js → remotes-tmKwrr20.js} +3 -3
  24. package/dist/{remotes-C696AJzL.js → remotes-wPL0JtBs.js} +2 -2
  25. package/dist/{rustBinary-CZRPfa4y.js → rustBinary-C_EbyMTk.js} +2 -2
  26. package/dist/{rustBinary-jO6EN8LR.js → rustBinary-Clbk_K5c.js} +2 -2
  27. package/dist/{schedule-Hao0WqyJ.js → schedule-Ba_4wNVK.js} +7 -7
  28. package/dist/{serve-B2nisui8.js → serve-CJDQq_Jp.js} +46 -40
  29. package/dist/{setup-vCDTqyqR.js → setup-DwLAkbTC.js} +2 -2
  30. package/dist/share-Bmnd_qT0.js +4 -0
  31. package/dist/{share-DTlaFn6s.js → share-C9oGvjXT.js} +13 -4
  32. package/dist/spawnGate-BUEswFgo.js +5 -0
  33. package/dist/{spawnGate-B26UnzqP.js → spawnGate-YpHopveO.js} +2 -2
  34. package/dist/{subcommands-BpFdlCY0.js → subcommands-x8Pe7DrF.js} +6 -6
  35. package/dist/{subcommands-D6LBtVPM.js → subcommands-yWgVRM2r.js} +273 -45
  36. package/dist/{terminal-iGHEnc7Z.js → terminal-Dv73BTE-.js} +2 -2
  37. package/dist/terminal.js +1 -1
  38. package/dist/{todoCli-azqbjBtz.js → todoCli-DSQXAcwA.js} +3 -3
  39. package/dist/{todoIdentity-NEceTmEe.js → todoIdentity-CAmnkzzP.js} +2 -2
  40. package/dist/{trayApp-BdNlQCFx.js → trayApp-BRw8f0mT.js} +2 -2
  41. package/dist/trayApp-BjQ5V3WV.js +5 -0
  42. package/dist/{ts-oyuaLWWx.js → ts-C8PxhJZM.js} +7 -7
  43. package/dist/{versionChecker-CMjS88mL.js → versionChecker-CWPGASjD.js} +2 -2
  44. package/dist/webrtcLink-CnQf9pmu.js +73 -0
  45. package/dist/{webrtcRemote-L5NxhLLR.js → webrtcRemote-yvMYsij3.js} +2 -2
  46. package/dist/{widget-OvkqW2Z6.js → widget-DhBSNUey.js} +3 -3
  47. package/dist/widgets.js +1 -1
  48. package/dist/{ws-BeMouYAU.js → ws-CMX0pkti.js} +2 -2
  49. package/dist/{ws-qZy7Giub.js → ws-DZ9YAXlY.js} +7 -7
  50. package/lab/ui/console-logic.js +27 -4
  51. package/package.json +1 -1
  52. package/ts/crossTreeClaim.bun.spec.ts +76 -0
  53. package/ts/crossTreeClaim.spec.ts +76 -0
  54. package/ts/globalPidIndex.spec.ts +153 -0
  55. package/ts/globalPidIndex.ts +151 -10
  56. package/ts/messageLog.bun.spec.ts +33 -0
  57. package/ts/messageLog.spec.ts +33 -0
  58. package/ts/messageLog.ts +3 -0
  59. package/ts/rawInputDeclared.bun.spec.ts +68 -0
  60. package/ts/rawInputDeclared.spec.ts +68 -0
  61. package/ts/remoteHealth.bun.spec.ts +130 -0
  62. package/ts/remoteHealth.spec.ts +130 -0
  63. package/ts/remoteHealth.ts +149 -0
  64. package/ts/restartKillGuard.bun.spec.ts +40 -0
  65. package/ts/restartKillGuard.spec.ts +40 -0
  66. package/ts/senderAncestry.ts +45 -7
  67. package/ts/serve.ts +46 -20
  68. package/ts/share.bun.spec.ts +24 -2
  69. package/ts/share.spec.ts +24 -2
  70. package/ts/share.ts +37 -5
  71. package/ts/subcommands.ts +239 -19
  72. package/ts/terminal/browser.ts +5 -1
  73. package/ts/webrtcLink.bun.spec.ts +45 -1
  74. package/ts/webrtcLink.spec.ts +45 -1
  75. package/ts/webrtcLink.ts +63 -19
  76. package/dist/SUPPORTED_CLIS-CuvT3tKq.js +0 -12
  77. package/dist/globalPidIndex-yvWZXeWT.js +0 -4
  78. package/dist/pidStore-V51mbgZY.js +0 -6
  79. package/dist/share-BvU4I8BI.js +0 -4
  80. package/dist/spawnGate-C8MSRt7X.js +0 -5
  81. package/dist/trayApp-Br2T2Yrb.js +0 -5
  82. package/dist/webrtcLink-DLaJfTcB.js +0 -45
@@ -1,4 +1,5 @@
1
- import { appendFile, mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
1
+ import type { Dirent } from "fs";
2
+ import { appendFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
2
3
  import { homedir } from "os";
3
4
  import type { AgentPermissions } from "./agentPermissions.ts";
4
5
  import path from "path";
@@ -246,6 +247,33 @@ function logSiblings(logFile: string | null): string[] {
246
247
  return [`${base}.raw.log`, `${base}.log`, `${base}.lines.log`, `${base}.debug.log`];
247
248
  }
248
249
 
250
+ export interface LogGcResult {
251
+ /** Absolute paths of the log files that were removed. */
252
+ removed: string[];
253
+ /** Total bytes reclaimed (0 for files whose size could not be read). */
254
+ freedBytes: number;
255
+ }
256
+
257
+ /**
258
+ * Delete one log file, reporting the bytes it held. Returns null when nothing
259
+ * was removed (already gone, or not ours to delete) so callers can count only
260
+ * real deletions.
261
+ */
262
+ async function removeLogFile(file: string): Promise<number | null> {
263
+ let size = 0;
264
+ try {
265
+ size = (await stat(file)).size;
266
+ } catch {
267
+ // Unreadable/missing — unlink below still decides whether it counted.
268
+ }
269
+ try {
270
+ await unlink(file);
271
+ return size;
272
+ } catch {
273
+ return null; // missing / already gone — ignore
274
+ }
275
+ }
276
+
249
277
  /**
250
278
  * Index-driven retention sweep: delete the log files of sessions whose process
251
279
  * is gone (exited or dead pid) and that started longer ago than the retention
@@ -254,30 +282,143 @@ function logSiblings(logFile: string | null): string[] {
254
282
  * Best-effort; never throws. Returns the number of files removed.
255
283
  */
256
284
  export async function pruneOldLogs(maxAgeMs: number = retentionMs()): Promise<number> {
285
+ const { removed } = await pruneOldLogsDetailed(maxAgeMs);
286
+ return removed.length;
287
+ }
288
+
289
+ async function pruneOldLogsDetailed(maxAgeMs: number = retentionMs()): Promise<LogGcResult> {
290
+ const result: LogGcResult = { removed: [], freedBytes: 0 };
257
291
  let records: GlobalPidRecord[];
258
292
  try {
259
293
  records = await readGlobalPidsRaw();
260
294
  } catch {
261
- return 0;
295
+ return result;
262
296
  }
263
297
  const now = Date.now();
264
- let removed = 0;
265
298
  for (const r of records) {
266
299
  const dead = r.status === "exited" || !isProcessAlive(r.pid);
267
300
  const old = now - (r.started_at ?? now) > maxAgeMs;
268
301
  if (!dead || !old) continue;
269
302
  for (const f of logSiblings(r.log_file)) {
303
+ const freed = await removeLogFile(f);
304
+ if (freed === null) continue;
305
+ result.removed.push(f);
306
+ result.freedBytes += freed;
307
+ }
308
+ }
309
+ if (result.removed.length > 0) {
310
+ logger.debug(`[globalPidIndex] pruned ${result.removed.length} stale log file(s)`);
311
+ await maybeCompactGlobalPids();
312
+ }
313
+ return result;
314
+ }
315
+
316
+ /** `<pid>.log`, `<pid>.raw.log`, `<pid>.lines.log`, `<pid>.debug.log` — nothing else. */
317
+ const PID_LOG_FILE = /^(\d+)\.(?:raw\.log|lines\.log|debug\.log|log)$/;
318
+
319
+ /**
320
+ * Every `.agent-yes/` dir that might hold logs: this process's cwd plus the
321
+ * dir of each path the index knows about. Records are the only breadcrumb to
322
+ * *other* projects, so a pid whose record is gone is still reachable as long
323
+ * as some sibling record points at the same dir.
324
+ */
325
+ function candidateLogDirs(records: GlobalPidRecord[]): string[] {
326
+ const dirs = new Set<string>([path.resolve(process.cwd(), ".agent-yes")]);
327
+ for (const r of records) {
328
+ if (r.log_file) dirs.add(path.resolve(path.dirname(r.log_file)));
329
+ if (r.cwd) dirs.add(path.resolve(r.cwd, ".agent-yes"));
330
+ }
331
+ return [...dirs];
332
+ }
333
+
334
+ /**
335
+ * Directory-driven sweep for **orphaned** logs — the ones `pruneOldLogs` can
336
+ * never see.
337
+ *
338
+ * `maybeCompactGlobalPids` drops records that are dead AND exited, but
339
+ * `pruneOldLogs` only deletes a log once its record is older than the
340
+ * retention window. A session that exits cleanly is therefore compacted out of
341
+ * the index long before its log ages out, and with the record goes the only
342
+ * record of the `log_file` path — the file is then unreclaimable by any
343
+ * index-driven pass. Left alone, those accumulate for as long as a machine
344
+ * keeps running agents, and a single long-lived session's raw log can reach
345
+ * hundreds of MiB on its own.
346
+ *
347
+ * So this pass ignores the index for *what* to delete and trusts the
348
+ * filesystem: in each candidate dir, any `<pid>.*.log` whose pid is no longer
349
+ * running and whose mtime is outside the retention window. Guards:
350
+ *
351
+ * - the name must parse as a pid log (never `inbox.jsonl`, `pid.sqlite`, …);
352
+ * - `isProcessAlive(pid)` must be false — pid reuse can only make a dead pid
353
+ * look alive, which keeps a file that a later run collects, never the
354
+ * reverse;
355
+ * - mtime, not `started_at`, bounds the age, so a log still being appended to
356
+ * is safe even when its session started long ago.
357
+ *
358
+ * Best-effort; never throws.
359
+ */
360
+ export async function sweepOrphanLogs(maxAgeMs: number = retentionMs()): Promise<LogGcResult> {
361
+ const result: LogGcResult = { removed: [], freedBytes: 0 };
362
+ let records: GlobalPidRecord[] = [];
363
+ try {
364
+ records = await readGlobalPidsRaw();
365
+ } catch {
366
+ // No index at all — the cwd is still worth sweeping.
367
+ }
368
+ const now = Date.now();
369
+
370
+ for (const dir of candidateLogDirs(records)) {
371
+ let entries: Dirent[];
372
+ try {
373
+ entries = await readdir(dir, { withFileTypes: true });
374
+ } catch {
375
+ continue; // dir gone / unreadable — nothing to collect here
376
+ }
377
+ for (const entry of entries) {
378
+ if (!entry.isFile()) continue;
379
+ const match = PID_LOG_FILE.exec(entry.name);
380
+ if (!match) continue;
381
+ const pid = Number(match[1]);
382
+ if (!Number.isInteger(pid) || pid <= 0 || isProcessAlive(pid)) continue;
383
+
384
+ const file = path.join(dir, entry.name);
385
+ let mtimeMs: number;
270
386
  try {
271
- await unlink(f);
272
- removed++;
387
+ mtimeMs = (await stat(file)).mtimeMs;
273
388
  } catch {
274
- // missing / already gone — ignore
389
+ continue; // raced with deletion
275
390
  }
391
+ if (now - mtimeMs <= maxAgeMs) continue; // still inside the window
392
+
393
+ const freed = await removeLogFile(file);
394
+ if (freed === null) continue;
395
+ result.removed.push(file);
396
+ result.freedBytes += freed;
276
397
  }
277
398
  }
278
- if (removed > 0) {
279
- logger.debug(`[globalPidIndex] pruned ${removed} stale log file(s)`);
280
- await maybeCompactGlobalPids();
399
+
400
+ if (result.removed.length > 0) {
401
+ logger.debug(`[globalPidIndex] swept ${result.removed.length} orphaned log file(s)`);
402
+ }
403
+ return result;
404
+ }
405
+
406
+ /**
407
+ * The full log reclaim `ay gc` runs: the index-driven retention pass first
408
+ * (it also compacts the index), then the directory sweep for whatever the
409
+ * index no longer remembers. Paths are deduped, so a file both passes could
410
+ * claim is only counted once.
411
+ */
412
+ export async function gcLogs(maxAgeMs: number = retentionMs()): Promise<LogGcResult> {
413
+ const pruned = await pruneOldLogsDetailed(maxAgeMs);
414
+ const swept = await sweepOrphanLogs(maxAgeMs);
415
+ const seen = new Set(pruned.removed);
416
+ const result: LogGcResult = { ...pruned };
417
+ for (const f of swept.removed) {
418
+ if (seen.has(f)) continue;
419
+ seen.add(f);
420
+ result.removed.push(f);
281
421
  }
282
- return removed;
422
+ result.freedBytes = pruned.freedBytes + swept.freedBytes;
423
+ return result;
283
424
  }
@@ -14,6 +14,7 @@ import {
14
14
  shouldRecord,
15
15
  type MessageRecord,
16
16
  } from "./messageLog.ts";
17
+ import { envelopeAttribution } from "./subcommands.ts";
17
18
 
18
19
  function makeRecord(over: Partial<MessageRecord> = {}): MessageRecord {
19
20
  return {
@@ -540,3 +541,35 @@ describe("messageLog sender provenance — derived vs asserted", () => {
540
541
  ).toBe(true);
541
542
  });
542
543
  });
544
+
545
+ describe("unverifiable is not contradicted", () => {
546
+ // The direction matters more than the fact. Collapsing UNKNOWN into SUSPICIOUS
547
+ // trains every reader to dismiss the marker on legitimate traffic, so when a
548
+ // real contradiction arrives it reads as more of the same — the alarm is spent
549
+ // before its first true positive. The reverse error would merely miss one.
550
+ it("stays SILENT in the envelope when there is nothing to disagree with", () => {
551
+ expect(envelopeAttribution("env-unverified")).toBe("");
552
+ });
553
+
554
+ it("keeps the loud marker for a genuine contradiction only", () => {
555
+ expect(envelopeAttribution("env-uncorroborated")).toContain("UNCORROBORATED");
556
+ });
557
+
558
+ it("still records the distinction for a reader who wants it", () => {
559
+ // Silent in the body is not the same as thrown away: `ay msgs` shows it,
560
+ // and from_via carries it for anything that wants to weigh it.
561
+ const from = { pid: 1111, cli: "claude", cwd: "/repo/alpha", agent_id: "agent-A" };
562
+ expect(senderLabel(makeRecord({ from, from_via: "env-unverified" }))).toBe(
563
+ "claude #1111 (unverified)",
564
+ );
565
+ expect(senderLabel(makeRecord({ from, from_via: "env-uncorroborated" }))).toBe(
566
+ "claude #1111 (UNCORROBORATED)",
567
+ );
568
+ });
569
+
570
+ it("does not weaken the honest path", () => {
571
+ const from = { pid: 1111, cli: "claude", cwd: "/repo/alpha", agent_id: "agent-A" };
572
+ expect(senderLabel(makeRecord({ from, from_via: "env" }))).toBe("claude #1111");
573
+ expect(envelopeAttribution("env")).toBe("");
574
+ });
575
+ });
@@ -14,6 +14,7 @@ import {
14
14
  shouldRecord,
15
15
  type MessageRecord,
16
16
  } from "./messageLog.ts";
17
+ import { envelopeAttribution } from "./subcommands.ts";
17
18
 
18
19
  function makeRecord(over: Partial<MessageRecord> = {}): MessageRecord {
19
20
  return {
@@ -540,3 +541,35 @@ describe("messageLog sender provenance — derived vs asserted", () => {
540
541
  ).toBe(true);
541
542
  });
542
543
  });
544
+
545
+ describe("unverifiable is not contradicted", () => {
546
+ // The direction matters more than the fact. Collapsing UNKNOWN into SUSPICIOUS
547
+ // trains every reader to dismiss the marker on legitimate traffic, so when a
548
+ // real contradiction arrives it reads as more of the same — the alarm is spent
549
+ // before its first true positive. The reverse error would merely miss one.
550
+ it("stays SILENT in the envelope when there is nothing to disagree with", () => {
551
+ expect(envelopeAttribution("env-unverified")).toBe("");
552
+ });
553
+
554
+ it("keeps the loud marker for a genuine contradiction only", () => {
555
+ expect(envelopeAttribution("env-uncorroborated")).toContain("UNCORROBORATED");
556
+ });
557
+
558
+ it("still records the distinction for a reader who wants it", () => {
559
+ // Silent in the body is not the same as thrown away: `ay msgs` shows it,
560
+ // and from_via carries it for anything that wants to weigh it.
561
+ const from = { pid: 1111, cli: "claude", cwd: "/repo/alpha", agent_id: "agent-A" };
562
+ expect(senderLabel(makeRecord({ from, from_via: "env-unverified" }))).toBe(
563
+ "claude #1111 (unverified)",
564
+ );
565
+ expect(senderLabel(makeRecord({ from, from_via: "env-uncorroborated" }))).toBe(
566
+ "claude #1111 (UNCORROBORATED)",
567
+ );
568
+ });
569
+
570
+ it("does not weaken the honest path", () => {
571
+ const from = { pid: 1111, cli: "claude", cwd: "/repo/alpha", agent_id: "agent-A" };
572
+ expect(senderLabel(makeRecord({ from, from_via: "env" }))).toBe("claude #1111");
573
+ expect(envelopeAttribution("env")).toBe("");
574
+ });
575
+ });
package/ts/messageLog.ts CHANGED
@@ -183,6 +183,9 @@ export function senderLabel(record: MessageRecord): string {
183
183
  // The one attributed value that is only a claim — say so, since a receiver
184
184
  // weighing whether to act on it cannot otherwise tell it from a fact.
185
185
  if (record.from_via === "env-uncorroborated") return `${name} (UNCORROBORATED)`;
186
+ // Visible in a listing, where a human is reading deliberately, but never in
187
+ // the envelope — see envelopeAttribution.
188
+ if (record.from_via === "env-unverified") return `${name} (unverified)`;
186
189
  return name;
187
190
  }
188
191
  // No agent identified. Say what WAS measured rather than "unattributed" —
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ // #453 made the PRODUCER declare a terminal wire (`raw: true`), because no
6
+ // byte-level predicate on the server may claim a keystroke or a mouse report —
7
+ // those shapes are indistinguishable from a short message. A declaration only
8
+ // works if every producer makes it, and #453 landed it on one of three: the
9
+ // other two kept flooding capacity-capped inboxes, where a mouse scrolled over
10
+ // a pane evicts the real messages behind it.
11
+ //
12
+ // So this is the guard the design needs: any `/api/send` write that declines to
13
+ // submit (`code: "none"`) is raw terminal input and must say so.
14
+ const ROOTS = ["ts", "lab/ui"];
15
+ const SKIP = new Set(["node_modules", "cf", "dist", "public"]);
16
+ const RAW_SEND = /code:\s*"none"/;
17
+
18
+ function sources(dir: string, out: string[] = []): string[] {
19
+ for (const e of readdirSync(dir)) {
20
+ if (SKIP.has(e)) continue;
21
+ const p = join(dir, e);
22
+ if (statSync(p).isDirectory()) sources(p, out);
23
+ else if (/\.(ts|js)$/.test(e) && !/\.spec\./.test(e)) out.push(p);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ /**
29
+ * Call sites only — prose in a comment describing the protocol is not one.
30
+ *
31
+ * Line comments are stripped; block comments deliberately are not. Tracking
32
+ * them needs a lexer, and getting it wrong wedges the scan silently. Prose
33
+ * inside a block comment can only ever ADD a false offender, which fails
34
+ * loudly and is fixed by rewording; it can never hide a real producer. The
35
+ * count assertion below covers the opposite slip, a scan that stops matching.
36
+ *
37
+ * Split on /\r?\n/, not "\n": on a CRLF checkout every line would keep a
38
+ * trailing \r, and `.` does not match \r in JS, so `//.*$` silently stops
39
+ * stripping comments and every line of prose about the protocol becomes a
40
+ * false offender. This test failed on Windows only, for exactly that.
41
+ */
42
+ function callSites(file: string): { line: number; window: string }[] {
43
+ const lines = readFileSync(file, "utf-8").split(/\r?\n/);
44
+ const hits: { line: number; window: string }[] = [];
45
+ lines.forEach((raw, i) => {
46
+ const code = raw.replace(/\/\/.*$/, "");
47
+ if (RAW_SEND.test(code)) hits.push({ line: i + 1, window: code + (lines[i + 1] ?? "") });
48
+ });
49
+ return hits;
50
+ }
51
+
52
+ describe("every raw-input producer declares itself", () => {
53
+ const files = ROOTS.flatMap((r) => sources(r));
54
+
55
+ it('no /api/send call uses code:"none" without raw:true', () => {
56
+ const offenders = files.flatMap((f) =>
57
+ callSites(f)
58
+ .filter((h) => !/raw:\s*true/.test(h.window))
59
+ .map((h) => `${f}:${h.line}`),
60
+ );
61
+ expect(offenders).toEqual([]);
62
+ });
63
+
64
+ it("still finds the producers (a scan that matches nothing passes forever)", () => {
65
+ const total = files.reduce((n, f) => n + callSites(f).length, 0);
66
+ expect(total).toBeGreaterThanOrEqual(3);
67
+ });
68
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ // #453 made the PRODUCER declare a terminal wire (`raw: true`), because no
6
+ // byte-level predicate on the server may claim a keystroke or a mouse report —
7
+ // those shapes are indistinguishable from a short message. A declaration only
8
+ // works if every producer makes it, and #453 landed it on one of three: the
9
+ // other two kept flooding capacity-capped inboxes, where a mouse scrolled over
10
+ // a pane evicts the real messages behind it.
11
+ //
12
+ // So this is the guard the design needs: any `/api/send` write that declines to
13
+ // submit (`code: "none"`) is raw terminal input and must say so.
14
+ const ROOTS = ["ts", "lab/ui"];
15
+ const SKIP = new Set(["node_modules", "cf", "dist", "public"]);
16
+ const RAW_SEND = /code:\s*"none"/;
17
+
18
+ function sources(dir: string, out: string[] = []): string[] {
19
+ for (const e of readdirSync(dir)) {
20
+ if (SKIP.has(e)) continue;
21
+ const p = join(dir, e);
22
+ if (statSync(p).isDirectory()) sources(p, out);
23
+ else if (/\.(ts|js)$/.test(e) && !/\.spec\./.test(e)) out.push(p);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ /**
29
+ * Call sites only — prose in a comment describing the protocol is not one.
30
+ *
31
+ * Line comments are stripped; block comments deliberately are not. Tracking
32
+ * them needs a lexer, and getting it wrong wedges the scan silently. Prose
33
+ * inside a block comment can only ever ADD a false offender, which fails
34
+ * loudly and is fixed by rewording; it can never hide a real producer. The
35
+ * count assertion below covers the opposite slip, a scan that stops matching.
36
+ *
37
+ * Split on /\r?\n/, not "\n": on a CRLF checkout every line would keep a
38
+ * trailing \r, and `.` does not match \r in JS, so `//.*$` silently stops
39
+ * stripping comments and every line of prose about the protocol becomes a
40
+ * false offender. This test failed on Windows only, for exactly that.
41
+ */
42
+ function callSites(file: string): { line: number; window: string }[] {
43
+ const lines = readFileSync(file, "utf-8").split(/\r?\n/);
44
+ const hits: { line: number; window: string }[] = [];
45
+ lines.forEach((raw, i) => {
46
+ const code = raw.replace(/\/\/.*$/, "");
47
+ if (RAW_SEND.test(code)) hits.push({ line: i + 1, window: code + (lines[i + 1] ?? "") });
48
+ });
49
+ return hits;
50
+ }
51
+
52
+ describe("every raw-input producer declares itself", () => {
53
+ const files = ROOTS.flatMap((r) => sources(r));
54
+
55
+ it('no /api/send call uses code:"none" without raw:true', () => {
56
+ const offenders = files.flatMap((f) =>
57
+ callSites(f)
58
+ .filter((h) => !/raw:\s*true/.test(h.window))
59
+ .map((h) => `${f}:${h.line}`),
60
+ );
61
+ expect(offenders).toEqual([]);
62
+ });
63
+
64
+ it("still finds the producers (a scan that matches nothing passes forever)", () => {
65
+ const total = files.reduce((n, f) => n + callSites(f).length, 0);
66
+ expect(total).toBeGreaterThanOrEqual(3);
67
+ });
68
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ REMOTE_BACKOFF_BASE_MS,
4
+ REMOTE_BACKOFF_MAX_MS,
5
+ noteRemoteResult,
6
+ pruneRemoteHealth,
7
+ remoteBackoffMs,
8
+ shouldSkipRemote,
9
+ } from "./remoteHealth.ts";
10
+
11
+ describe("remoteHealth.remoteBackoffMs", () => {
12
+ it("doubles from the base and then holds at the cap", () => {
13
+ expect(remoteBackoffMs(1)).toBe(REMOTE_BACKOFF_BASE_MS);
14
+ expect(remoteBackoffMs(2)).toBe(REMOTE_BACKOFF_BASE_MS * 2);
15
+ expect(remoteBackoffMs(3)).toBe(REMOTE_BACKOFF_BASE_MS * 4);
16
+ expect(remoteBackoffMs(99)).toBe(REMOTE_BACKOFF_MAX_MS);
17
+ });
18
+
19
+ it("is zero for a host that has not failed — and this GUARDS the clause below", () => {
20
+ // A streak of 0 must never produce a wait: that is the difference between
21
+ // "healthy" and "failed once", and getting it wrong skips a live host.
22
+ expect(remoteBackoffMs(0)).toBe(0);
23
+ expect(remoteBackoffMs(-1)).toBe(0);
24
+
25
+ // This is also the tripwire for `shouldSkipRemote`'s `streak <= 0` clause.
26
+ // That clause is UNREACHABLE while this holds, which is why no test can
27
+ // redden it directly — but change the curve so a non-positive streak yields
28
+ // a non-zero wait and the clause goes LIVE, with nothing behind it, at the
29
+ // exact moment someone is editing the thing it guards. So the invariant is
30
+ // pinned HERE, where it is reachable and can fail loudly, instead.
31
+ });
32
+
33
+ it("does not overflow on an absurd streak", () => {
34
+ // 2 ** 5000 is Infinity; the shift clamp is what keeps this finite.
35
+ expect(Number.isFinite(remoteBackoffMs(5000))).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("remoteHealth.shouldSkipRemote", () => {
40
+ const NOW = 1_000_000_000;
41
+
42
+ it("never skips a host with no record — the control that matters", () => {
43
+ // Wrongly skipping makes an agent silently vanish from `ay ls`; wrongly
44
+ // probing costs one slow list. Those are not symmetric, so the tie goes to
45
+ // probing, and an unknown host is always probed.
46
+ expect(shouldSkipRemote(undefined, NOW)).toBe(false);
47
+ // A cleared host carrying a stale timestamp — what `noteRemoteResult`
48
+ // produces on success — must also never be skipped. This asserts the
49
+ // BEHAVIOUR; it does not isolate the `streak <= 0` clause, which is
50
+ // redundant with `remoteBackoffMs(0) === 0` and cannot be reddened on its
51
+ // own. Said plainly rather than left looking like coverage it is not.
52
+ expect(shouldSkipRemote({ streak: 0, lastFailedAt: NOW - 1 }, NOW)).toBe(false);
53
+ expect(shouldSkipRemote({ streak: -3, lastFailedAt: NOW - 1 }, NOW)).toBe(false);
54
+ });
55
+
56
+ it("skips inside the backoff window and probes again once it elapses", () => {
57
+ const h = { streak: 1, lastFailedAt: NOW };
58
+ expect(shouldSkipRemote(h, NOW + REMOTE_BACKOFF_BASE_MS - 1)).toBe(true);
59
+ expect(shouldSkipRemote(h, NOW + REMOTE_BACKOFF_BASE_MS)).toBe(false);
60
+ });
61
+
62
+ it("probes rather than guesses when the clock has moved backwards", () => {
63
+ // A backwards clock makes `elapsed` negative, which would otherwise read as
64
+ // "still inside the window" and skip the host until the clock caught up.
65
+ expect(shouldSkipRemote({ streak: 3, lastFailedAt: NOW }, NOW - 60_000)).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe("remoteHealth.readRemoteHealth validation", () => {
70
+ it("ignores an entry whose shape it cannot trust", async () => {
71
+ // The file is derived data in a writable path. A valid-JSON record with a
72
+ // plausible-looking streak must not be able to hide a live agent, so
73
+ // anything that is not two finite numbers is dropped rather than trusted.
74
+ const { mkdtemp, writeFile } = await import("fs/promises");
75
+ const { tmpdir } = await import("os");
76
+ const path = (await import("path")).default;
77
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-health-"));
78
+ const saved = process.env.AGENT_YES_HOME;
79
+ process.env.AGENT_YES_HOME = dir;
80
+ try {
81
+ await writeFile(
82
+ path.join(dir, "remote-health.json"),
83
+ JSON.stringify({
84
+ good: { streak: 2, lastFailedAt: 123 },
85
+ missingField: { streak: 2 },
86
+ wrongType: { streak: "9", lastFailedAt: 1 },
87
+ notFinite: { streak: Number.POSITIVE_INFINITY, lastFailedAt: 1 },
88
+ notAnObject: 7,
89
+ }),
90
+ );
91
+ const { readRemoteHealth } = await import("./remoteHealth.ts");
92
+ expect(await readRemoteHealth()).toEqual({ good: { streak: 2, lastFailedAt: 123 } });
93
+ } finally {
94
+ if (saved === undefined) delete process.env.AGENT_YES_HOME;
95
+ else process.env.AGENT_YES_HOME = saved;
96
+ }
97
+ });
98
+ });
99
+
100
+ describe("remoteHealth.pruneRemoteHealth", () => {
101
+ it("drops aliases no longer configured, so the file cannot grow forever", () => {
102
+ const h = {
103
+ live: { streak: 1, lastFailedAt: 5 },
104
+ removed: { streak: 9, lastFailedAt: 5 },
105
+ };
106
+ expect(pruneRemoteHealth(h, ["live"])).toEqual({ live: { streak: 1, lastFailedAt: 5 } });
107
+ expect(pruneRemoteHealth(h, [])).toEqual({});
108
+ });
109
+ });
110
+
111
+ describe("remoteHealth.noteRemoteResult", () => {
112
+ const NOW = 1_000_000_000;
113
+
114
+ it("clears the streak completely on success", () => {
115
+ // A host that comes back must return to full speed immediately, not decay
116
+ // through the backoff it earned while it was down.
117
+ expect(noteRemoteResult({ streak: 9, lastFailedAt: NOW }, true, NOW + 1)).toEqual({
118
+ streak: 0,
119
+ lastFailedAt: 0,
120
+ });
121
+ });
122
+
123
+ it("extends the streak on failure, from no record and from an existing one", () => {
124
+ expect(noteRemoteResult(undefined, false, NOW)).toEqual({ streak: 1, lastFailedAt: NOW });
125
+ expect(noteRemoteResult({ streak: 2, lastFailedAt: 1 }, false, NOW)).toEqual({
126
+ streak: 3,
127
+ lastFailedAt: NOW,
128
+ });
129
+ });
130
+ });