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
package/ts/share.ts CHANGED
@@ -147,7 +147,7 @@ export interface ShareOpts {
147
147
  * generation/token), startShare mints a fresh room, persists it, and calls
148
148
  * this so the caller can refresh its stored link. Leave unset for explicit
149
149
  * webrtc:// URLs, which must NOT be silently rotated. */
150
- onRotate?: (info: { room: string; link: string }) => void | Promise<void>;
150
+ onRotate?: (info: { room: string; link: string; joinLink: string }) => void | Promise<void>;
151
151
  }
152
152
 
153
153
  // The room+token persist like the serve token, so the share link (and any
@@ -183,7 +183,13 @@ export async function loadOrCreateShareRoom(sighost = DEFAULT_SIGHOST): Promise<
183
183
 
184
184
  function parseShareUrl(s: string): { room: string; token: string; host: string } {
185
185
  const m = /^webrtc:\/\/([^:@/]+):([^@/]+)@(.+)$/.exec(s);
186
- if (!m) throw new Error(`bad --share url: ${s} (want webrtc://room:token@host)`);
186
+ // Callers normalize any user-facing spelling (https://…/room/#…) to this
187
+ // internal form via toWebrtcUrl, so reaching here means a caller bug — point at
188
+ // the shape operators actually hold rather than at the internal one.
189
+ if (!m)
190
+ throw new Error(
191
+ `bad room url: ${s} (want a https://agent-yes.com/room/#room=<id>&s=e1.<secret> link)`,
192
+ );
187
193
  return { room: m[1]!, token: m[2]!, host: m[3]! };
188
194
  }
189
195
 
@@ -200,6 +206,27 @@ function formatShareLink(room: string, S: string, host: string): string {
200
206
  return `${ui}/#${room}:${MARKER}${S}${suffix}`;
201
207
  }
202
208
 
209
+ // The /room/ link for a room — the one you hand to ANOTHER machine to attach it
210
+ // to this room. Opened in a browser it's a guide page (which renders the install
211
+ // one-liner client-side from this very fragment, so the secret never reaches a
212
+ // server); passed to `ay serve --webrtc` it joins.
213
+ //
214
+ // The fragment is key=value (URLSearchParams) rather than the positional form
215
+ // /w/ uses: the fragment namespace is shared with #k=, #ch= and #launch=, and a
216
+ // positional grammar can only be told apart from those by a blocklist someone
217
+ // has to remember to update (see parseRoomHash in lab/ui/rtc.js). Bare `k=v&…`
218
+ // — no leading "?" — matches OAuth 2.0's implicit-grant fragment (RFC 6749
219
+ // §4.2.2), the same trick for the same reason: a credential the server must
220
+ // never see.
221
+ export function formatRoomLink(room: string, S: string, host: string): string {
222
+ const prod = host === DEFAULT_SIGHOST;
223
+ const ui = prod ? "https://agent-yes.com/room" : "http://localhost:7778/room";
224
+ // Values must be URL-safe: `s` is hex and `room` is [A-Za-z0-9_-], but encode
225
+ // anyway — URLSearchParams decodes on the way out and reads "+" as a space.
226
+ const q = `room=${encodeURIComponent(room)}&s=${encodeURIComponent(MARKER + S)}`;
227
+ return `${ui}/#${q}${prod ? "" : `&sig=${encodeURIComponent(host)}`}`;
228
+ }
229
+
203
230
  // Derive the shareable console link from a persisted/explicit webrtc://room:token@host
204
231
  // URL WITHOUT starting a bridge, so `ay serve install` can print the same link the
205
232
  // background daemon will serve. Throws on a legacy (unencrypted) room, mirroring
