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.
- package/README.md +6 -2
- package/dist/web/assets/index-DT1bdZn0.css +1 -0
- package/dist/web/assets/index-PPcCF-io.js +111 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/src/server/agent-activity.mjs +506 -0
- package/src/server/browser-history.mjs +467 -0
- package/src/server/browser-presence.mjs +157 -0
- package/src/server/browser-profiles.mjs +267 -0
- package/src/server/browser-react.mjs +204 -0
- package/src/server/browser-watch-store.mjs +288 -0
- package/src/server/browser-watch.mjs +717 -0
- package/src/server/claude-accounts.mjs +77 -7
- package/src/server/cswap-admin.mjs +419 -31
- package/src/server/index.mjs +152 -2
- package/src/server/installer.mjs +13 -0
- package/src/server/relay-guard.mjs +502 -0
- package/dist/web/assets/index-BQgkpz9j.css +0 -1
- package/dist/web/assets/index-CifRQYMu.js +0 -97
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -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
|
+
}
|