agent-dag 1.47.0 → 3.0.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-6cAJDiBs.js +125 -0
- package/dist/web/assets/index-Bnn7d7u8.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -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/codex-usage.mjs +79 -1
- package/src/server/cswap-admin.mjs +485 -36
- package/src/server/index.mjs +172 -4
- package/src/server/installer.mjs +13 -0
- package/src/server/relay-guard.mjs +502 -0
- package/src/server/system-metrics.mjs +715 -32
- package/dist/web/assets/index-BgcITzCV.css +0 -1
- package/dist/web/assets/index-ChVeFKhn.js +0 -101
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// What Browser Watch remembers between runs: whether it is on, how it is tuned,
|
|
2
|
+
// and the episodes it has already seen.
|
|
3
|
+
//
|
|
4
|
+
// THE ARCHIVE IS THE POINT, AND IT IS NOT A CACHE. Everything the panel shows
|
|
5
|
+
// is read live out of Chrome's own history, which is complete and needs no help
|
|
6
|
+
// from us — with one exception that is the whole reason this file exists.
|
|
7
|
+
// Whoever can drive your browser can also clear its history, and they have the
|
|
8
|
+
// same buttons you do. A watch that only ever reads live is a watch that any
|
|
9
|
+
// intruder can erase behind themselves.
|
|
10
|
+
//
|
|
11
|
+
// So while the watch is ON, every episode it sees is copied here, and the panel
|
|
12
|
+
// shows the union of what Chrome still remembers and what the deck already
|
|
13
|
+
// wrote down. What happened while the watch was OFF is at the mercy of the
|
|
14
|
+
// browser, and the panel says so rather than implying an unbroken record.
|
|
15
|
+
//
|
|
16
|
+
// A separate directory rather than a file beside the deck records in
|
|
17
|
+
// `~/.claude/agent-dag`: readLiveDecks() reads every `.json` in that directory
|
|
18
|
+
// and would have to keep skipping this one forever. A subdirectory is not a
|
|
19
|
+
// name it can collide with.
|
|
20
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
23
|
+
|
|
24
|
+
/** Reactions the panel can arm. `close-tab` is macOS-only and the server is the
|
|
25
|
+
* one that says so — a client cannot be trusted to know what the OS can do,
|
|
26
|
+
* and a mode that silently does nothing is worse than one that is not offered. */
|
|
27
|
+
export const REACTIONS = ["notify", "close-tab", "quit-browser"];
|
|
28
|
+
|
|
29
|
+
export const DEFAULTS = {
|
|
30
|
+
v: 1,
|
|
31
|
+
enabled: false,
|
|
32
|
+
reaction: "notify",
|
|
33
|
+
quietMinutes: 15,
|
|
34
|
+
gapMinutes: 15,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The store's schema version.
|
|
39
|
+
*
|
|
40
|
+
* Bumped when the rule that PRODUCES episodes changes, not when their shape
|
|
41
|
+
* does — see readStore. Version 1 kept rows from a thirty-day sweep of the
|
|
42
|
+
* browser's history; version 2 keeps only what the deck saw while watching.
|
|
43
|
+
*/
|
|
44
|
+
const STORE_VERSION = 2;
|
|
45
|
+
|
|
46
|
+
/** How many archived episodes are kept. Roughly two years at the measured rate
|
|
47
|
+
* of one card every eight days, and small enough that the file stays a thing a
|
|
48
|
+
* person could open and read. Trimmed oldest-first. */
|
|
49
|
+
const KEEP = 500;
|
|
50
|
+
|
|
51
|
+
export const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
|
|
52
|
+
export const storePath = (home = claudeConfigDir()) => join(storeDir(home), "state.json");
|
|
53
|
+
|
|
54
|
+
/** The plain-text log, which is the one file here a person opens themselves.
|
|
55
|
+
* state.json is the deck's own record and is JSON because the deck reads it
|
|
56
|
+
* back; this is the same events in the shape `tail -f` wants. */
|
|
57
|
+
export const logPath = (home = claudeConfigDir()) => join(storeDir(home), "watch.log");
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Append one episode, and EVERY ADDRESS IN IT, oldest first.
|
|
61
|
+
*
|
|
62
|
+
* THE URLs ARE THE POINT OF THE FILE. A summary line — host, count, duration —
|
|
63
|
+
* says something happened and leaves the reader unable to act on it: the
|
|
64
|
+
* question three days later is not "did a program touch gitlab" but "WHICH
|
|
65
|
+
* pages", because a jobs list and a settings page mean different things. So
|
|
66
|
+
* every address is written in full, unshortened and unescaped, exactly as
|
|
67
|
+
* Chrome recorded it.
|
|
68
|
+
*
|
|
69
|
+
* Query strings and fragments included. They are frequently the whole content
|
|
70
|
+
* of the visit — `?scope=all`, `#servicii` — and a log that dropped them would
|
|
71
|
+
* be tidier and useless for the one job it has.
|
|
72
|
+
*
|
|
73
|
+
* Indented under their episode so the shape survives `grep`: a summary line
|
|
74
|
+
* starts at column zero, a URL line does not, which is what lets
|
|
75
|
+
* `grep -v '^ '` give the summary alone and `grep '^ '` give the addresses.
|
|
76
|
+
*
|
|
77
|
+
* Append-only and never rewritten: a log a program edits is not a log. It is
|
|
78
|
+
* the only part of this feature that outlives the process by design — the panel
|
|
79
|
+
* shows what this deck has seen, this file is what somebody reads three days
|
|
80
|
+
* later without opening the panel at all.
|
|
81
|
+
*/
|
|
82
|
+
export async function appendLog(episodes, home = claudeConfigDir(), deps = {}) {
|
|
83
|
+
if (!episodes.length) return;
|
|
84
|
+
const mk = deps.mkdir ?? mkdir;
|
|
85
|
+
const add = deps.appendFile ?? appendFile;
|
|
86
|
+
// Local time, not UTC. The reader's question is "what was happening at four
|
|
87
|
+
// yesterday afternoon", and their afternoon is not UTC's — the ISO stamp this
|
|
88
|
+
// replaced was off by the offset for everyone outside London.
|
|
89
|
+
const stamp = ms => {
|
|
90
|
+
const d = new Date(ms);
|
|
91
|
+
const p = n => String(n).padStart(2, "0");
|
|
92
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
|
|
93
|
+
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
94
|
+
};
|
|
95
|
+
const block = e => {
|
|
96
|
+
const span = e.endMs - e.startMs >= 60_000
|
|
97
|
+
? ` over ${Math.round((e.endMs - e.startMs) / 60_000)}m`
|
|
98
|
+
: "";
|
|
99
|
+
const where = e.browser ? ` [${e.browser}]` : "";
|
|
100
|
+
const head = `${stamp(e.startMs)} ${e.host} ${e.count} page${e.count === 1 ? "" : "s"}${span}${where}`;
|
|
101
|
+
const rows = (e.urls ?? []).map(u => ` ${stamp(u.timeMs).slice(11)} ${u.url}`);
|
|
102
|
+
return [head, ...rows].join("\n");
|
|
103
|
+
};
|
|
104
|
+
await mk(storeDir(home), { recursive: true });
|
|
105
|
+
await add(logPath(home), episodes.map(block).join("\n") + "\n", "utf8");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Settings as they will be used, whatever the file said.
|
|
110
|
+
*
|
|
111
|
+
* Every field is checked rather than spread, because this file is on disk and
|
|
112
|
+
* on disk is where a hand edit, a half-written save and an older version all
|
|
113
|
+
* arrive from. A `quietMinutes` of `"15"` or of `0` would otherwise reach
|
|
114
|
+
* classify() and widen the gate to everything, which is the failure that turns
|
|
115
|
+
* the panel into noise — the same reason the route refuses to coerce its query
|
|
116
|
+
* string.
|
|
117
|
+
*/
|
|
118
|
+
export function normalise(raw) {
|
|
119
|
+
const it = raw && typeof raw === "object" ? raw : {};
|
|
120
|
+
const num = (v, fallback, lo, hi) =>
|
|
121
|
+
typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi ? v : fallback;
|
|
122
|
+
return {
|
|
123
|
+
v: 1,
|
|
124
|
+
enabled: it.enabled === true,
|
|
125
|
+
reaction: REACTIONS.includes(it.reaction) ? it.reaction : DEFAULTS.reaction,
|
|
126
|
+
quietMinutes: num(it.quietMinutes, DEFAULTS.quietMinutes, 1, 24 * 60),
|
|
127
|
+
gapMinutes: num(it.gapMinutes, DEFAULTS.gapMinutes, 1, 24 * 60),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** An episode reduced to what an archive needs: enough to redraw the card and
|
|
132
|
+
* enough to recognise it again. `urls` is kept whole — it is the evidence, and
|
|
133
|
+
* an archive that dropped it would preserve the accusation without it. */
|
|
134
|
+
function archivable(e) {
|
|
135
|
+
return {
|
|
136
|
+
host: String(e.host ?? ""),
|
|
137
|
+
// Which browser it happened in. Kept because a reaction has to tell ONE
|
|
138
|
+
// application to close a tab, and because a log line that names the host
|
|
139
|
+
// but not the browser leaves a two-browser machine guessing. Dropping it
|
|
140
|
+
// here was the one place the tag was lost between finding and acting.
|
|
141
|
+
browser: typeof e.browser === "string" ? e.browser : null,
|
|
142
|
+
startMs: Number(e.startMs),
|
|
143
|
+
endMs: Number(e.endMs),
|
|
144
|
+
count: Number(e.count),
|
|
145
|
+
urls: Array.isArray(e.urls)
|
|
146
|
+
? e.urls.map(u => ({ url: String(u.url ?? ""), timeMs: Number(u.timeMs) }))
|
|
147
|
+
: [],
|
|
148
|
+
// When the deck wrote it down, which is the only claim the archive can make
|
|
149
|
+
// that Chrome's history cannot: the episode existed at this moment, whatever
|
|
150
|
+
// the browser says later.
|
|
151
|
+
archivedMs: Number(e.archivedMs ?? Date.now()),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Two episodes are the same one when they start at the same moment on the same
|
|
156
|
+
* host. Not the count or the end, both of which grow while a program is still
|
|
157
|
+
* working — keyed on those, one run would archive itself a dozen times. */
|
|
158
|
+
// Separated by an escaped NUL rather than a space: a host cannot contain one,
|
|
159
|
+
// so no two different episodes can collide on the joined string. Written as an
|
|
160
|
+
// ESCAPE and never as the raw byte — source-nul-bytes.test.ts exists because a
|
|
161
|
+
// raw NUL makes grep skip the whole file without ever saying so.
|
|
162
|
+
const keyOf = e => `${e.host}\u0000${e.startMs}`;
|
|
163
|
+
|
|
164
|
+
/** The same key, from the two fields a caller has. Exported because the route
|
|
165
|
+
* that dismisses an episode is handed a host and a start, not an episode. */
|
|
166
|
+
export const episodeKey = (host, startMs) => `${host}\u0000${startMs}`;
|
|
167
|
+
|
|
168
|
+
/** How many dismissals are remembered. A dismissal is a few dozen bytes and
|
|
169
|
+
* the archive it filters is capped at 500, so this is generous — but it is
|
|
170
|
+
* capped all the same, because a set that only grows is a file that only
|
|
171
|
+
* grows. Trimmed oldest-first, and the cost of forgetting the oldest is that
|
|
172
|
+
* an episode from two years ago could reappear if it were still live, which
|
|
173
|
+
* it cannot be. */
|
|
174
|
+
const DISMISS_KEEP = 2000;
|
|
175
|
+
|
|
176
|
+
export async function readStore(home = claudeConfigDir(), deps = {}) {
|
|
177
|
+
const read = deps.readFile ?? readFile;
|
|
178
|
+
let parsed = null;
|
|
179
|
+
try { parsed = JSON.parse(await read(storePath(home), "utf8")); } catch { /* absent or corrupt */ }
|
|
180
|
+
const settings = normalise(parsed?.settings);
|
|
181
|
+
|
|
182
|
+
// A VERSION BUMP DROPS THE EPISODES AND KEEPS THE SETTINGS, because the two
|
|
183
|
+
// are not the same kind of thing. Settings are what the user chose and stay
|
|
184
|
+
// chosen; episodes are FINDINGS, and a finding produced by a rule the deck no
|
|
185
|
+
// longer applies is not a finding it can stand behind.
|
|
186
|
+
//
|
|
187
|
+
// Version 1 archived whatever a thirty-day sweep of the browser's history
|
|
188
|
+
// turned up, so its rows are the user's own past browsing — read before the
|
|
189
|
+
// watch existed, under a rule that has since been removed. Keeping them would
|
|
190
|
+
// put "nothing from before this deck started" on screen directly above four
|
|
191
|
+
// episodes from a fortnight earlier, which is the panel calling itself a liar.
|
|
192
|
+
//
|
|
193
|
+
// Dropping rather than migrating: there is no way to re-derive which of those
|
|
194
|
+
// rows the current rule WOULD have found, because the evidence for that
|
|
195
|
+
// question is exactly the history the deck no longer reads.
|
|
196
|
+
//
|
|
197
|
+
// `migrated` tells the caller to write the file back. Hiding the rows is not
|
|
198
|
+
// enough: the promise is that nothing from before the watch is KEPT, and rows
|
|
199
|
+
// left on disk are kept whatever the panel chooses to draw. readStore does not
|
|
200
|
+
// write them away itself — a read with a side effect is a trap for the next
|
|
201
|
+
// caller — so it says so and the snapshot does it.
|
|
202
|
+
if (parsed && parsed.v !== STORE_VERSION) return { settings, episodes: [], dismissed: [], migrated: true };
|
|
203
|
+
|
|
204
|
+
const episodes = Array.isArray(parsed?.episodes)
|
|
205
|
+
? parsed.episodes.map(archivable).filter(e => Number.isFinite(e.startMs))
|
|
206
|
+
: [];
|
|
207
|
+
// WHAT THE READER HAS ALREADY LOOKED AT. It has to be its own list rather
|
|
208
|
+
// than a deletion from `episodes`, because the panel reads the browser's
|
|
209
|
+
// history live as well as its own archive — delete the row and the very next
|
|
210
|
+
// poll finds the same visits and puts it back, which is worse than having no
|
|
211
|
+
// delete at all.
|
|
212
|
+
const dismissed = Array.isArray(parsed?.dismissed)
|
|
213
|
+
? parsed.dismissed.filter(k => typeof k === "string" && k.includes("\u0000")).slice(-DISMISS_KEEP)
|
|
214
|
+
: [];
|
|
215
|
+
return { settings, episodes, dismissed, migrated: false };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Write the store, atomically.
|
|
220
|
+
*
|
|
221
|
+
* Through a temp file and a rename because the alternative is a truncated JSON
|
|
222
|
+
* document as the only record of what was seen while the browser was being
|
|
223
|
+
* driven — the one file whose loss this feature cannot absorb. installer.mjs
|
|
224
|
+
* makes the same argument about settings.json, for the same reason.
|
|
225
|
+
*/
|
|
226
|
+
/**
|
|
227
|
+
* Write the whole store, atomically.
|
|
228
|
+
*
|
|
229
|
+
* IT WRITES WHAT IT IS HANDED. There is no merge with what is on disk, on
|
|
230
|
+
* purpose — a writer that read first would have to decide what wins, and two
|
|
231
|
+
* decks racing on that is worse than one deck writing a whole state. The cost
|
|
232
|
+
* is that every caller must pass every field, and the cost was paid once: the
|
|
233
|
+
* settings route omitted `dismissed` and so erased every episode the reader had
|
|
234
|
+
* marked reviewed, from a change that had nothing to do with them. There is a
|
|
235
|
+
* test that greps this file's callers for the field.
|
|
236
|
+
*/
|
|
237
|
+
export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
|
|
238
|
+
const mk = deps.mkdir ?? mkdir;
|
|
239
|
+
const write = deps.writeFile ?? writeFile;
|
|
240
|
+
const mv = deps.rename ?? rename;
|
|
241
|
+
await mk(storeDir(home), { recursive: true });
|
|
242
|
+
const body = JSON.stringify({
|
|
243
|
+
v: STORE_VERSION,
|
|
244
|
+
settings: normalise(state.settings),
|
|
245
|
+
episodes: (state.episodes ?? []).map(archivable),
|
|
246
|
+
dismissed: [...new Set(state.dismissed ?? [])].slice(-DISMISS_KEEP),
|
|
247
|
+
}, null, 2) + "\n";
|
|
248
|
+
const tmp = `${storePath(home)}.${process.pid}.tmp`;
|
|
249
|
+
await write(tmp, body, "utf8");
|
|
250
|
+
await mv(tmp, storePath(home));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The archive with `seen` folded into it, newest first and capped.
|
|
255
|
+
*
|
|
256
|
+
* An episode already archived is REPLACED rather than skipped, because a run
|
|
257
|
+
* that is still going gains pages: the card the deck wrote at 17:05 said one
|
|
258
|
+
* page, and by 17:44 the truth is thirteen. Skipping would freeze the first
|
|
259
|
+
* reading; appending would show the same run twice.
|
|
260
|
+
*/
|
|
261
|
+
export function mergeEpisodes(archive, seen, now = Date.now()) {
|
|
262
|
+
const byKey = new Map();
|
|
263
|
+
for (const e of archive) byKey.set(keyOf(e), archivable(e));
|
|
264
|
+
for (const e of seen) {
|
|
265
|
+
const key = keyOf(e);
|
|
266
|
+
const had = byKey.get(key);
|
|
267
|
+
byKey.set(key, archivable({ ...e, archivedMs: had?.archivedMs ?? now }));
|
|
268
|
+
}
|
|
269
|
+
return [...byKey.values()].sort((a, b) => b.startMs - a.startMs).slice(0, KEEP);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Episodes the reader has not dismissed.
|
|
274
|
+
*
|
|
275
|
+
* Applied to the LIVE read as well as to the archive, which is the whole point:
|
|
276
|
+
* an episode is rebuilt from the browser's own history on every poll, so a
|
|
277
|
+
* dismissal that only removed the archived copy would be undone within ten
|
|
278
|
+
* seconds by the next read of the same visits.
|
|
279
|
+
*
|
|
280
|
+
* Keyed on host and START, never on the end or the count: a run that is still
|
|
281
|
+
* going gains pages, and a key that moved with them would let a dismissed
|
|
282
|
+
* episode return the moment its program opened one more tab.
|
|
283
|
+
*/
|
|
284
|
+
export function undismissed(episodes, dismissed) {
|
|
285
|
+
if (!Array.isArray(dismissed) || dismissed.length === 0) return episodes;
|
|
286
|
+
const gone = new Set(dismissed);
|
|
287
|
+
return episodes.filter(e => !gone.has(keyOf(e)));
|
|
288
|
+
}
|