@@ -307,7 +334,7 @@ export async function importRTC(): Promise<any> {
307
334
  * process exits, reconnecting signaling on drop. Returns the shareable link. */
308
335
  export async function startShare(
309
336
  opts: ShareOpts,
310
- ): Promise<{ room: string; link: string; close: () => void }> {
337
+ ): Promise<{ room: string; link: string; joinLink: string; close: () => void }> {
311
338
  const minted = !opts.url;
312
339
  const sighost = opts.sighost ?? DEFAULT_SIGHOST;
313
340
  const initial = opts.url
@@ -337,8 +364,12 @@ export async function startShare(
337
364
 
338
365
  const wsScheme = host.startsWith("localhost") || host.startsWith("127.") ? "ws" : "wss";
339
366
  const mkLink = () => formatShareLink(room, S, host);
367
+ // The /room/ link — handed to ANOTHER machine to attach it to this room,
368
+ // where the console link above is for a human to watch it.
369
+ const mkJoinLink = () => formatRoomLink(room, S, host);
340
370
  let authToken = await deriveAuthToken(S, room, host);
341
371
  let link = mkLink();
372
+ let joinLink = mkJoinLink();
342
373
 
343
374
  const RTCPeerConnection = await importRTC();
344
375
 
@@ -356,6 +387,7 @@ export async function startShare(
356
387
  S = parseSecret(token).s;
357
388
  authToken = await deriveAuthToken(S, room, host);
358
389
  link = mkLink();
390
+ joinLink = mkJoinLink();
359
391
  // close() may have run during the await above — don't persist/announce or let
360
392
  // the caller reconnect a room for a share that's shutting down.
361
393
  if (closed) return false;
@@ -365,7 +397,7 @@ export async function startShare(
365
397
  } catch {
366
398
  /* best effort — in-memory rotation still lets new browsers join */
367
399
  }
368
- await opts.onRotate({ room, link });
400
+ await opts.onRotate({ room, link, joinLink });
369
401
  return true;
370
402
  };
371
403
 
@@ -885,5 +917,5 @@ export async function startShare(
885
917
  }
886
918
  for (const peerId of [...peers.keys()]) closePeer(peerId);
887
919
  };
888
- return { room, link, close };
920
+ return { room, link, joinLink, close };
889
921
  }
package/ts/subcommands.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  */
13
13
 
14
14
  import { randomBytes } from "crypto";
15
- import { closeSync, constants as fsConstants, openSync } from "fs";
15
+ import { closeSync, constants as fsConstants, openSync, realpathSync } from "fs";
16
+ import { execFileSync } from "node:child_process";
16
17
  import { appendFile, mkdir, open, readFile, stat, writeFile } from "fs/promises";
17
18
  import ms from "ms";
18
19
  import { homedir } from "os";
@@ -72,11 +73,20 @@ import { framePaste as frameAsPaste, shouldFramePaste } from "./bracketedPaste.t
72
73
  import {
73
74
  ageMatchesRegistration,
74
75
  findAgentAncestor,
76
+ pidOwnershipVerdict,
75
77
  readAncestryTable,
76
78
  type SenderVia,
77
79
  } from "./senderAncestry.ts";
78
80
  import yargs from "yargs";
79
81
  import { type ResolvedRemote, readRemotes, resolveRemoteSpec } from "./remotes.ts";
82
+ import {
83
+ noteRemoteResult,
84
+ pruneRemoteHealth,
85
+ readRemoteHealth,
86
+ remoteBackoffMs,
87
+ shouldSkipRemote,
88
+ writeRemoteHealth,
89
+ } from "./remoteHealth.ts";
80
90
  import { isWebrtcSpec } from "./webrtcLink.ts";
81
91
  import { withIpcLock } from "./ipcLock.ts";
82
92
 
@@ -289,6 +299,35 @@ function observedSender(): ObservedSender {
289
299
  };
290
300
  }
291
301
 
302
+ /**
303
+ * The repository a directory belongs to, as git itself defines it: the common
304
+ * dir, which every linked worktree of one repository shares and no separate
305
+ * clone does. Null when the path is not in a repository or git cannot answer.
306
+ *
307
+ * Synchronous and cached: it is consulted at most twice per send, and only on
308
+ * the path where the cheaper directory check already failed.
309
+ */
310
+ const _commonDirCache = new Map<string, string | null>();
311
+ function gitCommonDir(dir: string): string | null {
312
+ const hit = _commonDirCache.get(dir);
313
+ if (hit !== undefined) return hit;
314
+ let out: string | null = null;
315
+ try {
316
+ out =
317
+ execFileSync("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
318
+ encoding: "utf8",
319
+ timeout: 5000,
320
+ stdio: ["ignore", "pipe", "ignore"],
321
+ })
322
+ .trim()
323
+ .replace(/\/+$/, "") || null;
324
+ } catch {
325
+ out = null;
326
+ }
327
+ _commonDirCache.set(dir, out);
328
+ return out;
329
+ }
330
+
292
331
  async function resolveSender(): Promise<GlobalPidRecord | null> {
293
332
  return (await resolveSenderVia()).agent;
294
333
  }
