agent-dag 3.22.1 → 3.22.4
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 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
// What the watch does when it finds something, beyond writing it down.
|
|
2
|
-
//
|
|
3
|
-
// THREE REACTIONS, AND ONLY TWO OF THEM EXIST EVERYWHERE. The panel offers a
|
|
4
|
-
// browser exactly the reactions its platform can actually perform, because a
|
|
5
|
-
// mode that silently does nothing is worse than one that was never offered —
|
|
6
|
-
// the user arms it, believes they are covered, and finds out on the day it
|
|
7
|
-
// mattered.
|
|
8
|
-
//
|
|
9
|
-
// notify every platform. A system notification.
|
|
10
|
-
// quit-browser every platform. Blunt, and the only one that takes the
|
|
11
|
-
// session away from whoever was driving it.
|
|
12
|
-
// close-tab macOS only. AppleScript is the one interface that can close
|
|
13
|
-
// ONE tab by URL. On Windows the nearest thing is walking the
|
|
14
|
-
// accessibility tree and matching on the page TITLE, which two
|
|
15
|
-
// tabs can share; under Wayland there is nothing at all.
|
|
16
|
-
//
|
|
17
|
-
// WHY CLOSING A TAB IS THE WEAKEST OF THE THREE, said plainly because the
|
|
18
|
-
// panel should not oversell it: by the time the deck sees the visit, Chrome has
|
|
19
|
-
// already loaded the page and sent the user's cookies. Closing it is cleanup.
|
|
20
|
-
// The session that opened it is still attached and can still read every other
|
|
21
|
-
// tab. Only quitting takes anything back.
|
|
22
|
-
import { run } from "./exec.mjs";
|
|
23
|
-
// The one table of process names, shared with the presence probe so the reaction
|
|
24
|
-
// and the "is it running" answer can never disagree about what to look for.
|
|
25
|
-
import { processName } from "./browser-presence.mjs";
|
|
26
|
-
|
|
27
|
-
/** Reactions this platform can actually carry out, in the order the panel
|
|
28
|
-
* should offer them. Never a list the caller has to filter again. */
|
|
29
|
-
export function available(platform = process.platform) {
|
|
30
|
-
return platform === "darwin"
|
|
31
|
-
? ["notify", "close-tab", "quit-browser"]
|
|
32
|
-
: ["notify", "quit-browser"];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Whether a stored setting is still performable here. A store written on a Mac
|
|
36
|
-
* and carried to a Linux machine — or a browser that is not the one the
|
|
37
|
-
* setting was chosen for — must not silently do nothing. */
|
|
38
|
-
export const performable = (reaction, platform = process.platform) =>
|
|
39
|
-
available(platform).includes(reaction);
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The AppleScript that closes one tab, given its URL through argv.
|
|
43
|
-
*
|
|
44
|
-
* THE URL GOES THROUGH argv AND NEVER INTO THE SOURCE. It is attacker-chosen
|
|
45
|
-
* text: the whole premise of this feature is that somebody else may have opened
|
|
46
|
-
* that page, so its address is the last string in the deck that should be
|
|
47
|
-
* pasted into a script. The shell tool this descends from verified that an
|
|
48
|
-
* interpolated URL could reach `do shell script`.
|
|
49
|
-
*
|
|
50
|
-
* The application name IS interpolated, because AppleScript will not load an
|
|
51
|
-
* app's terminology from a variable — `tell application appName` leaves `tabs`
|
|
52
|
-
* and `URL` unresolvable. It comes from the fixed table below and from nowhere
|
|
53
|
-
* else.
|
|
54
|
-
*
|
|
55
|
-
* The `is not running` guard is not politeness either: `tell application "X"`
|
|
56
|
-
* LAUNCHES X when it is not running, so without it a watch would resurrect a
|
|
57
|
-
* browser the user had quit in order to close a tab in it.
|
|
58
|
-
*/
|
|
59
|
-
export function closeTabScript(app) {
|
|
60
|
-
return `on run argv
|
|
61
|
-
set wanted to item 1 of argv
|
|
62
|
-
if application "${app}" is not running then return "not-running"
|
|
63
|
-
tell application "${app}"
|
|
64
|
-
repeat with w in windows
|
|
65
|
-
repeat with t in tabs of w
|
|
66
|
-
if (URL of t as string) is wanted then
|
|
67
|
-
close t
|
|
68
|
-
return "closed"
|
|
69
|
-
end if
|
|
70
|
-
end repeat
|
|
71
|
-
end repeat
|
|
72
|
-
end tell
|
|
73
|
-
return "missing"
|
|
74
|
-
end run`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** The application name for a browser key, or null when the deck does not know
|
|
78
|
-
* one — which is a reason to do nothing rather than to guess. */
|
|
79
|
-
const APP = {
|
|
80
|
-
chrome: "Google Chrome",
|
|
81
|
-
"chrome-beta": "Google Chrome Beta",
|
|
82
|
-
"chrome-canary": "Google Chrome Canary",
|
|
83
|
-
chromium: "Chromium",
|
|
84
|
-
brave: "Brave Browser",
|
|
85
|
-
edge: "Microsoft Edge",
|
|
86
|
-
vivaldi: "Vivaldi",
|
|
87
|
-
arc: "Arc",
|
|
88
|
-
};
|
|
89
|
-
export const appName = key => APP[key] ?? null;
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* A system notification.
|
|
93
|
-
*
|
|
94
|
-
* The text is passed as an argument on every platform rather than built into a
|
|
95
|
-
* script, for the reason above: a host name reaching this function came out of
|
|
96
|
-
* a browser's history and is not the deck's own string.
|
|
97
|
-
*/
|
|
98
|
-
export async function notify(title, body, platform = process.platform, deps = {}) {
|
|
99
|
-
const exec = deps.run ?? run;
|
|
100
|
-
if (platform === "darwin") {
|
|
101
|
-
// `-e` with argv, so neither string is interpolated into the source. The
|
|
102
|
-
// shell tool this descends from built its notification by interpolation and
|
|
103
|
-
// that is the one place it had left the pattern it had banned everywhere
|
|
104
|
-
// else.
|
|
105
|
-
//
|
|
106
|
-
// AND `--` BEFORE THE OPERANDS, WHICH IS THE HALF THAT WAS MISSING. Passing
|
|
107
|
-
// a string as argv is not the same as it being treated as data: osascript's
|
|
108
|
-
// option parser reads any LEADING-DASH operand as an option, so a title
|
|
109
|
-
// beginning with `-e` was taken as a second script chunk and COMPILED. The
|
|
110
|
-
// note above asserted the argv form made an attacker-chosen host name safe;
|
|
111
|
-
// it did not.
|
|
112
|
-
//
|
|
113
|
-
// The reach was the whole of it. `title` is `basename(cwd) — ccdeck` from
|
|
114
|
-
// block-notify.mjs, `cwd` arrives in the body of `POST /api/event`, and
|
|
115
|
-
// that route is in OPEN_MUTATIONS — no token, no browser identity, which is
|
|
116
|
-
// exactly the sandboxed-subprocess-with-loopback-egress case the gate
|
|
117
|
-
// comments name. Verified on Darwin 25.5 with no payload at all: without
|
|
118
|
-
// `--`, a leading `-e` operand answers "The run handler is specified more
|
|
119
|
-
// than once", which is the compiler reporting on the attacker's string;
|
|
120
|
-
// with `--`, the same string arrives as `item 1 of argv`.
|
|
121
|
-
const r = await exec("osascript", [
|
|
122
|
-
"-e",
|
|
123
|
-
'on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run',
|
|
124
|
-
"--", title, body,
|
|
125
|
-
]).catch(() => null);
|
|
126
|
-
return r?.ok === true;
|
|
127
|
-
}
|
|
128
|
-
if (platform === "win32") {
|
|
129
|
-
// THE STRINGS GO THROUGH THE ENVIRONMENT, and the previous spelling could
|
|
130
|
-
// not have worked at all. PowerShell documents that a string `-Command`
|
|
131
|
-
// must be the LAST parameter: everything after it is appended to the
|
|
132
|
-
// command text. So `… -t <title> -b <body>` was not two parameters, it was
|
|
133
|
-
// more script — pasted after `…Show($x)`, where it failed to parse — and
|
|
134
|
-
// `param($t,$b)` cannot receive arguments through `-Command` in any case.
|
|
135
|
-
// The toast therefore never appeared on Windows, for the reaction that is
|
|
136
|
-
// the default.
|
|
137
|
-
//
|
|
138
|
-
// That mistake also put attacker-chosen text into a script. `body` carries
|
|
139
|
-
// `episode.host`, which is `new URL(row.url).host` out of the browser's own
|
|
140
|
-
// history — the whole premise of this feature is that somebody else may
|
|
141
|
-
// have opened that page. `$env:` reads it as data at runtime, which is the
|
|
142
|
-
// same discipline the argv paths above keep.
|
|
143
|
-
// TEMPLATE 5, ToastText02: a bold heading and a body line, and the ONLY
|
|
144
|
-
// stock template with exactly the two text nodes this fills.
|
|
145
|
-
//
|
|
146
|
-
// It was 0 — ToastImageAndText01 — from the day this was written until it
|
|
147
|
-
// was run on Windows. That template has ONE text node and an image slot, so
|
|
148
|
-
// `$n.Item(1)` below threw "Specified argument was out of the range of
|
|
149
|
-
// valid values" every time, the catch turned that into `null`, and `notify`
|
|
150
|
-
// returned false. Not intermittently and not on some machines: this branch
|
|
151
|
-
// could never once have raised a toast.
|
|
152
|
-
//
|
|
153
|
-
// It took this long to find because everything around it is right. The type
|
|
154
|
-
// loads, the AppUserModelID is accepted, the reaction is offered on the
|
|
155
|
-
// platform — `available()` lists "notify" on win32 — and the failure is a
|
|
156
|
-
// rejected promise on a fire-and-forget path whose whole contract is to
|
|
157
|
-
// stay quiet. The only surface that ever said anything was a boolean
|
|
158
|
-
// nobody read.
|
|
159
|
-
//
|
|
160
|
-
// Verified on Windows 10 19045 in the logged-on user's own session
|
|
161
|
-
// (schtasks /IT, session 2): template 0 FAILED, template 5 SHOWN. Over SSH
|
|
162
|
-
// both fail with "The notification platform is unavailable" — every SSH
|
|
163
|
-
// process lands in session 0, which has no desktop — so a check that runs
|
|
164
|
-
// there proves nothing about this line either way, and that is a property
|
|
165
|
-
// of the transport rather than of the code.
|
|
166
|
-
const r = await exec("powershell.exe", [
|
|
167
|
-
"-NoProfile", "-NonInteractive", "-Command",
|
|
168
|
-
"[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime];"
|
|
169
|
-
+ "$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(5);"
|
|
170
|
-
+ "$n = $x.GetElementsByTagName('text');"
|
|
171
|
-
+ "$n.Item(0).AppendChild($x.CreateTextNode($env:CCDECK_TOAST_TITLE)) > $null;"
|
|
172
|
-
+ "$n.Item(1).AppendChild($x.CreateTextNode($env:CCDECK_TOAST_BODY)) > $null;"
|
|
173
|
-
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ccdeck').Show($x)",
|
|
174
|
-
], { env: { ...process.env, CCDECK_TOAST_TITLE: title, CCDECK_TOAST_BODY: body } }).catch(() => null);
|
|
175
|
-
return r?.ok === true;
|
|
176
|
-
}
|
|
177
|
-
// `--` for the same reason as the darwin branch above, and it is worth having
|
|
178
|
-
// even though the consequence here is smaller: notify-send parses a leading
|
|
179
|
-
// dash as an option too, so an attacker-chosen title could suppress or
|
|
180
|
-
// misdirect the notification. There is no script compiler behind it, so this
|
|
181
|
-
// is a broken notification rather than an execution — fixed anyway, because
|
|
182
|
-
// the rule is "never let a user string be read as an option" and a rule with
|
|
183
|
-
// an exception is a rule somebody will apply to the wrong call next time.
|
|
184
|
-
const r = await exec("notify-send", ["--", title, body]).catch(() => null);
|
|
185
|
-
return r?.ok === true;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Close one tab by its exact URL. macOS only; see `available`. */
|
|
189
|
-
export async function closeTab(browserKey, url, platform = process.platform, deps = {}) {
|
|
190
|
-
if (platform !== "darwin") return { ok: false, reason: "unsupported" };
|
|
191
|
-
const app = appName(browserKey);
|
|
192
|
-
if (!app) return { ok: false, reason: "unknown_browser" };
|
|
193
|
-
const exec = deps.run ?? run;
|
|
194
|
-
// Through `-e` rather than stdin: `run` closes the child's stdin immediately
|
|
195
|
-
// and says so in its own contract, so `osascript -` would read an empty
|
|
196
|
-
// script and report success having done nothing. The URL still travels as
|
|
197
|
-
// argv, which is the part that matters.
|
|
198
|
-
// `--` here too. The url comes from a real http(s) history row so it cannot
|
|
199
|
-
// begin with a dash today, but that is a property of the caller rather than
|
|
200
|
-
// of this line, and the next caller will not know it.
|
|
201
|
-
const r = await exec("osascript", ["-e", closeTabScript(app), "--", url]).catch(() => null);
|
|
202
|
-
if (!r?.ok) return { ok: false, reason: "script_failed" };
|
|
203
|
-
const said = String(r.stdout ?? "").trim();
|
|
204
|
-
return { ok: said === "closed", reason: said || "unknown" };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Quit a browser. The only reaction that takes the session back. */
|
|
208
|
-
export async function quitBrowser(browserKey, platform = process.platform, deps = {}) {
|
|
209
|
-
const exec = deps.run ?? run;
|
|
210
|
-
if (platform === "darwin") {
|
|
211
|
-
// The darwin display-name table is consulted HERE rather than at the top of
|
|
212
|
-
// the function (#794). It used to gate every platform, so a browser missing
|
|
213
|
-
// from a macOS-only table — `chromium-snap` and `brave-flatpak`, the two
|
|
214
|
-
// roots a default Ubuntu install actually has — answered `unknown_browser`
|
|
215
|
-
// on Linux before the branch that would have known what to do with it was
|
|
216
|
-
// ever reached.
|
|
217
|
-
const app = appName(browserKey);
|
|
218
|
-
if (!app) return { ok: false, reason: "unknown_browser" };
|
|
219
|
-
const r = await exec("osascript", [
|
|
220
|
-
// `--`, as everywhere else that hands osascript an operand. `app` comes
|
|
221
|
-
// from a fixed table so it is safe by construction; the separator costs
|
|
222
|
-
// nothing and means no reader has to go and check that.
|
|
223
|
-
"-e", 'on run argv\ntell application (item 1 of argv) to quit\nend run', "--", app,
|
|
224
|
-
]).catch(() => null);
|
|
225
|
-
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "script_failed" };
|
|
226
|
-
}
|
|
227
|
-
// NOT THE DISPLAY NAME WITH ITS SPACES REMOVED. `"Google Chrome"` became
|
|
228
|
-
// `GoogleChrome.exe` and `google-chrome`, and neither is a process on either
|
|
229
|
-
// platform — so this reaction was offered on Windows and Linux and could
|
|
230
|
-
// never once have worked. The names live in browser-presence, which is where
|
|
231
|
-
// the other probe reads them from, so the two cannot drift apart.
|
|
232
|
-
const proc = processName(browserKey, platform);
|
|
233
|
-
if (!proc) return { ok: false, reason: "unknown_browser" };
|
|
234
|
-
if (platform === "win32") {
|
|
235
|
-
const r = await exec("taskkill", ["/IM", `${proc}.exe`, "/F"]).catch(() => null);
|
|
236
|
-
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "taskkill_failed" };
|
|
237
|
-
}
|
|
238
|
-
const r = await exec("pkill", ["-x", proc]).catch(() => null);
|
|
239
|
-
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "pkill_failed" };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Carry out the armed reaction for one episode.
|
|
244
|
-
*
|
|
245
|
-
* Always notifies, whatever else it does. A tab that closed itself with no
|
|
246
|
-
* explanation is a mystery rather than a warning, and the point of the feature
|
|
247
|
-
* is that the user finds out.
|
|
248
|
-
*/
|
|
249
|
-
export async function react(reaction, episode, { platform = process.platform, deps = {} } = {}) {
|
|
250
|
-
const done = [];
|
|
251
|
-
const pages = `${episode.count} page${episode.count === 1 ? "" : "s"}`;
|
|
252
|
-
if (await notify("Browser watch", `${episode.host} — ${pages} while you were away`, platform, deps)) {
|
|
253
|
-
done.push("notified");
|
|
254
|
-
} else {
|
|
255
|
-
done.push("could not notify");
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (!performable(reaction, platform) || reaction === "notify") return done;
|
|
259
|
-
|
|
260
|
-
// A REACTION THAT COULD NOT ACT MUST SAY SO. This reported only its
|
|
261
|
-
// successes, so every failure was silent — and there were two whole months of
|
|
262
|
-
// them: `episode.browser` was null until it was fixed, `appName(null)` is
|
|
263
|
-
// null, and both destructive reactions returned `unknown_browser` and pushed
|
|
264
|
-
// nothing. The panel said a finding had been handled and nothing had been.
|
|
265
|
-
//
|
|
266
|
-
// The failures that remain are ordinary and will happen: macOS asks once for
|
|
267
|
-
// permission to control another application and refuses forever if declined;
|
|
268
|
-
// a tab can be closed by hand before the poll reaches it; a browser can quit
|
|
269
|
-
// on its own. Each of those is something the reader has to be able to see,
|
|
270
|
-
// because the alternative is believing a tab was closed that is still open.
|
|
271
|
-
if (reaction === "close-tab") {
|
|
272
|
-
// Every URL in the episode, because an episode is a run and closing only
|
|
273
|
-
// its first page leaves the rest of the run open.
|
|
274
|
-
for (const u of episode.urls ?? []) {
|
|
275
|
-
const out = await closeTab(episode.browser, u.url, platform, deps);
|
|
276
|
-
done.push(out.ok ? `closed ${u.url}` : `could not close ${u.url} — ${out.reason}`);
|
|
277
|
-
}
|
|
278
|
-
return done;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const out = await quitBrowser(episode.browser, platform, deps);
|
|
282
|
-
done.push(out.ok ? "quit the browser" : `could not quit the browser — ${out.reason}`);
|
|
283
|
-
return done;
|
|
284
|
-
}
|
|
@@ -1,350 +0,0 @@
|
|
|
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, writeFile } from "node:fs/promises";
|
|
21
|
-
// The rename, with the Windows retry ladder installer.mjs wrote for exactly
|
|
22
|
-
// this call. See the note over `writeNow` (#786).
|
|
23
|
-
import { renameWithRetry } from "./installer.mjs";
|
|
24
|
-
import { join } from "node:path";
|
|
25
|
-
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
26
|
-
|
|
27
|
-
/** Reactions the panel can arm. `close-tab` is macOS-only and the server is the
|
|
28
|
-
* one that says so — a client cannot be trusted to know what the OS can do,
|
|
29
|
-
* and a mode that silently does nothing is worse than one that is not offered. */
|
|
30
|
-
export const REACTIONS = ["notify", "close-tab", "quit-browser"];
|
|
31
|
-
|
|
32
|
-
export const DEFAULTS = {
|
|
33
|
-
v: 1,
|
|
34
|
-
enabled: false,
|
|
35
|
-
reaction: "notify",
|
|
36
|
-
quietMinutes: 15,
|
|
37
|
-
gapMinutes: 15,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The store's schema version.
|
|
42
|
-
*
|
|
43
|
-
* Bumped when the rule that PRODUCES episodes changes, not when their shape
|
|
44
|
-
* does — see readStore. Version 1 kept rows from a thirty-day sweep of the
|
|
45
|
-
* browser's history; version 2 keeps only what the deck saw while watching.
|
|
46
|
-
*/
|
|
47
|
-
const STORE_VERSION = 2;
|
|
48
|
-
|
|
49
|
-
/** How many archived episodes are kept. Roughly two years at the measured rate
|
|
50
|
-
* of one card every eight days, and small enough that the file stays a thing a
|
|
51
|
-
* person could open and read. Trimmed oldest-first. */
|
|
52
|
-
const KEEP = 500;
|
|
53
|
-
|
|
54
|
-
const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
|
|
55
|
-
export const storePath = (home = claudeConfigDir()) => join(storeDir(home), "state.json");
|
|
56
|
-
|
|
57
|
-
/** The plain-text log, which is the one file here a person opens themselves.
|
|
58
|
-
* state.json is the deck's own record and is JSON because the deck reads it
|
|
59
|
-
* back; this is the same events in the shape `tail -f` wants. */
|
|
60
|
-
export const logPath = (home = claudeConfigDir()) => join(storeDir(home), "watch.log");
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Append one episode, and EVERY ADDRESS IN IT, oldest first.
|
|
64
|
-
*
|
|
65
|
-
* THE URLs ARE THE POINT OF THE FILE. A summary line — host, count, duration —
|
|
66
|
-
* says something happened and leaves the reader unable to act on it: the
|
|
67
|
-
* question three days later is not "did a program touch gitlab" but "WHICH
|
|
68
|
-
* pages", because a jobs list and a settings page mean different things. So
|
|
69
|
-
* every address is written in full, unshortened and unescaped, exactly as
|
|
70
|
-
* Chrome recorded it.
|
|
71
|
-
*
|
|
72
|
-
* Query strings and fragments included. They are frequently the whole content
|
|
73
|
-
* of the visit — `?scope=all`, `#servicii` — and a log that dropped them would
|
|
74
|
-
* be tidier and useless for the one job it has.
|
|
75
|
-
*
|
|
76
|
-
* Indented under their episode so the shape survives `grep`: a summary line
|
|
77
|
-
* starts at column zero, a URL line does not, which is what lets
|
|
78
|
-
* `grep -v '^ '` give the summary alone and `grep '^ '` give the addresses.
|
|
79
|
-
*
|
|
80
|
-
* Append-only and never rewritten: a log a program edits is not a log. It is
|
|
81
|
-
* the only part of this feature that outlives the process by design — the panel
|
|
82
|
-
* shows what this deck has seen, this file is what somebody reads three days
|
|
83
|
-
* later without opening the panel at all.
|
|
84
|
-
*/
|
|
85
|
-
export async function appendLog(episodes, home = claudeConfigDir(), deps = {}) {
|
|
86
|
-
if (!episodes.length) return;
|
|
87
|
-
const mk = deps.mkdir ?? mkdir;
|
|
88
|
-
const add = deps.appendFile ?? appendFile;
|
|
89
|
-
// Local time, not UTC. The reader's question is "what was happening at four
|
|
90
|
-
// yesterday afternoon", and their afternoon is not UTC's — the ISO stamp this
|
|
91
|
-
// replaced was off by the offset for everyone outside London.
|
|
92
|
-
const stamp = ms => {
|
|
93
|
-
const d = new Date(ms);
|
|
94
|
-
const p = n => String(n).padStart(2, "0");
|
|
95
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
|
|
96
|
-
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
97
|
-
};
|
|
98
|
-
const block = e => {
|
|
99
|
-
const span = e.endMs - e.startMs >= 60_000
|
|
100
|
-
? ` over ${Math.round((e.endMs - e.startMs) / 60_000)}m`
|
|
101
|
-
: "";
|
|
102
|
-
const where = e.browser ? ` [${e.browser}]` : "";
|
|
103
|
-
const head = `${stamp(e.startMs)} ${e.host} ${e.count} page${e.count === 1 ? "" : "s"}${span}${where}`;
|
|
104
|
-
const rows = (e.urls ?? []).map(u => ` ${stamp(u.timeMs).slice(11)} ${u.url}`);
|
|
105
|
-
return [head, ...rows].join("\n");
|
|
106
|
-
};
|
|
107
|
-
await mk(storeDir(home), { recursive: true });
|
|
108
|
-
await add(logPath(home), episodes.map(block).join("\n") + "\n", "utf8");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Settings as they will be used, whatever the file said.
|
|
113
|
-
*
|
|
114
|
-
* Every field is checked rather than spread, because this file is on disk and
|
|
115
|
-
* on disk is where a hand edit, a half-written save and an older version all
|
|
116
|
-
* arrive from. A `quietMinutes` of `"15"` or of `0` would otherwise reach
|
|
117
|
-
* classify() and widen the gate to everything, which is the failure that turns
|
|
118
|
-
* the panel into noise — the same reason the route refuses to coerce its query
|
|
119
|
-
* string.
|
|
120
|
-
*/
|
|
121
|
-
export function normalise(raw) {
|
|
122
|
-
const it = raw && typeof raw === "object" ? raw : {};
|
|
123
|
-
const num = (v, fallback, lo, hi) =>
|
|
124
|
-
typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi ? v : fallback;
|
|
125
|
-
return {
|
|
126
|
-
v: 1,
|
|
127
|
-
enabled: it.enabled === true,
|
|
128
|
-
reaction: REACTIONS.includes(it.reaction) ? it.reaction : DEFAULTS.reaction,
|
|
129
|
-
quietMinutes: num(it.quietMinutes, DEFAULTS.quietMinutes, 1, 24 * 60),
|
|
130
|
-
gapMinutes: num(it.gapMinutes, DEFAULTS.gapMinutes, 1, 24 * 60),
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** An episode reduced to what an archive needs: enough to redraw the card and
|
|
135
|
-
* enough to recognise it again. `urls` is kept whole — it is the evidence, and
|
|
136
|
-
* an archive that dropped it would preserve the accusation without it. */
|
|
137
|
-
function archivable(e) {
|
|
138
|
-
return {
|
|
139
|
-
host: String(e.host ?? ""),
|
|
140
|
-
// Which browser it happened in. Kept because a reaction has to tell ONE
|
|
141
|
-
// application to close a tab, and because a log line that names the host
|
|
142
|
-
// but not the browser leaves a two-browser machine guessing. Dropping it
|
|
143
|
-
// here was the one place the tag was lost between finding and acting.
|
|
144
|
-
browser: typeof e.browser === "string" ? e.browser : null,
|
|
145
|
-
startMs: Number(e.startMs),
|
|
146
|
-
endMs: Number(e.endMs),
|
|
147
|
-
count: Number(e.count),
|
|
148
|
-
urls: Array.isArray(e.urls)
|
|
149
|
-
? e.urls.map(u => ({ url: String(u.url ?? ""), timeMs: Number(u.timeMs) }))
|
|
150
|
-
: [],
|
|
151
|
-
// When the deck wrote it down, which is the only claim the archive can make
|
|
152
|
-
// that Chrome's history cannot: the episode existed at this moment, whatever
|
|
153
|
-
// the browser says later.
|
|
154
|
-
archivedMs: Number(e.archivedMs ?? Date.now()),
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Two episodes are the same one when they start at the same moment on the same
|
|
159
|
-
* host. Not the count or the end, both of which grow while a program is still
|
|
160
|
-
* working — keyed on those, one run would archive itself a dozen times. */
|
|
161
|
-
// Separated by an escaped NUL rather than a space: a host cannot contain one,
|
|
162
|
-
// so no two different episodes can collide on the joined string. Written as an
|
|
163
|
-
// ESCAPE and never as the raw byte — source-nul-bytes.test.ts exists because a
|
|
164
|
-
// raw NUL makes grep skip the whole file without ever saying so.
|
|
165
|
-
const keyOf = e => `${e.host}\u0000${e.startMs}`;
|
|
166
|
-
|
|
167
|
-
/** The same key, from the two fields a caller has. Exported because the route
|
|
168
|
-
* that dismisses an episode is handed a host and a start, not an episode. */
|
|
169
|
-
export const episodeKey = (host, startMs) => `${host}\u0000${startMs}`;
|
|
170
|
-
|
|
171
|
-
/** How many dismissals are remembered. A dismissal is a few dozen bytes and
|
|
172
|
-
* the archive it filters is capped at 500, so this is generous — but it is
|
|
173
|
-
* capped all the same, because a set that only grows is a file that only
|
|
174
|
-
* grows. Trimmed oldest-first, and the cost of forgetting the oldest is that
|
|
175
|
-
* an episode from two years ago could reappear if it were still live, which
|
|
176
|
-
* it cannot be. */
|
|
177
|
-
const DISMISS_KEEP = 2000;
|
|
178
|
-
|
|
179
|
-
export async function readStore(home = claudeConfigDir(), deps = {}) {
|
|
180
|
-
const read = deps.readFile ?? readFile;
|
|
181
|
-
let parsed = null;
|
|
182
|
-
try { parsed = JSON.parse(await read(storePath(home), "utf8")); } catch { /* absent or corrupt */ }
|
|
183
|
-
const settings = normalise(parsed?.settings);
|
|
184
|
-
|
|
185
|
-
// A VERSION BUMP DROPS THE EPISODES AND KEEPS THE SETTINGS, because the two
|
|
186
|
-
// are not the same kind of thing. Settings are what the user chose and stay
|
|
187
|
-
// chosen; episodes are FINDINGS, and a finding produced by a rule the deck no
|
|
188
|
-
// longer applies is not a finding it can stand behind.
|
|
189
|
-
//
|
|
190
|
-
// Version 1 archived whatever a thirty-day sweep of the browser's history
|
|
191
|
-
// turned up, so its rows are the user's own past browsing — read before the
|
|
192
|
-
// watch existed, under a rule that has since been removed. Keeping them would
|
|
193
|
-
// put "nothing from before this deck started" on screen directly above four
|
|
194
|
-
// episodes from a fortnight earlier, which is the panel calling itself a liar.
|
|
195
|
-
//
|
|
196
|
-
// Dropping rather than migrating: there is no way to re-derive which of those
|
|
197
|
-
// rows the current rule WOULD have found, because the evidence for that
|
|
198
|
-
// question is exactly the history the deck no longer reads.
|
|
199
|
-
//
|
|
200
|
-
// `migrated` tells the caller to write the file back. Hiding the rows is not
|
|
201
|
-
// enough: the promise is that nothing from before the watch is KEPT, and rows
|
|
202
|
-
// left on disk are kept whatever the panel chooses to draw. readStore does not
|
|
203
|
-
// write them away itself — a read with a side effect is a trap for the next
|
|
204
|
-
// caller — so it says so and the snapshot does it.
|
|
205
|
-
if (parsed && parsed.v !== STORE_VERSION) return { settings, episodes: [], dismissed: [], migrated: true };
|
|
206
|
-
|
|
207
|
-
const episodes = Array.isArray(parsed?.episodes)
|
|
208
|
-
? parsed.episodes.map(archivable).filter(e => Number.isFinite(e.startMs))
|
|
209
|
-
: [];
|
|
210
|
-
// WHAT THE READER HAS ALREADY LOOKED AT. It has to be its own list rather
|
|
211
|
-
// than a deletion from `episodes`, because the panel reads the browser's
|
|
212
|
-
// history live as well as its own archive — delete the row and the very next
|
|
213
|
-
// poll finds the same visits and puts it back, which is worse than having no
|
|
214
|
-
// delete at all.
|
|
215
|
-
const dismissed = Array.isArray(parsed?.dismissed)
|
|
216
|
-
? parsed.dismissed.filter(k => typeof k === "string" && k.includes("\u0000")).slice(-DISMISS_KEEP)
|
|
217
|
-
: [];
|
|
218
|
-
return { settings, episodes, dismissed, migrated: false };
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Write the store, atomically.
|
|
223
|
-
*
|
|
224
|
-
* Through a temp file and a rename because the alternative is a truncated JSON
|
|
225
|
-
* document as the only record of what was seen while the browser was being
|
|
226
|
-
* driven — the one file whose loss this feature cannot absorb. installer.mjs
|
|
227
|
-
* makes the same argument about settings.json, for the same reason.
|
|
228
|
-
*/
|
|
229
|
-
/**
|
|
230
|
-
* One writer at a time, in this process.
|
|
231
|
-
*
|
|
232
|
-
* Three call sites write this file — the poll's snapshot, the settings route
|
|
233
|
-
* and the dismiss route — and none of them knew about the others. The queue is
|
|
234
|
-
* the same shape `log-writer.mjs` uses for its appends: a promise chain that
|
|
235
|
-
* survives a rejection, so one failed write cannot wedge every later one.
|
|
236
|
-
*/
|
|
237
|
-
let _chain = Promise.resolve();
|
|
238
|
-
let _writeSeq = 0;
|
|
239
|
-
function serialized(job) {
|
|
240
|
-
const started = _chain.then(job, job);
|
|
241
|
-
_chain = started.then(() => {}, () => {});
|
|
242
|
-
return started;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Write the whole store, atomically.
|
|
247
|
-
*
|
|
248
|
-
* IT WRITES WHAT IT IS HANDED. There is no merge with what is on disk, on
|
|
249
|
-
* purpose — a writer that read first would have to decide what wins, and two
|
|
250
|
-
* decks racing on that is worse than one deck writing a whole state. The cost
|
|
251
|
-
* is that every caller must pass every field, and the cost was paid once: the
|
|
252
|
-
* settings route omitted `dismissed` and so erased every episode the reader had
|
|
253
|
-
* marked reviewed, from a change that had nothing to do with them. There is a
|
|
254
|
-
* test that greps this file's callers for the field.
|
|
255
|
-
*/
|
|
256
|
-
export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
|
|
257
|
-
return serialized(() => writeNow(state, home, deps));
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** The write itself, already inside the queue. */
|
|
261
|
-
async function writeNow(state, home, deps) {
|
|
262
|
-
const mk = deps.mkdir ?? mkdir;
|
|
263
|
-
const write = deps.writeFile ?? writeFile;
|
|
264
|
-
// `renameWithRetry`, not `rename` (#786). Same Windows rule as deck-prefs,
|
|
265
|
-
// and the stakes are higher here: none of the three writers catches the
|
|
266
|
-
// throw, so a refused rename 500s `GET /api/browser-watch` and the panel goes
|
|
267
|
-
// blank, while the episode archive — the file that exists BECAUSE an intruder
|
|
268
|
-
// can clear the browser's own history — is not written at all.
|
|
269
|
-
const mv = deps.rename ?? renameWithRetry;
|
|
270
|
-
await mk(storeDir(home), { recursive: true });
|
|
271
|
-
const body = JSON.stringify({
|
|
272
|
-
v: STORE_VERSION,
|
|
273
|
-
settings: normalise(state.settings),
|
|
274
|
-
episodes: (state.episodes ?? []).map(archivable),
|
|
275
|
-
dismissed: [...new Set(state.dismissed ?? [])].slice(-DISMISS_KEEP),
|
|
276
|
-
}, null, 2) + "\n";
|
|
277
|
-
// A NAME NO SECOND WRITE CAN BE USING. The pid distinguishes decks and not
|
|
278
|
-
// the calls inside one, and there are three writers in this process — the
|
|
279
|
-
// poll's snapshot, the settings route and the dismiss route — with nothing
|
|
280
|
-
// between them. Measured with a full 500-episode archive (~2.5 MB, past the
|
|
281
|
-
// 512 KiB writeFile chunk): eight concurrent runs left state.json unparseable
|
|
282
|
-
// in six of them and failed one call with ENOENT, renaming a temp file the
|
|
283
|
-
// other writer had already renamed away. readStore swallows a corrupt file,
|
|
284
|
-
// so the next poll reported an empty archive and no dismissals at all — total
|
|
285
|
-
// loss of the one file this feature exists to keep.
|
|
286
|
-
const tmp = `${storePath(home)}.${process.pid}.${++_writeSeq}.tmp`;
|
|
287
|
-
await write(tmp, body, "utf8");
|
|
288
|
-
await mv(tmp, storePath(home));
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Read, change, write — with nothing else writing in between.
|
|
293
|
-
*
|
|
294
|
-
* `writeStore` writes what it is handed and merges nothing, which is right for
|
|
295
|
-
* a whole-state write and wrong for a caller that owns one field. The snapshot
|
|
296
|
-
* takes about 400ms — a 21 MB History copy plus the sqlite read — and used to
|
|
297
|
-
* write back the `dismissed` and `settings` it had read at the start, so a
|
|
298
|
-
* dismissal made while it ran was reverted by the next poll ten seconds later.
|
|
299
|
-
* The settings route and the dismiss route had the same shape against each
|
|
300
|
-
* other.
|
|
301
|
-
*
|
|
302
|
-
* So a caller that owns one field passes a function instead: it runs inside the
|
|
303
|
-
* same queue the write does, against the state on disk at that moment, and no
|
|
304
|
-
* other writer can slip between the read and the write.
|
|
305
|
-
*/
|
|
306
|
-
export async function updateStore(mutate, home = claudeConfigDir(), deps = {}) {
|
|
307
|
-
return serialized(async () => {
|
|
308
|
-
const current = await readStore(home, deps);
|
|
309
|
-
const next = (await mutate(current)) ?? current;
|
|
310
|
-
await writeNow(next, home, deps);
|
|
311
|
-
return next;
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* The archive with `seen` folded into it, newest first and capped.
|
|
317
|
-
*
|
|
318
|
-
* An episode already archived is REPLACED rather than skipped, because a run
|
|
319
|
-
* that is still going gains pages: the card the deck wrote at 17:05 said one
|
|
320
|
-
* page, and by 17:44 the truth is thirteen. Skipping would freeze the first
|
|
321
|
-
* reading; appending would show the same run twice.
|
|
322
|
-
*/
|
|
323
|
-
export function mergeEpisodes(archive, seen, now = Date.now()) {
|
|
324
|
-
const byKey = new Map();
|
|
325
|
-
for (const e of archive) byKey.set(keyOf(e), archivable(e));
|
|
326
|
-
for (const e of seen) {
|
|
327
|
-
const key = keyOf(e);
|
|
328
|
-
const had = byKey.get(key);
|
|
329
|
-
byKey.set(key, archivable({ ...e, archivedMs: had?.archivedMs ?? now }));
|
|
330
|
-
}
|
|
331
|
-
return [...byKey.values()].sort((a, b) => b.startMs - a.startMs).slice(0, KEEP);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Episodes the reader has not dismissed.
|
|
336
|
-
*
|
|
337
|
-
* Applied to the LIVE read as well as to the archive, which is the whole point:
|
|
338
|
-
* an episode is rebuilt from the browser's own history on every poll, so a
|
|
339
|
-
* dismissal that only removed the archived copy would be undone within ten
|
|
340
|
-
* seconds by the next read of the same visits.
|
|
341
|
-
*
|
|
342
|
-
* Keyed on host and START, never on the end or the count: a run that is still
|
|
343
|
-
* going gains pages, and a key that moved with them would let a dismissed
|
|
344
|
-
* episode return the moment its program opened one more tab.
|
|
345
|
-
*/
|
|
346
|
-
export function undismissed(episodes, dismissed) {
|
|
347
|
-
if (!Array.isArray(dismissed) || dismissed.length === 0) return episodes;
|
|
348
|
-
const gone = new Set(dismissed);
|
|
349
|
-
return episodes.filter(e => !gone.has(keyOf(e)));
|
|
350
|
-
}
|