agent-dag 1.46.3 → 1.48.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.
@@ -40,8 +40,8 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-CifRQYMu.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-BQgkpz9j.css">
43
+ <script type="module" crossorigin src="/assets/index-PPcCF-io.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-DT1bdZn0.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.46.3",
3
+ "version": "1.48.0",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,26 @@
31
31
  "about a defect in the type, and the suite refuses both it and no space at",
32
32
  "all."
33
33
  ],
34
+ "1.48.0": [
35
+ {
36
+ "title": "👁 Browser Watch — see what a program opened while you were away",
37
+ "body": "There is a new eye in the top bar. It watches the browsers on this machine and tells you when a program — an extension, an agent, a command — opened pages while nobody was touching the browser.\n\nUsually that is your own agent doing what you asked, and the panel says so rather than crying intrusion. What it gives you is the evidence: which pages, in which browser, at what time, with every address written out in full.\n\nIt is off until you switch it on, and nothing leaves this machine. It reads a copy of the browser's own history and only pages newer than the moment the deck started — never anything from before, never cookies, never saved passwords. Open ⚙ inside the panel and \"What Browser Watch can access\" says exactly what it reads, keeps and sends.\n\nWhen it finds something the eye turns amber with a count. You can dismiss a finding once you have looked at it and it stays gone, while the log file on disk keeps the record. On a Mac it can also close the tab it found, or quit the browser."
38
+ },
39
+ {
40
+ "title": "🔑 Move all your accounts to another machine in one paste",
41
+ "body": "Sharing accounts between two decks used to be one account at a time, and step four of six is where people stop — leaving the second deck half set up with nothing saying so.\n\nPick the accounts you want, copy one blob, paste it into the other deck. The import adds what is missing, leaves what already works alone, heals an account whose token has died, and tells you which of those happened to each one.\n\nThe blob carries real credentials, so it expires ten minutes after you make it and the deck refuses an expired one before it unwraps anything."
42
+ }
43
+ ],
44
+ "1.47.0": [
45
+ {
46
+ "title": "🔌 The top bar goes quiet when everything is fine",
47
+ "body": "The green \"live\" pill used to sit up there permanently to tell you nothing was broken. It is gone while things are working.\n\nYou will still see a pill when it matters: amber when you pause the canvas, with how many events are waiting, and red when the deck loses its connection to the server. No pill means all is well."
48
+ },
49
+ {
50
+ "title": "🔴 The browser tab tells you when the connection drops",
51
+ "body": "The little icon in the tab has always shown what your sessions are doing — grey when it is quiet, blue when something is working, amber when a session is waiting on you.\n\nIt could not say the one thing that matters most: that the deck has stopped hearing from the server. Until now it just kept showing whatever it had last seen. It turns into a broken red ring instead, so you can spot it from another tab without switching over."
52
+ }
53
+ ],
34
54
  "1.46.0": [
35
55
  {
36
56
  "title": "🔊 Pick a volume and a sound for each of the two tones",
@@ -0,0 +1,506 @@
1
+ // Which browser visits a program drove while nobody was at the browser.
2
+ //
3
+ // Pure classification. No sqlite handle, no filesystem, no clock, no network:
4
+ // visit rows in, findings and episodes out. That is deliberate and it is the
5
+ // reason this file is the one carrying the judgement calls — every threshold
6
+ // below can be proved from a list of literals, where the same rule buried in
7
+ // the reader could only be argued about.
8
+ //
9
+ // MEASURED, on 46 days of this machine's real history — 26,395 visits:
10
+ //
11
+ // * Chrome sets PAGE_TRANSITION_FROM_API (0x08000000) on 769 of them, 2.9%,
12
+ // and every single one of those also carried core transition type LINK
13
+ // (the low byte, `transition & 0xFF`). The bit is the signal, and it is not
14
+ // smeared across transition types the way a heuristic would be.
15
+ // * The `visit_source` table looks like the obvious place to ask instead. It
16
+ // is EMPTY. Chrome fills it only for visits that arrived by sync or import,
17
+ // so on a normal profile it answers nothing — and it answers nothing
18
+ // SILENTLY, which reads exactly like "no program has ever touched this
19
+ // browser". Do not reach for it.
20
+ // * The quiet gate, swept over the same history:
21
+ //
22
+ // quiet findings/46d per day after excluding the deck's own tabs
23
+ // 5 min 94 2.04 73
24
+ // 15 min 18 0.39 18
25
+ // 30 min 14 0.30 14
26
+ // 60 min 14 0.30 14
27
+ // 120 min 13 0.28 13
28
+ //
29
+ // Fifteen minutes is where the noise drops from two a day to under one in
30
+ // two days, and nothing past it is bought at any price. Hence the default.
31
+ // * The 14 that survive a 60-minute gate are not fourteen events. They are
32
+ // ONE burst, on 2026-08-24 between 17:05 and 17:44, every one of them on
33
+ // the same GitLab project — settings, jobs, branches. Fourteen rows
34
+ // describe that worse than one card does. That is what toEpisodes is for.
35
+ // * Without exclusions the deck reports ITSELF: 41 visits to 127.0.0.1:4317
36
+ // and 34 to 127.0.0.1:4399 carry FROM_API, because ccdeck opens its own tab
37
+ // through `open` on every start (bin/deck.js) and the listen fallback range
38
+ // means it is not always the same port. defaultExclusions() is that, and
39
+ // only that.
40
+ //
41
+ // WHAT "NOBODY WAS AT THE BROWSER" MEANS HERE. It is derived from the visit
42
+ // list itself: a human visit is any visit WITHOUT the bit. There is no OS idle
43
+ // probe anywhere in this feature and there is not going to be one — under
44
+ // Wayland the real idle time is not readable at all without a portal the user
45
+ // has to grant, and on Windows it is a native call per poll. A gap in the
46
+ // browser's own history costs nothing, is the same three lines on every
47
+ // platform this ships to, and is closer to the question actually being asked:
48
+ // not "was the screen locked" but "was anyone driving this browser".
49
+ //
50
+ // The window is either side of the candidate, not just before it. A program
51
+ // that opened a tab ninety seconds before the user came back and started
52
+ // clicking was not working in an empty room, and only the AFTER half of the
53
+ // window can tell you that.
54
+ //
55
+ // ONE BOUNDARY RULE, spelled once for both thresholds: each names the first
56
+ // distance that counts as FAR. A human strictly closer than `quietMs` cancels a
57
+ // candidate and a human exactly `quietMs` away does not; two visits strictly
58
+ // closer than `gapMs` stay in one episode and two exactly `gapMs` apart start a
59
+ // second. Written down because "within 15 minutes" is ambiguous in English, and
60
+ // a test that leaves the boundary unpinned passes whichever way the code drifts.
61
+ //
62
+ // THE VOCABULARY IS DELIBERATE. Nothing here is an "intrusion". The single
63
+ // episode this rule found in 46 days was almost certainly the author's own
64
+ // Claude Code session driving a browser he had asked it to drive. This module
65
+ // reports program navigation; the person reading the card decides what it was.
66
+
67
+ /** Chrome's PAGE_TRANSITION_FROM_API qualifier — bit 27 of `visits.transition`.
68
+ * Set when the navigation was started through an API rather than by a person
69
+ * in the UI, which covers `open`, the debugger protocol, and an extension. */
70
+ export const FROM_API = 0x08000000;
71
+
72
+ /** The same bit for the arbitrary-precision path below. */
73
+ const FROM_API_BIG = BigInt(FROM_API);
74
+
75
+ /** Longest decimal string that is certainly exact as a double: 999999999999999
76
+ * < 2^53. Anything longer goes through BigInt rather than through a rounding
77
+ * that would be invisible here. */
78
+ const EXACT_DIGITS = 15;
79
+
80
+ /** The width of the field, for undoing a signed reading of it. Pre-widened to
81
+ * BigInt as well, so the long-string path does not allocate two of them per
82
+ * row it looks at. */
83
+ const UINT32 = 4294967296;
84
+ const INT32_MIN = -2147483648;
85
+ const UINT32_BIG = BigInt(UINT32);
86
+ const INT32_MIN_BIG = BigInt(INT32_MIN);
87
+
88
+ /** Loopback spelled every way a browser records it. The deck opens
89
+ * `http://127.0.0.1:<port>` itself, but a user who bookmarked the deck may
90
+ * have typed `localhost` — same server, different history rows, and an
91
+ * exclusion list that only knows one of them still lets the deck report
92
+ * itself. Bracketed for v6 because that is what `URL` produces. */
93
+ const LOOPBACK = ["127.0.0.1", "localhost", "[::1]"];
94
+
95
+ /**
96
+ * The transition as an exact non-negative integer — a Number, or a BigInt when
97
+ * only a BigInt can hold it — or null when the input is not one at all.
98
+ *
99
+ * The reader CASTs the column to text, because a transition is an unsigned
100
+ * 32-bit field that sqlite hands back as a signed 64-bit integer and the
101
+ * bindings differ on what they do with the top of that range; text is the one
102
+ * representation nobody can round. A Number is accepted too, so a caller
103
+ * holding a plain row — a test, or a second reader — is not forced to
104
+ * stringify it first, and a BigInt because both sqlite bindings in reach have a
105
+ * mode that returns one.
106
+ *
107
+ * A string of more than 15 digits becomes a BigInt. Not because Chrome emits
108
+ * one — the largest qualifier is 0x80000000 and every value observed fits in 32
109
+ * bits — but because the whole point of the CAST upstream is that the value
110
+ * survives the trip, and finishing the journey with `Number(text)` would put
111
+ * the rounding back one line later. Above 2^79 a double's step is wider than
112
+ * bit 27, so the bit this module exists to read is exactly the one that would
113
+ * be lost.
114
+ */
115
+ function transitionValue(transition) {
116
+ if (typeof transition === "number") {
117
+ if (!Number.isFinite(transition)) return null;
118
+ return transition < 0 ? unsigned32(transition, UINT32, INT32_MIN) : transition;
119
+ }
120
+ if (typeof transition === "bigint") {
121
+ return transition < 0n ? unsigned32(transition, UINT32_BIG, INT32_MIN_BIG) : transition;
122
+ }
123
+ if (typeof transition === "string") {
124
+ const text = transition.trim();
125
+ // A sign and digits: a CAST of Chrome's column produces nothing else, and
126
+ // anything else is a row this module has no business guessing about.
127
+ if (!/^-?\d+$/.test(text)) return null;
128
+ if (text.length > EXACT_DIGITS) {
129
+ const big = BigInt(text);
130
+ return big < 0n ? unsigned32(big, UINT32_BIG, INT32_MIN_BIG) : big;
131
+ }
132
+ const value = Number(text);
133
+ return value < 0 ? unsigned32(value, UINT32, INT32_MIN) : value;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * A negative transition, read back as the unsigned 32-bit value it is.
140
+ *
141
+ * `visits.transition` is an UNSIGNED 32-bit field whose top qualifier is
142
+ * SERVER_REDIRECT, 0x80000000. Anything that reads or composes it through a
143
+ * signed 32-bit lens produces a negative number carrying the same bits, and
144
+ * JavaScript makes that happen by accident: `0x80000000 | 0x08000000` is
145
+ * -2013265920, not 2281701376, because every bitwise operator here goes through
146
+ * ToInt32. This module's own test hit it while trying to spell a redirected
147
+ * program navigation.
148
+ *
149
+ * Reinterpreting is the only reading of a negative that can be right, because
150
+ * the field has no negative values. It does mean a corrupt -1 reads as every
151
+ * qualifier at once and answers true — which is the direction chosen on
152
+ * purpose. A false positive is a card the user dismisses; a false negative is
153
+ * this feature's original sin, the empty `visit_source` table that answered
154
+ * "nothing ever happened" and was believed.
155
+ *
156
+ * Below -2^31 there is no 32-bit field to undo, so that is a value from
157
+ * somewhere else entirely and answers null.
158
+ *
159
+ * The width is passed in rather than closed over so that one statement of the
160
+ * rule serves both the Number and the BigInt path — the two cannot share an
161
+ * operator, and they were written out twice before, which is one place for the
162
+ * next edit to only half-land.
163
+ */
164
+ function unsigned32(value, width, floor) {
165
+ return value >= floor ? value + width : null;
166
+ }
167
+
168
+ /**
169
+ * Was this navigation started by a program?
170
+ *
171
+ * Bit 27 of the transition, and nothing else. Not the core type: every FROM_API
172
+ * visit measured here was also LINK, which is evidence that the bit means what
173
+ * it says rather than a second condition to require — a program navigation with
174
+ * some other core type is still a program navigation, and demanding LINK would
175
+ * be fitting the rule to the 769 rows that happened to be on this disk.
176
+ *
177
+ * The bit is read with arithmetic rather than with `&`. `&` coerces its operand
178
+ * through ToInt32, so a value that arrives as a BigInt throws outright ("cannot
179
+ * mix BigInt and other types") and a value that arrives as a long numeric
180
+ * string is rounded to a double before the mask ever sees it. Division and a
181
+ * remainder work on the number that is actually there.
182
+ */
183
+ export function isProgramNavigation(transition) {
184
+ const value = transitionValue(transition);
185
+ if (value === null) return false;
186
+ if (typeof value === "bigint") return (value & FROM_API_BIG) !== 0n;
187
+ return Math.floor(value / FROM_API) % 2 === 1;
188
+ }
189
+
190
+ /** A timestamp as a finite number, or null.
191
+ *
192
+ * Written out rather than `Number(value)` because `Number(null)` is 0 and
193
+ * `Number("")` is 0: a row with a missing timestamp would otherwise be filed
194
+ * as a real visit at the Unix epoch. It would never be near enough to a
195
+ * candidate to silence one, so nothing would ever fail — the list would just
196
+ * quietly stop being the list of visits. */
197
+ function toMs(value) {
198
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
199
+ if (typeof value === "bigint") return Number(value);
200
+ if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value.trim())) return Number(value);
201
+ return null;
202
+ }
203
+
204
+ /** `hostname` and `port` of a matcher or a URL, parsed by the one parser.
205
+ *
206
+ * Both sides go through `URL` on purpose. It lower-cases the host, punycodes a
207
+ * non-ASCII one, brackets IPv6 and drops a port that is the scheme's default —
208
+ * four normalisations, and a matcher compared against a visit is only ever
209
+ * right if both of them got all four. A scheme is prepended when the matcher
210
+ * has none, which is how `127.0.0.1:4317` and `*.example.com` parse at all. */
211
+ function hostParts(text) {
212
+ if (typeof text !== "string") return null;
213
+ const trimmed = text.trim();
214
+ if (trimmed === "") return null;
215
+ const absolute = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
216
+ let url;
217
+ try {
218
+ url = new URL(absolute);
219
+ } catch {
220
+ return null;
221
+ }
222
+ if (url.hostname === "") return null;
223
+ return { host: url.host, hostname: url.hostname, port: url.port };
224
+ }
225
+
226
+ /**
227
+ * Exclusion matchers for the deck's own pages, from the origins it is listening
228
+ * on — `["http://127.0.0.1:4317"]`, or whatever the fallback range settled on.
229
+ *
230
+ * This is not politeness. ccdeck opens its own tab through `open`, so every
231
+ * start writes a FROM_API visit to the deck's own origin, and those visits land
232
+ * exactly where the rule is most sensitive: nobody is browsing at the moment a
233
+ * background process opens a tab. Unfiltered, the feature's loudest and most
234
+ * frequent finding is the feature itself.
235
+ *
236
+ * Each origin expands to its port on all three loopback spellings, because the
237
+ * deck opens 127.0.0.1 and a returning user may have typed localhost, and one
238
+ * history holds both. The port is kept: `127.0.0.1` on its own would exclude
239
+ * every local dev server the user runs, which is a real answer being thrown
240
+ * away to solve a problem that is only about one port.
241
+ */
242
+ export function defaultExclusions(deckOrigins) {
243
+ const list = Array.isArray(deckOrigins)
244
+ ? deckOrigins
245
+ : typeof deckOrigins === "string" ? [deckOrigins] : [];
246
+ const out = [];
247
+ for (const origin of list) {
248
+ const parts = hostParts(origin);
249
+ if (parts === null) continue;
250
+ const hosts = LOOPBACK.includes(parts.hostname) ? LOOPBACK : [parts.hostname];
251
+ for (const host of hosts) {
252
+ const matcher = parts.port === "" ? host : `${host}:${parts.port}`;
253
+ // Two origins on the same loopback port — 127.0.0.1 and localhost, say —
254
+ // are one exclusion, and a list that repeats itself reads like a bug.
255
+ if (!out.includes(matcher)) out.push(matcher);
256
+ }
257
+ }
258
+ return out;
259
+ }
260
+
261
+ /**
262
+ * Compile exclusion strings into host tests.
263
+ *
264
+ * Three forms, all host-based and none of them looking at the path or the
265
+ * scheme — an exclusion answers "not this site", and a rule that could be
266
+ * dodged by the same server answering on https would be a rule with a hole in
267
+ * it that nobody would find until it mattered:
268
+ *
269
+ * `127.0.0.1:4317` that host on that port
270
+ * `example.com` that host on any port
271
+ * `*.example.com` that host and anything under it, on any port
272
+ *
273
+ * A port narrows; no port does not. That way `defaultExclusions` can name one
274
+ * port without hiding the rest of localhost, and a user writing `example.com`
275
+ * gets what they meant rather than only the default port.
276
+ */
277
+ function compileExclusions(exclude) {
278
+ const list = Array.isArray(exclude) ? exclude : typeof exclude === "string" ? [exclude] : [];
279
+ const rules = [];
280
+ for (const entry of list) {
281
+ const parts = hostParts(entry);
282
+ if (parts === null) continue;
283
+ const wildcard = parts.hostname.startsWith("*.");
284
+ const base = wildcard ? parts.hostname.slice(2) : parts.hostname;
285
+ // `*.` alone has nothing under it and would match every host on earth.
286
+ if (base === "") continue;
287
+ rules.push({ base, wildcard, port: parts.port });
288
+ }
289
+ return rules;
290
+ }
291
+
292
+ function isExcluded(rules, visit) {
293
+ for (const rule of rules) {
294
+ if (rule.port !== "" && rule.port !== visit.port) continue;
295
+ if (rule.wildcard) {
296
+ if (visit.hostname === rule.base || visit.hostname.endsWith(`.${rule.base}`)) return true;
297
+ } else if (visit.hostname === rule.base) {
298
+ return true;
299
+ }
300
+ }
301
+ return false;
302
+ }
303
+
304
+ /**
305
+ * Distance from `t` to the nearest value in an ASCENDING array, or Infinity if
306
+ * the array is empty.
307
+ *
308
+ * Binary search, not a scan. `classify` is handed the whole history — 26,395
309
+ * rows on the machine this was measured on, and a browser profile that is not
310
+ * pruned goes much further — and the honest shape of the question is "for each
311
+ * of the 769 candidates, how far is the nearest of the other 25,626". Comparing
312
+ * every pair is a hundred million comparisons that grows with the square of the
313
+ * profile; sorting once and bisecting is the same answer in milliseconds.
314
+ *
315
+ * Both neighbours are checked, because the nearest human visit can be on either
316
+ * side of the candidate and the insertion point only knows about one of them.
317
+ *
318
+ * `<` rather than `<=` inside the loop is not load-bearing and no test pins it:
319
+ * it moves `lo` between the first index equal to `t` and the first index after
320
+ * the equal run, and since both `lo` and `lo - 1` are then measured, either
321
+ * spelling reports the same distance. Said out loud so the next reader does not
322
+ * go looking for the case that distinguishes them.
323
+ */
324
+ function nearestDistance(sortedTimes, t) {
325
+ if (sortedTimes.length === 0) return Infinity;
326
+ let lo = 0;
327
+ let hi = sortedTimes.length;
328
+ while (lo < hi) {
329
+ const mid = Math.floor((lo + hi) / 2);
330
+ if (sortedTimes[mid] < t) lo = mid + 1;
331
+ else hi = mid;
332
+ }
333
+ // `lo` is the first index at or after `t`; `lo - 1` is the last one before it.
334
+ let best = Infinity;
335
+ if (lo < sortedTimes.length) best = sortedTimes[lo] - t;
336
+ if (lo > 0) best = Math.min(best, t - sortedTimes[lo - 1]);
337
+ return best;
338
+ }
339
+
340
+ /** Chronological, then by URL. The URL tie-break is not decoration: visits
341
+ * recorded in the same millisecond would otherwise come out in whatever order
342
+ * the caller's query happened to hand them over, and the output of a pure
343
+ * function should be a function of its input's CONTENT. */
344
+ function byTimeThenUrl(a, b) {
345
+ if (a.timeMs !== b.timeMs) return a.timeMs - b.timeMs;
346
+ return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
347
+ }
348
+
349
+ /**
350
+ * The program-driven visits that happened in silence.
351
+ *
352
+ * `visits` is `[{ url, timeMs, transition }]` in any order. The answer is
353
+ * `[{ url, timeMs, host }]`, oldest first.
354
+ *
355
+ * Two things worth stating about what counts as evidence of a person.
356
+ *
357
+ * EXCLUSIONS DO NOT REMOVE EVIDENCE. They are applied to candidates only. A
358
+ * human visit to an excluded origin is still a human at the browser — somebody
359
+ * reading the deck's own UI is somebody sitting there — and dropping those rows
360
+ * before the quiet gate would manufacture silence around the exact moments the
361
+ * user was watching this feature.
362
+ *
363
+ * A PROGRAM VISIT NEVER SILENCES ANOTHER. A burst of automated navigation is
364
+ * the thing being detected, so counting its own rows as company would make a
365
+ * long agent session cancel itself and leave only the short ones — the exact
366
+ * inversion of what this is for.
367
+ */
368
+ export function classify(visits, { quietMs = 15 * 60_000, exclude = [] } = {}) {
369
+ if (!Array.isArray(visits) || visits.length === 0) return [];
370
+ const rules = compileExclusions(exclude);
371
+ const humanTimes = [];
372
+ const candidates = [];
373
+
374
+ for (const visit of visits) {
375
+ const timeMs = toMs(visit?.timeMs);
376
+ // A row with no readable timestamp is neither evidence nor a candidate: it
377
+ // cannot be placed on the line the whole rule is about.
378
+ if (timeMs === null) continue;
379
+ if (!isProgramNavigation(visit?.transition)) {
380
+ // Note the order — a human visit counts even if its URL is unparseable.
381
+ // `about:blank` and a typed search that never resolved are still hands on
382
+ // the keyboard, and they are common enough that discarding them would
383
+ // widen every quiet window that touches one.
384
+ humanTimes.push(timeMs);
385
+ continue;
386
+ }
387
+ const parts = hostParts(visit?.url);
388
+ // A candidate with no host has nothing an episode could be named after and
389
+ // nothing an exclusion could match, so it cannot be reported usefully.
390
+ if (parts === null) continue;
391
+ candidates.push({ url: visit.url, timeMs, host: parts.host, hostname: parts.hostname, port: parts.port });
392
+ }
393
+
394
+ humanTimes.sort((a, b) => a - b);
395
+
396
+ const findings = [];
397
+ for (const candidate of candidates) {
398
+ if (nearestDistance(humanTimes, candidate.timeMs) < quietMs) continue;
399
+ if (isExcluded(rules, candidate)) continue;
400
+ findings.push({ url: candidate.url, timeMs: candidate.timeMs, host: candidate.host });
401
+ }
402
+ findings.sort(byTimeThenUrl);
403
+ return findings;
404
+ }
405
+
406
+ /**
407
+ * Findings grouped into episodes — `[{ host, startMs, endMs, count, urls }]`,
408
+ * newest first, `urls` oldest first inside each.
409
+ *
410
+ * The measured case is the argument for this function existing. Fourteen
411
+ * findings survived a 60-minute gate over 46 days and all fourteen were one
412
+ * program working through one GitLab project between 17:05 and 17:44. As a list
413
+ * that is fourteen alarms; as an episode it is one sentence — "something drove
414
+ * your browser around one project for forty minutes while you were away" —
415
+ * which is the sentence a person can actually act on.
416
+ *
417
+ * The gap is measured between CONSECUTIVE visits, not from the start of the
418
+ * episode. An episode is therefore unbounded in length as long as no single
419
+ * silence inside it reaches `gapMs`, which is what a working agent looks like:
420
+ * the 17:05-17:44 burst is 39 minutes long and stays one card.
421
+ *
422
+ * FIFTEEN MINUTES, AND THE REAL BURST IS WHY. Ten was the first guess and it
423
+ * was wrong in a way only the real profile could show: that GitLab run goes
424
+ * quiet for twelve minutes between 17:12 and 17:24 — somebody reading a jobs
425
+ * page — so a ten-minute gap shreds one session into three cards, which is the
426
+ * fourteen-alarm problem back in smaller print. Sweeping the whole profile,
427
+ * episodes fall 8, 8, 6, 6, 6 at gaps of 5, 10, 15, 20 and 30 minutes: fifteen
428
+ * is where the count settles and nothing above it buys anything, the same
429
+ * plateau shape that fixed `quietMs`. A synthetic burst cannot find this,
430
+ * because an evenly spaced one has no silence in it to be wrong about.
431
+ *
432
+ * Grouped by `host`, which includes the port, so the deck's own 4317 and 4399
433
+ * are two hosts rather than one — different servers, and on a machine where
434
+ * both were running they were different sessions.
435
+ *
436
+ * Newest first by `startMs`: the list answers "what began most recently",
437
+ * because an episode's headline is when the program started working. Ties fall
438
+ * back to `endMs` and then to the host so that the order is fixed by the
439
+ * findings themselves and not by the order they were collected in.
440
+ */
441
+ export function toEpisodes(findings, { gapMs = 15 * 60_000 } = {}) {
442
+ if (!Array.isArray(findings) || findings.length === 0) return [];
443
+
444
+ const byHost = new Map();
445
+ const browserOf = new Map();
446
+ for (const finding of findings) {
447
+ const host = typeof finding?.host === "string" && finding.host !== "" ? finding.host : null;
448
+ const url = typeof finding?.url === "string" ? finding.url : null;
449
+ const timeMs = toMs(finding?.timeMs);
450
+ if (host === null || url === null || timeMs === null) continue;
451
+ const rows = byHost.get(host);
452
+ if (rows === undefined) byHost.set(host, [{ url, timeMs }]);
453
+ else rows.push({ url, timeMs });
454
+ // `browser` rides on the HOST, not on each url row: a url row is evidence
455
+ // and its shape is pinned by a test that is right to pin it. A reaction
456
+ // downstream has to know which application to tell, and one host's findings
457
+ // all came from the same profile.
458
+ if (!browserOf.has(host) && typeof finding?.browser === "string") {
459
+ browserOf.set(host, finding.browser);
460
+ }
461
+ }
462
+
463
+ const groups = [];
464
+ for (const [host, rows] of byHost) {
465
+ // A copy was built above, so this sorts nothing the caller can see. Callers
466
+ // hand this the output of `classify`, and a function that reordered its
467
+ // argument as a side effect would be a trap the second caller finds.
468
+ rows.sort(byTimeThenUrl);
469
+ let open = null;
470
+ for (const row of rows) {
471
+ if (open !== null && row.timeMs - open.endMs < gapMs) {
472
+ open.urls.push(row);
473
+ open.endMs = row.timeMs;
474
+ continue;
475
+ }
476
+ open = { host, browser: browserOf.get(host) ?? null, startMs: row.timeMs, endMs: row.timeMs, urls: [row] };
477
+ groups.push(open);
478
+ }
479
+ }
480
+
481
+ // `count` derived at the end rather than incremented alongside `urls`, so the
482
+ // number on the card cannot disagree with the list under it.
483
+ const episodes = groups.map(g => ({
484
+ host: g.host,
485
+ // THE TAG THIS FUNCTION SPENDS A MAP BUILDING. It was set on the group and
486
+ // then dropped here, because this rebuilds each episode field by field and
487
+ // the field was never added — so `browserOf` above was careful, commented,
488
+ // dead code, and every episode reached the panel with `browser: null`.
489
+ //
490
+ // What that cost, none of it visible as an error: a reaction had nothing to
491
+ // tell which application to close, and the radar's ring for a finding fell
492
+ // through `findIndex(...) === -1` into `Math.max(0, -1)` and drew itself on
493
+ // whichever browser happened to be first.
494
+ browser: g.browser,
495
+ startMs: g.startMs,
496
+ endMs: g.endMs,
497
+ count: g.urls.length,
498
+ urls: g.urls,
499
+ }));
500
+ episodes.sort((a, b) => (
501
+ b.startMs - a.startMs ||
502
+ b.endMs - a.endMs ||
503
+ (a.host < b.host ? -1 : a.host > b.host ? 1 : 0)
504
+ ));
505
+ return episodes;
506
+ }