@@ -355,12 +394,78 @@ async function computeSenderVia(): Promise<{ agent: GlobalPidRecord | null; via:
355
394
  // The honest path is unchanged in OUTCOME: the wrapper that injects
356
395
  // AGENT_YES_PID is an ancestor of the `ay send` it spawns, so a normal lane
357
396
  // corroborates and renders exactly as before.
358
- const corroborated = inherited?.pid === declared.pid;
359
- // A disagreement is REPORTED, never silently resolved. Attributing to the
360
- // ancestor instead would break an honest lane whose tree we misread; giving
361
- // it a plain "env" would launder a claim into a fact. So the claim stands
362
- // and the receiver is told it stands alone.
363
- return { agent: declared, via: corroborated ? "env" : "env-uncorroborated" };
397
+ // The claimed lane is our ancestor: claim and kernel agree.
398
+ if (inherited?.pid === declared.pid) return { agent: declared, via: "env" };
399
+
400
+ // It is not. That alone does not say which of two very different things is
401
+ // happening, and the earlier rule — "is some OTHER registered lane my
402
+ // ancestor" — picked the wrong discriminator. Measured on real traffic it
403
+ // was inverted: it fired on a lane's own nested processes (same tree,
404
+ // benign) and stayed silent while a different worktree sent two documents
405
+ // under this lane's name.
406
+ //
407
+ // The working directory separates them. A lane's own detached helper runs
408
+ // inside that lane's tree; a process in a DIFFERENT tree claiming the lane
409
+ // is the case the marker exists for. cwd is observed, not asserted — the
410
+ // body cannot set it.
411
+ // BOTH sides are realpath'd first. macOS resolves /tmp -> /private/tmp and
412
+ // /var -> /private/var, so a lane registered under a symlinked path would
413
+ // otherwise never appear to contain its own processes and every honest send
414
+ // from it would be accused. Caught by a same-tree test that went loud.
415
+ // Best-effort: an unreadable path falls back to the raw string rather than
416
+ // failing the send.
417
+ const real = (p2: string): string => {
418
+ try {
419
+ return realpathSync(p2).replace(/\/+$/, "");
420
+ } catch {
421
+ return p2.replace(/\/+$/, "");
422
+ }
423
+ };
424
+ const here = real(process.cwd());
425
+ const claimedRoot = declared.cwd ? real(declared.cwd) : "";
426
+ const insideClaimedTree =
427
+ Boolean(claimedRoot) && (here === claimedRoot || here.startsWith(claimedRoot + path.sep));
428
+ // "Inside the tree" is not the same as "belongs to that lane". A lane
429
+ // routinely works in a LINKED WORKTREE that is a sibling directory, not a
430
+ // child — `~/ws/org/_wt/feature-x` alongside `~/ws/org/repo/tree/dev`. A
431
+ // bare `ay send` from a shell there inherits AGENT_YES_PID honestly and
432
+ // would be accused on a path check alone.
433
+ //
434
+ // git already answers this exactly: linked worktrees of one repository
435
+ // share a git common dir, while a separate clone has its own. Measured on
436
+ // the fleet that reported it — the sibling worktree resolved to the lane's
437
+ // own `.git`, and the worktree that had been misattributing resolved to a
438
+ // different one. So the same probe separates the honest case from the case
439
+ // this marker exists for, which a "same parent directory" heuristic would
440
+ // not: both live under the same ancestor.
441
+ //
442
+ // Only consulted when the path check already failed, so the honest common
443
+ // path pays nothing.
444
+ // CONTAINMENT, not equality. A lane's submodule worktrees have a common dir
445
+ // NESTED inside the lane's own:
446
+ //
447
+ // lane …/tree/dev/.git
448
+ // its submodule wt …/tree/dev/.git/modules/lib/desktop <- descendant
449
+ // a separate clone …/tree/billings/.git <- neither
450
+ //
451
+ // Equality would mark the first case LOUD — the same false positive one
452
+ // layer down, reported from the fleet that runs that shape. Checked in both
453
+ // directions because the mirror layout (a lane registered in the submodule
454
+ // worktree, sending from the parent tree) is equally legitimate and would
455
+ // otherwise wait to be discovered by firing. A separate clone is under
456
+ // neither, so detection is unchanged.
457
+ const sameRepo = () => {
458
+ if (!claimedRoot) return false;
459
+ const a = gitCommonDir(here);
460
+ const b = gitCommonDir(claimedRoot);
461
+ if (a === null || b === null) return false;
462
+ return a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep);
463
+ };
464
+ if (!insideClaimedTree && !sameRepo()) return { agent: declared, via: "env-uncorroborated" };
465
+
466
+ // Inside the claimed lane's own tree, or nothing to compare against:
467
+ // unverifiable, not suspicious, and must not be dressed as it.
468
+ return { agent: declared, via: "env-unverified" };
364
469
  }
