agent-yes 1.169.1 → 1.171.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 (61) hide show
  1. package/dist/SUPPORTED_CLIS-BMExykB1.js +8 -0
  2. package/dist/{SUPPORTED_CLIS-Bwg9VqLI.js → SUPPORTED_CLIS-D6MArV7A.js} +2 -2
  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-BlGV0y4j.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-mYbyV0SD.js +228 -0
  21. package/dist/{schedule-CtVvSyaN.js → schedule-C5nUV0GO.js} +7 -7
  22. package/dist/{serve-CLmk5ujF.js → serve-ChcayCC3.js} +122 -26
  23. package/dist/{setup-CYbXOcpH.js → setup-CCQZ9tPB.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-CIk1SSdW.js +9 -0
  28. package/dist/{subcommands--EUush_X.js → subcommands-CtWxwydR.js} +1049 -44
  29. package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
  30. package/dist/{ts-Bbhf5JbN.js → ts-WHYKp13w.js} +8 -8
  31. package/dist/{versionChecker-BmWkWhcJ.js → versionChecker-BoPKPL9B.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 +125 -10
  36. package/lab/ui/index.html +340 -11
  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/notifyDaemon.spec.ts +276 -0
  43. package/ts/notifyDaemon.ts +698 -0
  44. package/ts/notifyInbox.spec.ts +192 -0
  45. package/ts/notifyInbox.ts +294 -0
  46. package/ts/notifyRouter.spec.ts +355 -0
  47. package/ts/notifyRouter.ts +288 -0
  48. package/ts/notifyStore.spec.ts +308 -0
  49. package/ts/notifyStore.ts +439 -0
  50. package/ts/reaper.spec.ts +18 -1
  51. package/ts/reaper.ts +33 -1
  52. package/ts/rustBinary.ts +30 -0
  53. package/ts/serve.ts +194 -14
  54. package/ts/subcommands.spec.ts +341 -0
  55. package/ts/subcommands.ts +764 -29
  56. package/ts/workspaceConfig.spec.ts +48 -0
  57. package/ts/workspaceConfig.ts +46 -3
  58. package/dist/SUPPORTED_CLIS-DiL99G3j.js +0 -8
  59. package/dist/pidStore-5qyLX3Fh.js +0 -5
  60. package/dist/spawnGate-CQ1Il3Xk.js +0 -5
  61. package/dist/subcommands-1ZcaHYkb.js +0 -9
@@ -1,4 +1,4 @@
1
- import { n as getRunningAgentCount } from "./runningLock-BobVW1_A.js";
1
+ import { n as getRunningAgentCount } from "./runningLock-CNMl13dC.js";
2
2
  import { existsSync } from "fs";
3
3
  import { mkdir, readFile, unlink, writeFile } from "fs/promises";
4
4
  import { homedir } from "os";
@@ -187,4 +187,4 @@ async function startTray() {
187
187
 
188
188
  //#endregion
189
189
  export { ensureTray, startTray };
190
- //# sourceMappingURL=tray-CWUpaZF4.js.map
190
+ //# sourceMappingURL=tray-DXr7iK3E.js.map
@@ -1,10 +1,10 @@
1
1
  import { n as logger, t as addTransport } from "./logger-CDIsZ-Pp.js";
2
- import { r as getInstalledPackage } from "./versionChecker-BmWkWhcJ.js";
3
- import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
4
- import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-BobVW1_A.js";
5
- import { t as PidStore } from "./pidStore-BfoBWUjc.js";
6
- import { i as readGlobalPids } from "./globalPidIndex-DlmmJlO8.js";
7
- import { n as register, r as sweep } from "./reaper-C-eWAxIj.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-BoPKPL9B.js";
3
+ import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
4
+ import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-CNMl13dC.js";
5
+ import { t as PidStore } from "./pidStore-BIvsBQ8X.js";
6
+ import { i as readGlobalPids } from "./globalPidIndex-CoNr7tS8.js";
7
+ import { n as register, r as sweep } from "./reaper-CWF2_ATd.js";
8
8
  import { arch, platform } from "process";
9
9
  import { execSync } from "child_process";
10
10
  import { closeSync, constants, createReadStream, existsSync, mkdirSync, openSync } from "fs";
@@ -1084,7 +1084,7 @@ async function notifyWebhook(status, details, cwd = process.cwd()) {
1084
1084
 
1085
1085
  //#endregion
1086
1086
  //#region ts/index.ts
1087
- const config = await import("./agent-yes.config-Bl08kMpl.js").then((mod) => mod.default || mod);
1087
+ const config = await import("./agent-yes.config-ugPYTPOd.js").then((mod) => mod.default || mod);
1088
1088
  const CLIS_CONFIG = config.clis;
1089
1089
  /**
1090
1090
  * Main function to run agent-cli with automatic yes/no responses
@@ -1824,4 +1824,4 @@ function sleep(ms) {
1824
1824
 
1825
1825
  //#endregion
1826
1826
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1827
- //# sourceMappingURL=ts-Bbhf5JbN.js.map
1827
+ //# sourceMappingURL=ts-WHYKp13w.js.map
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
 
8
8
  //#region package.json
9
9
  var name = "agent-yes";
10
- var version = "1.169.1";
10
+ var version = "1.171.0";
11
11
 
12
12
  //#endregion
13
13
  //#region ts/versionChecker.ts
@@ -215,4 +215,4 @@ async function displayVersion() {
215
215
 
216
216
  //#endregion
217
217
  export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
218
- //# sourceMappingURL=versionChecker-BmWkWhcJ.js.map
218
+ //# sourceMappingURL=versionChecker-BoPKPL9B.js.map
@@ -1,4 +1,4 @@
1
- import { u as parseSecret } from "./e2e-BeKjLhmO.js";
1
+ import { u as parseSecret } from "./e2e-jb0Hp43q.js";
2
2
 
3
3
  //#region ts/webrtcLink.ts
4
4
  const SIGNAL_SUBPROTOCOL = "ay-signal-1";
@@ -42,4 +42,4 @@ function isWebrtcSpec(spec) {
42
42
 
43
43
  //#endregion
44
44
  export { isWebrtcSpec as n, parseWebrtcLink as r, SIGNAL_SUBPROTOCOL as t };
45
- //# sourceMappingURL=webrtcLink-BG0Xc4-W.js.map
45
+ //# sourceMappingURL=webrtcLink-B7REGtK2.js.map
@@ -1,5 +1,5 @@
1
- import { a as computeTranscriptHash, c as open, d as randomHex, f as seal, l as packEnvelope, n as FLAG_CONFIRM, o as deriveAuthToken, p as unpackEnvelope, s as deriveDirKeys } from "./e2e-BeKjLhmO.js";
2
- import { n as isWebrtcSpec, r as parseWebrtcLink, t as SIGNAL_SUBPROTOCOL } from "./webrtcLink-BG0Xc4-W.js";
1
+ import { a as computeTranscriptHash, c as open, d as randomHex, f as seal, l as packEnvelope, n as FLAG_CONFIRM, o as deriveAuthToken, p as unpackEnvelope, s as deriveDirKeys } from "./e2e-jb0Hp43q.js";
2
+ import { n as isWebrtcSpec, r as parseWebrtcLink, t as SIGNAL_SUBPROTOCOL } from "./webrtcLink-B7REGtK2.js";
3
3
  import { RTCPeerConnection } from "node-datachannel/polyfill";
4
4
 
5
5
  //#region ts/webrtcRemote.ts
@@ -256,4 +256,4 @@ async function startWebrtcBridge(link) {
256
256
 
257
257
  //#endregion
258
258
  export { startWebrtcBridge };
259
- //# sourceMappingURL=webrtcRemote-SybKurg9.js.map
259
+ //# sourceMappingURL=webrtcRemote-Bx-eD_0I.js.map
@@ -1,4 +1,4 @@
1
- import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
1
+ import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
2
2
  import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import path from "path";
@@ -80,8 +80,17 @@ function isProvisionAllowed(owner, repo) {
80
80
  function getSpawnHook() {
81
81
  const env = process.env.AGENT_YES_SPAWN_HOOK;
82
82
  if (env && env.trim()) return env;
83
- const h = readConfig().spawnHook;
84
- if (!h || !h.trim()) return null;
83
+ return configHookIfTrusted(readConfig().spawnHook);
84
+ }
85
+ /**
86
+ * Return a config-file hook value only when the config file is safe to trust as
87
+ * a source of code to run: not a symlink (can't be swapped out from under us),
88
+ * owned by us, and not group/world-writable (no other user can rewrite it). The
89
+ * env-var forms of these hooks bypass this — they come from the daemon's own
90
+ * environment, which is already trusted. Returns null when unset or guarded out.
91
+ */
92
+ function configHookIfTrusted(value) {
93
+ if (!value || !value.trim()) return null;
85
94
  if (process.platform !== "win32") try {
86
95
  if (lstatSync(configPath()).isSymbolicLink()) return null;
87
96
  const st = statSync(configPath());
@@ -90,13 +99,35 @@ function getSpawnHook() {
90
99
  } catch {
91
100
  return null;
92
101
  }
93
- return h;
102
+ return value;
94
103
  }
95
104
  /** Whether a usable {@link getSpawnHook} is configured (for read-only disclosure). */
96
105
  function hasSpawnHook() {
97
106
  return getSpawnHook() !== null;
98
107
  }
99
108
  /**
109
+ * A trusted, HOST-LOCAL shell hook run BEFORE a `fork`/`from` provisioning git
110
+ * op — "koho-style" (codehost-style) provisioning. Env `AGENT_YES_PROVISION_HOOK`
111
+ * overrides the config `provisionHook`; the config form is subject to the same
112
+ * tamper guard as {@link getSpawnHook} ({@link configHookIfTrusted}).
113
+ *
114
+ * Its purpose is to prepare the host for provisioning — most usefully to select
115
+ * the right git identity (e.g. `gh auth switch --user <who>`, keyed on the
116
+ * `KOHO_OWNER`/`KOHO_REPO` env the caller exports) before the clone/worktree/
117
+ * setup runs. When configured it is ALSO the provisioning gate: its exit code
118
+ * decides admission (0 = allow, non-zero = deny), overriding
119
+ * {@link getProvisionAllowlist}. Returns null when unset/guarded.
120
+ */
121
+ function getProvisionHook() {
122
+ const env = process.env.AGENT_YES_PROVISION_HOOK;
123
+ if (env && env.trim()) return env;
124
+ return configHookIfTrusted(readConfig().provisionHook);
125
+ }
126
+ /** Whether a usable {@link getProvisionHook} is configured (for read-only disclosure). */
127
+ function hasProvisionHook() {
128
+ return getProvisionHook() !== null;
129
+ }
130
+ /**
100
131
  * Cap on concurrently-live agents admitted via `/api/spawn`. Env
101
132
  * `AGENT_YES_MAX_AGENTS` overrides the config `maxAgents`. A non-positive,
102
133
  * missing, or unparseable value means **unlimited** (returns undefined), which
@@ -161,5 +192,5 @@ function resolveSpawnCwd(input) {
161
192
  }
162
193
 
163
194
  //#endregion
164
- export { getSpawnWaitMs as a, isProvisionAllowed as c, getSpawnHook as i, resolveSpawnCwd as l, getMinFreeMb as n, getWorkspaceRoot as o, getProvisionRoot as r, hasSpawnHook as s, getMaxAgents as t, setWorkspaceRoot as u };
165
- //# sourceMappingURL=workspaceConfig-BC03X4Y1.js.map
195
+ export { getSpawnHook as a, hasProvisionHook as c, resolveSpawnCwd as d, setWorkspaceRoot as f, getProvisionRoot as i, hasSpawnHook as l, getMinFreeMb as n, getSpawnWaitMs as o, getProvisionHook as r, getWorkspaceRoot as s, getMaxAgents as t, isProvisionAllowed as u };
196
+ //# sourceMappingURL=workspaceConfig-BgqK-31W.js.map
@@ -187,6 +187,18 @@ export function taskLabel(e) {
187
187
  // id), so a newly-added server-side badge is never silently dropped.
188
188
  export const BADGE_META = {
189
189
  "goal-active": { label: "goal", title: "A /goal Stop-hook loop is active on this agent" },
190
+ "session-limit": {
191
+ label: "limit",
192
+ title: "Usage session limit hit — waiting for the reset time shown on screen",
193
+ },
194
+ retrying: {
195
+ label: "retry",
196
+ title: "Waiting for the API — the CLI is auto-retrying on its own backoff (no action needed)",
197
+ },
198
+ typing: {
199
+ label: "typing",
200
+ title: "The user is typing at this agent's terminal — ay send backs off until they pause",
201
+ },
190
202
  };
191
203
 
192
204
  // Status-flag chips ("badges") matched against the agent's screen — e.g. an
@@ -196,11 +208,16 @@ export function badgesFor(e) {
196
208
  return (e.badges || []).map((id) => ({ id, ...(BADGE_META[id] || { label: id, title: id }) }));
197
209
  }
198
210
 
199
- // Human age of an agent ("12s" / "5m" / "3h"). `now` is injectable so tests
200
- // don't depend on the wall clock; the browser calls age(e) and gets Date.now().
211
+ // Time since the agent was last active ("12s" / "5m" / "3h") measured from
212
+ // its last stdout write (last_active_at, the log file's mtime), so a long-lived
213
+ // but quiet agent reads as stale rather than "new". Falls back to started_at
214
+ // when the server hasn't stamped a last-active time (e.g. freshly spawned, no
215
+ // log yet). `now` is injectable so tests don't depend on the wall clock; the
216
+ // browser calls age(e) and gets Date.now().
201
217
  export function age(e, now = Date.now()) {
202
- if (!e.started_at) return "";
203
- const s = Math.max(0, (now - e.started_at) / 1000);
218
+ const at = e.last_active_at ?? e.started_at;
219
+ if (!at) return "";
220
+ const s = Math.max(0, (now - at) / 1000);
204
221
  if (s < 60) return Math.floor(s) + "s";
205
222
  if (s < 3600) return Math.floor(s / 60) + "m";
206
223
  return Math.floor(s / 3600) + "h";
@@ -331,7 +348,7 @@ function agentForestNodes(list) {
331
348
  // flat entry list BEFORE layeredRows builds the room/peer/agent tree (which keeps
332
349
  // the given order for siblings and first-seen order for room/peer groups). So
333
350
  // sorting reorders roots and siblings without breaking the nesting.
334
- export const SORT_MODES = ["state", "created", "identity"];
351
+ export const SORT_MODES = ["state", "active", "created", "identity"];
335
352
 
336
353
  // Attention-first state ranking: someone scanning the fleet wants the agents that
337
354
  // need them (needs_input) up top, then the wedged ones (stuck), then live work,
@@ -358,19 +375,29 @@ function gitWeight(e) {
358
375
  return (g.changed || 0) + (g.ahead || 0) + (g.behind || 0) + (g.dirty ? 0.5 : 0);
359
376
  }
360
377
 
378
+ // Last-active instant for an entry: its last stdout write (last_active_at),
379
+ // falling back to started_at when the server hasn't stamped one yet.
380
+ function lastActive(e) {
381
+ return e.last_active_at ?? e.started_at ?? 0;
382
+ }
383
+
361
384
  // Return a NEW array sorted for display per `mode` (default "state"):
362
385
  // - "state": attention-first state, then git busyness, then newest.
386
+ // - "active": most recently active first (last_active_at desc).
363
387
  // - "created": newest first (started_at desc).
364
388
  // - "identity": user@host:owner/repo/branch (alphabetical).
365
389
  // Every comparator falls back to newest-first so order is total & deterministic.
366
390
  export function sortEntries(entries, mode = "state") {
367
391
  const byNewest = (a, b) => (b.started_at || 0) - (a.started_at || 0);
392
+ const byActive = (a, b) => lastActive(b) - lastActive(a) || byNewest(a, b);
368
393
  const cmp =
369
- mode === "created"
370
- ? byNewest
371
- : mode === "identity"
372
- ? (a, b) => fullIdent(a).localeCompare(fullIdent(b)) || byNewest(a, b)
373
- : (a, b) => stateRank(a) - stateRank(b) || gitWeight(b) - gitWeight(a) || byNewest(a, b);
394
+ mode === "active"
395
+ ? byActive
396
+ : mode === "created"
397
+ ? byNewest
398
+ : mode === "identity"
399
+ ? (a, b) => fullIdent(a).localeCompare(fullIdent(b)) || byNewest(a, b)
400
+ : (a, b) => stateRank(a) - stateRank(b) || gitWeight(b) - gitWeight(a) || byNewest(a, b);
374
401
  return entries.slice().sort(cmp);
375
402
  }
376
403
 
@@ -465,6 +492,48 @@ function toAgentNode(n) {
465
492
  return { kind: "agent", entry: n.entry, children: n.children.map(toAgentNode) };
466
493
  }
467
494
 
495
+ // When the subagent trees are folded away, each surviving root agent shows a
496
+ // summary chip of the descendants hidden beneath it. Given the FULL layeredRows
497
+ // output, returns a Map keyed by the root agent's `entry` →
498
+ // { total, working, lastActive }:
499
+ // total = alive (non-exited) descendant subagents, at every depth
500
+ // working = those whose status is "active"
501
+ // lastActive = newest last_active_at (fallback started_at) among the alive
502
+ // descendants, or null when none is stamped
503
+ // Descendants roll up to their nearest folded-away ancestor's root, so a folded
504
+ // tree summarizes its whole subtree in one chip. Roots with no alive descendant
505
+ // aren't in the map (no chip). Pure so tests can exercise it without a DOM.
506
+ export function foldSummaries(rows) {
507
+ // Direct parent-agent entry → child agent entries (skip room/peer headers).
508
+ const kids = new Map();
509
+ for (const r of rows) {
510
+ if (r.kind !== "agent" || !r.parentEntry) continue;
511
+ const arr = kids.get(r.parentEntry) || [];
512
+ arr.push(r.entry);
513
+ kids.set(r.parentEntry, arr);
514
+ }
515
+ const summaries = new Map();
516
+ for (const r of rows) {
517
+ if (r.kind !== "agent" || r.parentEntry) continue; // roots only
518
+ const acc = { total: 0, working: 0, lastActive: null };
519
+ const stack = [...(kids.get(r.entry) || [])];
520
+ const seen = new Set();
521
+ while (stack.length) {
522
+ const e = stack.pop();
523
+ if (seen.has(e)) continue; // guard a pathological parent cycle
524
+ seen.add(e);
525
+ for (const c of kids.get(e) || []) stack.push(c);
526
+ if (e.status === "exited") continue;
527
+ acc.total++;
528
+ if (e.status === "active") acc.working++;
529
+ const at = e.last_active_at ?? e.started_at;
530
+ if (at != null && (acc.lastActive == null || at > acc.lastActive)) acc.lastActive = at;
531
+ }
532
+ if (acc.total > 0) summaries.set(r.entry, acc);
533
+ }
534
+ return summaries;
535
+ }
536
+
468
537
  // Next selection index when stepping the list by `dir` (+1 down / -1 up).
469
538
  // No current selection (i<0) lands on the first (down) or last (up) row;
470
539
  // otherwise clamps at the ends. Returns -1 for an empty list.
@@ -480,6 +549,52 @@ export function nextIndex(len, i, dir) {
480
549
  // coordinate math lives here so it's unit-testable without a browser.
481
550
  // ---------------------------------------------------------------------------
482
551
 
552
+ // ---- collaborative presence: peer focus + stdin flash ----------------------
553
+ // The left panel colour-codes who's on which agent: your own selection is blue
554
+ // (the .sel bar), every OTHER human peer's focus is yellow (.peerfocus), and a
555
+ // row pulses when its stdin was just written — independent of focus, since an
556
+ // `ay send` feeds an agent no one is looking at. These pure helpers own the
557
+ // bookkeeping so index.html only measures the DOM.
558
+
559
+ // Detect agents whose stdin just advanced. `seen` maps _key -> the last
560
+ // last_stdin_at we observed; given the current entries, return the _keys whose
561
+ // last_stdin_at is NEWER than what we'd seen (someone typed / `ay send` pushed),
562
+ // and fold the new values into `seen`. A key seen for the FIRST time never
563
+ // flashes (else every agent would pulse on initial load) — only a later bump
564
+ // does. Keys no longer present are pruned so `seen` can't grow without bound.
565
+ export function advanceStdinFlashes(entries, seen) {
566
+ const fresh = [];
567
+ const alive = new Set();
568
+ for (const e of entries) {
569
+ const key = e._key;
570
+ if (!key) continue;
571
+ alive.add(key);
572
+ const at = e.last_stdin_at;
573
+ if (typeof at !== "number") continue;
574
+ const prev = seen.get(key);
575
+ if (prev !== undefined && at > prev) fresh.push(key);
576
+ if (prev === undefined || at > prev) seen.set(key, at);
577
+ }
578
+ for (const key of [...seen.keys()]) if (!alive.has(key)) seen.delete(key);
579
+ return fresh;
580
+ }
581
+
582
+ // Summarize fleet presence for the peers badge + per-row chips. `records` is one
583
+ // entry per OTHER viewer currently watching an agent: { key, viewer } (key = the
584
+ // composite _key of the agent they're on). Returns { total, byKey }: `total`
585
+ // distinct viewers (the "N peers" badge), `byKey` a Map of _key -> how many
586
+ // peers watch that agent (the yellow per-row chip + .peerfocus trigger).
587
+ export function focusSummary(records) {
588
+ const viewers = new Set();
589
+ const byKey = new Map();
590
+ for (const r of records) {
591
+ if (!r || !r.key || !r.viewer) continue;
592
+ viewers.add(r.viewer);
593
+ byKey.set(r.key, (byKey.get(r.key) || 0) + 1);
594
+ }
595
+ return { total: viewers.size, byKey };
596
+ }
597
+
483
598
  // Stable per-viewer hue (0..359) for colour-coding peers' selections.
484
599
  export function hashHue(s) {
485
600
  let h = 0;