365
470
  // A set-but-unresolvable AGENT_YES_PID (stale env, aged-out record) is not a
366
471
  // reason to stop: the process tree can still say who this is.
@@ -687,10 +792,16 @@ export function envelopeAttribution(via: SenderVia): string {
687
792
  // Derived from the process tree because the wrapper's env was absent.
688
793
  return " via process-tree";
689
794
  case "env-uncorroborated":
690
- // The caller's own claim, and nothing corroborates it: any process can
691
- // export another lane's pid (#461). Loud, because a receiver that acts on
692
- // a message weighted by its sender must not be handed this as a fact.
795
+ // The claim and the process tree name DIFFERENT lanes. Loud, because this
796
+ // is the case a receiver acting on sender weight must not be handed as a
797
+ // fact — and loud only here, so it keeps meaning something.
693
798
  return " UNCORROBORATED-SENDER";
799
+ case "env-unverified":
800
+ // Nothing to disagree with. Silent in the envelope: the receiver gains
801
+ // nothing actionable from "we could not check", and a marker on honest
802
+ // traffic is how the loud one stops being read. It is still recorded in
803
+ // from_via for anyone who wants to weigh it.
804
+ return "";
694
805
  default:
695
806
  return "";
696
807
  }
@@ -1006,14 +1117,28 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
1006
1117
  }
1007
1118
  case "gc": {
1008
1119
  const { gcOldBinaryDirs } = await import("./rustBinary.ts");
1009
- const res = gcOldBinaryDirs();
1010
- if (res.removed.length === 0) {
1120
+ const { gcLogs } = await import("./globalPidIndex.ts");
1121
+ const bins = gcOldBinaryDirs();
1122
+ // Logs are the bigger leak of the two: binary dirs are ~20-30 MiB per
1123
+ // release, while a single long-lived session's raw log can pass 500.
1124
+ const logs = await gcLogs();
1125
+
1126
+ if (bins.removed.length === 0) {
1011
1127
  process.stdout.write("no old agent-yes binary cache dirs to remove\n");
1012
1128
  } else {
1013
- for (const v of res.removed) process.stdout.write(`removed ${v}\n`);
1014
- const mib = (res.freedBytes / 1024 / 1024).toFixed(1);
1129
+ for (const v of bins.removed) process.stdout.write(`removed ${v}\n`);
1130
+ const mib = (bins.freedBytes / 1024 / 1024).toFixed(1);
1131
+ process.stdout.write(
1132
+ `freed ${mib} MiB (${bins.freedBytes} bytes) across ${bins.removed.length} version dir(s)\n`,
1133
+ );
1134
+ }
1135
+
1136
+ if (logs.removed.length === 0) {
1137
+ process.stdout.write("no stale session logs to remove\n");
1138
+ } else {
1139
+ const mib = (logs.freedBytes / 1024 / 1024).toFixed(1);
1015
1140
  process.stdout.write(
1016
- `freed ${mib} MiB (${res.freedBytes} bytes) across ${res.removed.length} version dir(s)\n`,
1141
+ `freed ${mib} MiB (${logs.freedBytes} bytes) across ${logs.removed.length} session log file(s)\n`,
1017
1142
  );
1018
1143
  }
1019
1144
  return 0;
@@ -1155,7 +1280,7 @@ export async function cmdHelp(managerCommands = true): Promise<number> {
1155
1280
  ` ay result <keyword> [--wait] pull an agent's structured result envelope\n` +
1156
1281
  ` ay result set '<json>' (inside an agent) deposit your result envelope\n` +
1157
1282
  ` ay reap kill process groups leaked by dead agents\n` +
1158
- ` ay gc remove old-version binary cache dirs and report freed space\n` +
1283
+ ` ay gc reclaim old-version binary cache dirs + stale session logs\n` +
1159
1284
  ` ay dsh-legacy [args...] launch the DeepSeek Harness terminal client (dsh-tui)\n` +
1160
1285
  wsLines +
1161
1286
  `\n` +
@@ -1790,6 +1915,14 @@ async function fetchRemoteRecordsRaw(
1790
1915
  url: string,
1791
1916
  token: string,
1792
1917
  opts: { all: boolean; active: boolean; keyword?: string },
1918
+ /**
1919
+ * Set true when the host ANSWERED, false when it did not. A healthy host with
1920
+ * no agents — or one where `--keyword` matched nothing — returns [] exactly
1921
+ * like a dead one, so row count cannot stand in for reachability: using it
1922
+ * would mark a live-but-empty host failed and hide it for up to 32 minutes,
1923
+ * which is the failure this whole change exists to avoid.
1924
+ */
1925
+ onReachable?: (ok: boolean) => void,
1793
1926
  ): Promise<any[]> {
1794
1927
  const params = new URLSearchParams();
1795
1928
  if (opts.all) params.set("all", "1");
@@ -1810,9 +1943,13 @@ async function fetchRemoteRecordsRaw(
1810
1943
  headers: { Authorization: `Bearer ${bearer}` },
1811
1944
  signal: AbortSignal.timeout(8000),
1812
1945
  });
1946
+ // A 4xx/5xx still means the host is THERE and talking; only a throw (DNS,
1947
+ // refused connection, connect timeout, aborted bridge) means unreachable.
1948
+ onReachable?.(true);
1813
1949
  if (!res.ok) return [];
1814
1950
  return (await res.json()) as any[];
1815
1951
  } catch {
1952
+ onReachable?.(false);
1816
1953
  return [];
1817
1954
  } finally {
1818
1955
  bridge?.close();
@@ -1833,16 +1970,47 @@ async function runAllRemotesLs(opts: {
1833
1970
  cwdScope: null,
1834
1971
  };
1835
1972
 
1973
+ // A remote that is not answering costs a full connect timeout (25s for a
1974
+ // WebRTC spec) and returns nothing, and this gathers them together — so two
1975
+ // dead entries made every `ay ls` here take 27s instead of 5s. Skip a host
1976
+ // that has been failing, on a doubling backoff, and keep LISTING it as
1977
+ // unreachable so the config stays honest and the operator can see it is being
1978
+ // skipped rather than silently dropped. See ts/remoteHealth.ts.
1979
+ const health = await readRemoteHealth();
1980
+ const healthNow = Date.now();
1981
+ const skipped = new Set(
1982
+ [...remotes.keys()].filter((alias) => shouldSkipRemote(health[alias], healthNow)),
1983
+ );
1984
+ const probed = [...remotes.entries()].filter(([alias]) => !skipped.has(alias));
1985
+
1836
1986
  const [localResult, ...remoteResults] = await Promise.allSettled([
1837
1987
  listRecords(opts.keyword, localOpts).then((recs) => ({
1838
1988
  host: "local",
1839
1989
  records: recs as any[],
1840
1990
  })),
1841
- ...Array.from(remotes.entries()).map(([alias, cfg]) =>
1842
- fetchRemoteRecordsRaw(cfg.url, cfg.token, opts).then((records) => ({ host: alias, records })),
1843
- ),
1991
+ ...probed.map(([alias, cfg]) => {
1992
+ let reachable = false;
1993
+ return fetchRemoteRecordsRaw(cfg.url, cfg.token, opts, (ok) => {
1994
+ reachable = ok;
1995
+ }).then((records) => ({ host: alias, records, ok: reachable }));
1996
+ }),
1844
1997
  ]);
1845
1998
 
1999
+ // Record what we learned, then persist once. A host that answered clears its
2000
+ // streak; one that did not extends it. Best-effort: a health file we cannot
2001
+ // write must never break `ay ls`.
2002
+ {
2003
+ // Prune first, so an alias removed from remotes.yaml stops being carried
2004
+ // forever. `remotes` is the live configured set.
2005
+ const next = pruneRemoteHealth({ ...health }, remotes.keys());
2006
+ for (const [i, [alias]] of probed.entries()) {
2007
+ const res = remoteResults[i];
2008
+ const ok = res?.status === "fulfilled" && (res.value as any).ok === true;
2009
+ next[alias] = noteRemoteResult(health[alias], ok, healthNow);
2010
+ }
2011
+ await writeRemoteHealth(next).catch(() => {});
2012
+ }
2013
+
1846
2014
  // Group by host in a stable order (local first, then each remote alias in
1847
2015
  // config order), so the aggregated table reads top-down per machine.
1848
2016
  const byHost: { host: string; records: any[] }[] = [];
@@ -1882,6 +2050,28 @@ async function runAllRemotesLs(opts: {
1882
2050
  );
1883
2051
  byHost.push({ host: "local", records: enriched });
1884
2052
  }
2053
+ // A skipped host is REPORTED, not dropped. Silently omitting it would make a
2054
+ // machine that can come back disappear from the fleet view with nothing to
2055
+ // notice — the same failure as deleting it from the config, which is why we
2056
+ // did not do that. One line to stderr per skipped host, so `--json` and any
2057
+ // parser downstream stay unaffected.
2058
+ for (const alias of skipped) {
2059
+ const h = health[alias];
2060
+ // Time REMAINING, not the window length: a host with one second left would
2061
+ // otherwise report "retrying in up to 32m", which is true of the window and
2062
+ // false of the wait, and the operator is reading this to decide whether to
2063
+ // wait or force a probe.
2064
+ const leftMs = Math.max(
2065
+ 0,
2066
+ remoteBackoffMs(h?.streak ?? 1) - (healthNow - (h?.lastFailedAt ?? healthNow)),
2067
+ );
2068
+ const left =
2069
+ leftMs >= 60_000 ? `${Math.round(leftMs / 60_000)}m` : `${Math.ceil(leftMs / 1000)}s`;
2070
+ process.stderr.write(
2071
+ `${alias}: unreachable — skipped after ${h?.streak ?? 0} failed attempt(s), ` +
2072
+ `retrying in ${left} (ay ls ${alias} to force a probe now)\n`,
2073
+ );
2074
+ }
1885
2075
  for (const res of remoteResults) {
1886
2076
  if (res.status === "fulfilled")
1887
2077
  byHost.push({ host: res.value.host, records: res.value.records });
@@ -5415,6 +5605,36 @@ async function cmdRestart(rest: string[]): Promise<number> {
5415
5605
  let exited = await waitForExit(record.pid, 30_000);
5416
5606
  if (!exited) {
5417
5607
  // Wouldn't go gracefully — SIGKILL the pid (the reaper sweeps its pgid).
5608
+ //
5609
+ // But ONLY if the process at that number is still plausibly the agent we
5610
+ // registered. pids are reused: a row can outlive its agent and name
5611
+ // whatever now holds the number. This host carried a row 89 days old,
5612
+ // surviving a reboot, whose pid was macOS `login` running as root.
5613
+ //
5614
+ // Nothing in this code was declining to kill that. The OS was, with
5615
+ // EPERM, and only because the owner differed — a same-user recycled pid
5616
+ // would have been killed. Deciding is the code's job, not the kernel's.
5617
+ //
5618
+ // Same age test the send path uses (#460): a process that started AFTER
5619
+ // its registration cannot be the thing that registered.
5620
+ //
5621
+ // Refuse on EVIDENCE, not on ignorance. "Could not establish" is a third
5622
+ // answer, not a quiet synonym for "reused": win32 has no process table
5623
+ // reader, so treating unknown as reused would not guard this kill, it
5624
+ // would delete restart's force-kill from that platform entirely. Where
5625
+ // we cannot tell, behaviour is exactly what it was before this guard.
5626
+ const table = await readAncestryTable();
5627
+ const owns = pidOwnershipVerdict(table?.get(record.pid)?.ageSecs, record.started_at);
5628
+ if (owns === "reused") {
5629
+ process.stderr.write(
5630
+ `pid ${record.pid} is alive but is NOT the agent that registered it — ` +
5631
+ `its process is younger than the registration, so the number was reused. ` +
5632
+ `Refusing to kill it.\n` +
5633
+ ` check with: ps -p ${record.pid} -o pid=,user=,comm=\n` +
5634
+ ` retire the stale row instead: ay stop ${record.pid}\n`,
5635
+ );
5636
+ return 1;
5637
+ }
5418
5638
  try {
5419
5639
  process.kill(record.pid, "SIGKILL");
5420
5640
  } catch {
@@ -233,6 +233,10 @@ export class AyTerminal {
233
233
  // Interactive input (opt-in): forward keystrokes to the agent's stdin as raw
234
234
  // bytes (code:"none" — the terminal itself sends \r). Fails closed to a mirror
235
235
  // on a 403 (a read-only token), like the rgui embed's attachStdin/onDenied.
236
+ //
237
+ // `raw: true` marks this a terminal wire, not a message (#453). Without it
238
+ // every keystroke and SGR mouse report becomes an inbox row, and the inbox
239
+ // is capacity-capped — a mouse scrolled over the pane evicts real messages.
236
240
  if (!this.readOnly) {
237
241
  this.term.onData((data: string) => {
238
242
  if (!this.writable) return;
@@ -240,7 +244,7 @@ export class AyTerminal {
240
244
  .fetch(this.url("/api/send"), {
241
245
  method: "POST",
242
246
  headers: { "content-type": "application/json" },
243
- body: JSON.stringify({ keyword: this.pid, msg: data, code: "none" }),
247
+ body: JSON.stringify({ keyword: this.pid, msg: data, code: "none", raw: true }),
244
248
  })
245
249
  .then((r: any) => {
246
250
  if (r.status === 403 || r.status === 401) this.revokeWrite();
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { DEFAULT_SIGHOST, isWebrtcSpec, parseWebrtcLink } from "./webrtcLink.ts";
2
+ import { DEFAULT_SIGHOST, isWebrtcSpec, parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
3
3
 
4
4
  // A representative v2 share secret (e1.<64 hex>); parseSecret keeps the hex `s`.
5
5
  const SECRET = "e1.982610a3034f065bfe9700037b306a6afeb7dc48567064058e6c4bbc09e502c2";
@@ -42,4 +42,48 @@ describe("parseWebrtcLink", () => {
42
42
  expect(parseWebrtcLink("just-an-alias")).toBeNull();
43
43
  expect(parseWebrtcLink("https://agent-yes.com/w/#noColonHere")).toBeNull();
44
44
  });
45
+
46
+ it("parses the key=value /room/ fragment", () => {
47
+ const r = parseWebrtcLink(`https://agent-yes.com/room/#room=r223104&s=${SECRET}`);
48
+ expect(r).toMatchObject({ room: "r223104", host: DEFAULT_SIGHOST });
49
+ });
50
+
51
+ it("honors sig= and tolerates a leading ? in the key=value form", () => {
52
+ expect(
53
+ parseWebrtcLink(`https://agent-yes.com/room/#?room=r1&s=${SECRET}&sig=sig.example.com`),
54
+ ).toMatchObject({ room: "r1", host: "sig.example.com" });
55
+ });
56
+
57
+ it("returns null when the key=value form is missing room or s", () => {
58
+ expect(parseWebrtcLink(`https://agent-yes.com/room/#room=r1`)).toBeNull();
59
+ expect(parseWebrtcLink(`https://agent-yes.com/room/#s=${SECRET}`)).toBeNull();
60
+ });
61
+
62
+ // The fragment namespace is shared with #k=, #ch= and #launch= (see
63
+ // parseRoomHash in lab/ui/rtc.js); a foreign key set must not be read as a room.
64
+ it("does not mistake another feature's hash for a room", () => {
65
+ expect(parseWebrtcLink("https://agent-yes.com/w/#k=deadbeef")).toBeNull();
66
+ expect(parseWebrtcLink("https://agent-yes.com/w/#launch=claude")).toBeNull();
67
+ });
68
+ });
69
+
70
+ describe("toWebrtcUrl", () => {
71
+ it("normalizes every accepted spelling to the internal webrtc:// form", () => {
72
+ const want = `webrtc://r1:${SECRET}@${DEFAULT_SIGHOST}`;
73
+ expect(toWebrtcUrl(`https://agent-yes.com/room/#room=r1&s=${SECRET}`)).toBe(want);
74
+ expect(toWebrtcUrl(`https://agent-yes.com/w/#r1:${SECRET}`)).toBe(want);
75
+ expect(toWebrtcUrl(want)).toBe(want);
76
+ });
77
+
78
+ // parseSecret strips the "e1." marker; rebuilding from the parsed `s` would
79
+ // hand a legacy markerless room a marker it never had, changing the token the
80
+ // signaling DO pinned the room to.
81
+ it("round-trips a legacy markerless token unchanged", () => {
82
+ const legacy = `webrtc://r1:plaintexttoken@${DEFAULT_SIGHOST}`;
83
+ expect(toWebrtcUrl(legacy)).toBe(legacy);
84
+ });
85
+
86
+ it("returns null for a non-link", () => {
87
+ expect(toWebrtcUrl("just-an-alias")).toBeNull();
88
+ });
45
89
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { DEFAULT_SIGHOST, isWebrtcSpec, parseWebrtcLink } from "./webrtcLink.ts";
2
+ import { DEFAULT_SIGHOST, isWebrtcSpec, parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
3
3
 
4
4
  // A representative v2 share secret (e1.<64 hex>); parseSecret keeps the hex `s`.
5
5
  const SECRET = "e1.982610a3034f065bfe9700037b306a6afeb7dc48567064058e6c4bbc09e502c2";
@@ -42,4 +42,48 @@ describe("parseWebrtcLink", () => {
42
42
  expect(parseWebrtcLink("just-an-alias")).toBeNull();
43
43
  expect(parseWebrtcLink("https://agent-yes.com/w/#noColonHere")).toBeNull();
44
44
  });
45
+
46
+ it("parses the key=value /room/ fragment", () => {
47
+ const r = parseWebrtcLink(`https://agent-yes.com/room/#room=r223104&s=${SECRET}`);
48
+ expect(r).toMatchObject({ room: "r223104", host: DEFAULT_SIGHOST });
49
+ });
50
+
51
+ it("honors sig= and tolerates a leading ? in the key=value form", () => {
52
+ expect(
53
+ parseWebrtcLink(`https://agent-yes.com/room/#?room=r1&s=${SECRET}&sig=sig.example.com`),
54
+ ).toMatchObject({ room: "r1", host: "sig.example.com" });
55
+ });
56
+
57
+ it("returns null when the key=value form is missing room or s", () => {
58
+ expect(parseWebrtcLink(`https://agent-yes.com/room/#room=r1`)).toBeNull();
59
+ expect(parseWebrtcLink(`https://agent-yes.com/room/#s=${SECRET}`)).toBeNull();
60
+ });
61
+
62
+ // The fragment namespace is shared with #k=, #ch= and #launch= (see
63
+ // parseRoomHash in lab/ui/rtc.js); a foreign key set must not be read as a room.
64
+ it("does not mistake another feature's hash for a room", () => {
65
+ expect(parseWebrtcLink("https://agent-yes.com/w/#k=deadbeef")).toBeNull();
66
+ expect(parseWebrtcLink("https://agent-yes.com/w/#launch=claude")).toBeNull();
67
+ });
68
+ });
69
+
70
+ describe("toWebrtcUrl", () => {
71
+ it("normalizes every accepted spelling to the internal webrtc:// form", () => {
72
+ const want = `webrtc://r1:${SECRET}@${DEFAULT_SIGHOST}`;
73
+ expect(toWebrtcUrl(`https://agent-yes.com/room/#room=r1&s=${SECRET}`)).toBe(want);
74
+ expect(toWebrtcUrl(`https://agent-yes.com/w/#r1:${SECRET}`)).toBe(want);
75
+ expect(toWebrtcUrl(want)).toBe(want);
76
+ });
77
+
78
+ // parseSecret strips the "e1." marker; rebuilding from the parsed `s` would
79
+ // hand a legacy markerless room a marker it never had, changing the token the
80
+ // signaling DO pinned the room to.
81
+ it("round-trips a legacy markerless token unchanged", () => {
82
+ const legacy = `webrtc://r1:plaintexttoken@${DEFAULT_SIGHOST}`;
83
+ expect(toWebrtcUrl(legacy)).toBe(legacy);
84
+ });
85
+
86
+ it("returns null for a non-link", () => {
87
+ expect(toWebrtcUrl("just-an-alias")).toBeNull();
88
+ });
45
89
  });