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.
@@ -0,0 +1,267 @@
1
+ // Which Chromium-family browsers are on this machine, which of their profiles
2
+ // exist, and which of those carry the Claude in Chrome extension.
3
+ //
4
+ // This is the floor of Browser Watch: every other part of the feature opens a
5
+ // profile's History or its Secure Preferences, and neither is findable until
6
+ // something has answered "where are the profiles". The whole module is a claim
7
+ // about somebody else's directory layout, which is why nothing here reaches the
8
+ // disk except through an injected `deps` — the Windows layout and the two Linux
9
+ // sandbox layouts have to be exercisable from a Mac, exactly as
10
+ // claudeCliCandidates() and cswapCandidates() are, or the list rots on every
11
+ // platform its author does not sit at.
12
+ //
13
+ // THE ASYMMETRY THAT SILENTLY BREAKS IT. Chromium's user-data directory is the
14
+ // parent of `Default` and `Profile N`, and macOS spells it differently from
15
+ // everywhere else:
16
+ //
17
+ // macOS ~/Library/Application Support/Google/Chrome/Default
18
+ // Windows %LOCALAPPDATA%\Google\Chrome\User Data\Default
19
+ // Linux ~/.config/google-chrome/Default
20
+ //
21
+ // There is NO `User Data` level on macOS — the app-support directory is itself
22
+ // the user-data directory — and the mistake is invisible in both directions.
23
+ // Insert the level on macOS and every root misses by one; drop it on Windows
24
+ // and every root misses by one. Both produce an empty list, and an empty list
25
+ // is also the honest answer for a machine that has no Chrome. So a bug here
26
+ // never looks like a bug. It looks like a user who does not use Chrome, on a
27
+ // panel whose entire job is to say which browsers are watched.
28
+ //
29
+ // Arc is the exception that proves the rule: it keeps `User Data` under its own
30
+ // app-support directory on macOS, because Arc is Chromium shipped by people who
31
+ // did not inherit Chrome's macOS habit. It is the reason the macOS list is a
32
+ // table of paths rather than a directory name plus a formula.
33
+ import { existsSync as fsExistsSync, readdirSync as fsReaddirSync, statSync as fsStatSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { posix as posixPath, win32 as winPath } from "node:path";
36
+
37
+ /**
38
+ * Claude in Chrome's extension id, which is the same 32 characters in every
39
+ * Chromium-family browser that can install it.
40
+ *
41
+ * A Chrome extension id is derived from the packing key, not from the store or
42
+ * the browser, so Brave, Edge and Vivaldi all hold it under this exact name.
43
+ * That is what makes a directory test sufficient here and what lets one
44
+ * constant serve every row this module returns.
45
+ */
46
+ export const CLAUDE_EXT_ID = "fcoeoabgfenejglbffodgkkbkcdhcgfn";
47
+
48
+ /**
49
+ * The directories inside a user-data root that are browsing profiles.
50
+ *
51
+ * `Default` and `Profile N` only. Chromium also keeps `Guest Profile` and
52
+ * `System Profile` beside them on every install — the second exists before the
53
+ * user has ever opened a window — and both carry a History file that never gets
54
+ * a row. Reporting them would put two permanently empty entries on the panel of
55
+ * every machine in the world and make "no browsing here yet" indistinguishable
56
+ * from "this is not a profile at all".
57
+ */
58
+ const PROFILE_DIR = /^(?:Default|Profile \d+)$/;
59
+
60
+ /**
61
+ * The path rules a root is written in, read off the root itself.
62
+ *
63
+ * `profileDirs` and `hasExtension` take a path and no platform — they are handed
64
+ * a root `browserRoots` already spelled — so the flavour has to come out of the
65
+ * string. It matters more than it looks. POSIX `join` would answer
66
+ * `C:\…\User Data/Default`, a mixed-separator path that Windows itself would
67
+ * happily open, so nothing on a real machine would ever complain; but
68
+ * `posixPath.basename` of that string is the WHOLE string, so `profile` would
69
+ * come back as an absolute path instead of `Default` and every caller keying on
70
+ * the profile name would miss. The failure only ever appears on the leg that
71
+ * checks Windows from a Mac, which is every leg that exists for this module.
72
+ *
73
+ * Anchored at the front rather than sniffing for a backslash anywhere: a POSIX
74
+ * directory may legally be named `Brave\Browser`, and every root built here is
75
+ * absolute, so a drive letter or a UNC prefix is the entire test.
76
+ */
77
+ const WINDOWS_ROOT = /^(?:[A-Za-z]:[\\/]|\\\\)/;
78
+ const flavourOf = p => (WINDOWS_ROOT.test(String(p)) ? winPath : posixPath);
79
+
80
+ /**
81
+ * Whether `p` is a directory, answering false for everything else a real disk
82
+ * can put there.
83
+ *
84
+ * Never throws, and that is the point rather than tidiness. This runs once per
85
+ * candidate profile across every browser on the machine: one profile on a
86
+ * disconnected network home, one `Default` that a sync tool left as a file, one
87
+ * broken symlink into an unmounted volume would otherwise abort the whole walk
88
+ * and take every other browser's profiles down with it.
89
+ */
90
+ function isDirectory(p, statSync) {
91
+ try { return statSync(p).isDirectory(); } catch { return false; }
92
+ }
93
+
94
+ /**
95
+ * Every known Chromium-family user-data root for this platform, whether or not
96
+ * it exists.
97
+ *
98
+ * Pure, and platform, environment and home are parameters, so the Windows and
99
+ * Linux answers are checkable from a Mac — the same reason claudeCliCandidates()
100
+ * takes them. Deliberately does NOT stat anything: "which browsers could be
101
+ * here" and "which are" are separate questions, and keeping the first one pure
102
+ * is what lets a test assert the whole table without a disk of any kind.
103
+ *
104
+ * `key` is the stable identifier the rest of Browser Watch keys rows on; `name`
105
+ * is the string a human reads. They are separate because Brave's directory is
106
+ * `Brave-Browser` on one platform and `com.brave.Browser` on another, and no
107
+ * panel should ever show either.
108
+ */
109
+ export function browserRoots(platform = process.platform, env = process.env, home = homedir()) {
110
+ // The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
111
+ // emits forward slashes when the Windows table is built on a Mac, which is
112
+ // both wrong for the caller and invisible in a test that only compares
113
+ // against a string it built the same wrong way.
114
+ const path = platform === "win32" ? winPath : posixPath;
115
+
116
+ if (platform === "darwin") {
117
+ // No `User Data` anywhere in here except Arc. See the module header.
118
+ const support = path.join(home, "Library", "Application Support");
119
+ return [
120
+ { key: "chrome", name: "Google Chrome", root: path.join(support, "Google", "Chrome") },
121
+ { key: "chrome-beta", name: "Google Chrome Beta", root: path.join(support, "Google", "Chrome Beta") },
122
+ { key: "chrome-canary", name: "Google Chrome Canary", root: path.join(support, "Google", "Chrome Canary") },
123
+ { key: "chromium", name: "Chromium", root: path.join(support, "Chromium") },
124
+ { key: "brave", name: "Brave", root: path.join(support, "BraveSoftware", "Brave-Browser") },
125
+ { key: "edge", name: "Microsoft Edge", root: path.join(support, "Microsoft Edge") },
126
+ { key: "vivaldi", name: "Vivaldi", root: path.join(support, "Vivaldi") },
127
+ { key: "arc", name: "Arc", root: path.join(support, "Arc", "User Data") },
128
+ ];
129
+ }
130
+
131
+ if (platform === "win32") {
132
+ // %LOCALAPPDATA% rather than a path under the home directory, because a
133
+ // roaming profile puts AppData\Roaming on a network share while Local stays
134
+ // on the machine — the same reason claudeCliCandidates() reads %APPDATA%.
135
+ // An empty value is what a broken login script leaves behind, not a
136
+ // relative path anybody means, so it falls back rather than joining onto "".
137
+ const local = env.LOCALAPPDATA?.trim() || path.join(home, "AppData", "Local");
138
+ return [
139
+ { key: "chrome", name: "Google Chrome", root: path.join(local, "Google", "Chrome", "User Data") },
140
+ { key: "chrome-beta", name: "Google Chrome Beta", root: path.join(local, "Google", "Chrome Beta", "User Data") },
141
+ // Canary is `Chrome SxS` here and `Chrome Canary` on macOS — the same
142
+ // channel under two names, because the Windows installer has always used
143
+ // the side-by-side codename. Spelling it the macOS way finds nothing, and
144
+ // finding nothing is this module's indistinguishable failure. Linux has
145
+ // no Canary channel at all, which is why the third table has no such row.
146
+ { key: "chrome-canary", name: "Google Chrome Canary", root: path.join(local, "Google", "Chrome SxS", "User Data") },
147
+ { key: "chromium", name: "Chromium", root: path.join(local, "Chromium", "User Data") },
148
+ { key: "brave", name: "Brave", root: path.join(local, "BraveSoftware", "Brave-Browser", "User Data") },
149
+ { key: "edge", name: "Microsoft Edge", root: path.join(local, "Microsoft", "Edge", "User Data") },
150
+ { key: "vivaldi", name: "Vivaldi", root: path.join(local, "Vivaldi", "User Data") },
151
+ ];
152
+ }
153
+
154
+ // Linux, and every other POSIX that is not macOS. Falling through rather than
155
+ // testing for "linux" is deliberate: a deck on FreeBSD gets the XDG layout,
156
+ // which is the one its Chromium package actually uses, instead of nothing.
157
+ //
158
+ // XDG_CONFIG_HOME is honoured because Chromium honours it — it is where the
159
+ // browser itself puts the directory, not a preference of ours — and trimmed
160
+ // and truthiness-tested for the reason codexHome() spells out: an empty
161
+ // variable is a shell accident, and joining onto it yields a CWD-relative
162
+ // path that would have the deck reading profiles out of wherever it was
163
+ // started from.
164
+ const config = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config");
165
+ return [
166
+ { key: "chrome", name: "Google Chrome", root: path.join(config, "google-chrome") },
167
+ { key: "chrome-beta", name: "Google Chrome Beta", root: path.join(config, "google-chrome-beta") },
168
+ { key: "chromium", name: "Chromium", root: path.join(config, "chromium") },
169
+ { key: "brave", name: "Brave", root: path.join(config, "BraveSoftware", "Brave-Browser") },
170
+ { key: "edge", name: "Microsoft Edge", root: path.join(config, "microsoft-edge") },
171
+ { key: "vivaldi", name: "Vivaldi", root: path.join(config, "vivaldi") },
172
+ // Snap and Flatpak confine the browser to a private filesystem, so their
173
+ // profiles are NOT under XDG_CONFIG_HOME and never appear in the six above.
174
+ // On Ubuntu this is not an edge case: `chromium` from the archive has been
175
+ // a snap-only transitional package for years, so the snap path is the only
176
+ // Chromium root a default Ubuntu install has.
177
+ { key: "chromium-snap", name: "Chromium (snap)", root: path.join(home, "snap", "chromium", "common", "chromium") },
178
+ { key: "brave-flatpak", name: "Brave (Flatpak)", root: path.join(home, ".var", "app", "com.brave.Browser", "config", "BraveSoftware", "Brave-Browser") },
179
+ ];
180
+ }
181
+
182
+ /** Where a profile name sits in the browser's own numbering; `Default` is first. */
183
+ const profileIndex = name => (name === "Default" ? -1 : Number(name.slice("Profile ".length)));
184
+
185
+ /**
186
+ * The profile directories that actually exist inside `root`, `Default` first
187
+ * and then `Profile N` in numeric order.
188
+ *
189
+ * A root that is absent, unreadable, or a file is not an error — it is the
190
+ * ordinary state of five of the eight roots on any real machine — so it answers
191
+ * with an empty list and the caller moves on.
192
+ *
193
+ * THE SORT IS NUMERIC ON PURPOSE. readdir returns whatever order the filesystem
194
+ * feels like, and the obvious repair — a plain `.sort()` — puts `Profile 10`
195
+ * between `Profile 1` and `Profile 2`. Nothing breaks visibly; the panel just
196
+ * lists a user's profiles in an order that changes meaning as soon as they
197
+ * create their tenth, which is the sort of wrongness nobody reports and
198
+ * everybody notices.
199
+ */
200
+ export function profileDirs(root, { readdirSync = fsReaddirSync, statSync = fsStatSync } = {}) {
201
+ const path = flavourOf(root);
202
+ let names;
203
+ try { names = readdirSync(root); } catch { return []; }
204
+ return names
205
+ .map(String)
206
+ .filter(name => PROFILE_DIR.test(name))
207
+ // `Default` is profile zero in everything but its name, and it sorts ahead
208
+ // of `Profile 1` here rather than by luck of the alphabet.
209
+ .sort((a, b) => profileIndex(a) - profileIndex(b))
210
+ .map(name => path.join(root, name))
211
+ .filter(dir => isDirectory(dir, statSync));
212
+ }
213
+
214
+ /**
215
+ * Whether this profile has the extension's payload on disk.
216
+ *
217
+ * Chromium unpacks an extension to `Extensions/<id>/<version>/`, one directory
218
+ * per profile, so the id directory is the cheap and complete answer to "is it
219
+ * installed here" — one stat per profile, at boot, across every browser found.
220
+ *
221
+ * It deliberately does NOT answer "is it enabled". That state lives in Secure
222
+ * Preferences, which is signed against the profile and is another part of this
223
+ * feature's job; conflating the two here would mean parsing a several-megabyte
224
+ * JSON file per profile to answer a question this function is not being asked.
225
+ */
226
+ export function hasExtension(profileDir, extId = CLAUDE_EXT_ID, { existsSync = fsExistsSync } = {}) {
227
+ const path = flavourOf(profileDir);
228
+ return existsSync(path.join(profileDir, "Extensions", extId));
229
+ }
230
+
231
+ /**
232
+ * Every browsing profile on this machine, with the two files Browser Watch
233
+ * reads and whether Claude in Chrome is installed in it.
234
+ *
235
+ * A PROFILE WITHOUT THE EXTENSION IS REPORTED, NOT DROPPED. "You have Brave
236
+ * here and the extension is not in it" is the one sentence on this panel that
237
+ * gets somebody unstuck, and it can only be said by a row that exists. Filtering
238
+ * to extension-carrying profiles would make an un-extended browser look exactly
239
+ * like an absent one — the same indistinguishable-from-nothing failure the
240
+ * `User Data` trap produces, arrived at on purpose.
241
+ *
242
+ * A ROOT WITH NO PROFILES CONTRIBUTES NOTHING, which is not the same thing. On
243
+ * the machine this was written against, Edge, Vivaldi, Chromium and Arc all have
244
+ * a root and no profile inside it — installed, or merely left behind by an
245
+ * uninstall — and a row for one of them would point the History reader at a
246
+ * path that will never exist.
247
+ */
248
+ export function discoverProfiles(platform = process.platform, env = process.env, home = homedir(), deps = {}) {
249
+ const profiles = [];
250
+ for (const { key, name, root } of browserRoots(platform, env, home)) {
251
+ // Read off the root rather than off `platform`, so the two can never
252
+ // disagree about which separator this row's paths are spelled with.
253
+ const path = flavourOf(root);
254
+ for (const dir of profileDirs(root, deps)) {
255
+ profiles.push({
256
+ browser: key,
257
+ name,
258
+ profile: path.basename(dir),
259
+ dir,
260
+ historyPath: path.join(dir, "History"),
261
+ securePrefsPath: path.join(dir, "Secure Preferences"),
262
+ hasClaudeExt: hasExtension(dir, CLAUDE_EXT_ID, deps),
263
+ });
264
+ }
265
+ }
266
+ return profiles;
267
+ }
@@ -0,0 +1,204 @@
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
+
24
+ /** Reactions this platform can actually carry out, in the order the panel
25
+ * should offer them. Never a list the caller has to filter again. */
26
+ export function available(platform = process.platform) {
27
+ return platform === "darwin"
28
+ ? ["notify", "close-tab", "quit-browser"]
29
+ : ["notify", "quit-browser"];
30
+ }
31
+
32
+ /** Whether a stored setting is still performable here. A store written on a Mac
33
+ * and carried to a Linux machine — or a browser that is not the one the
34
+ * setting was chosen for — must not silently do nothing. */
35
+ export const performable = (reaction, platform = process.platform) =>
36
+ available(platform).includes(reaction);
37
+
38
+ /**
39
+ * The AppleScript that closes one tab, given its URL through argv.
40
+ *
41
+ * THE URL GOES THROUGH argv AND NEVER INTO THE SOURCE. It is attacker-chosen
42
+ * text: the whole premise of this feature is that somebody else may have opened
43
+ * that page, so its address is the last string in the deck that should be
44
+ * pasted into a script. The shell tool this descends from verified that an
45
+ * interpolated URL could reach `do shell script`.
46
+ *
47
+ * The application name IS interpolated, because AppleScript will not load an
48
+ * app's terminology from a variable — `tell application appName` leaves `tabs`
49
+ * and `URL` unresolvable. It comes from the fixed table below and from nowhere
50
+ * else.
51
+ *
52
+ * The `is not running` guard is not politeness either: `tell application "X"`
53
+ * LAUNCHES X when it is not running, so without it a watch would resurrect a
54
+ * browser the user had quit in order to close a tab in it.
55
+ */
56
+ export function closeTabScript(app) {
57
+ return `on run argv
58
+ set wanted to item 1 of argv
59
+ if application "${app}" is not running then return "not-running"
60
+ tell application "${app}"
61
+ repeat with w in windows
62
+ repeat with t in tabs of w
63
+ if (URL of t as string) is wanted then
64
+ close t
65
+ return "closed"
66
+ end if
67
+ end repeat
68
+ end repeat
69
+ end tell
70
+ return "missing"
71
+ end run`;
72
+ }
73
+
74
+ /** The application name for a browser key, or null when the deck does not know
75
+ * one — which is a reason to do nothing rather than to guess. */
76
+ const APP = {
77
+ chrome: "Google Chrome",
78
+ "chrome-beta": "Google Chrome Beta",
79
+ "chrome-canary": "Google Chrome Canary",
80
+ chromium: "Chromium",
81
+ brave: "Brave Browser",
82
+ edge: "Microsoft Edge",
83
+ vivaldi: "Vivaldi",
84
+ arc: "Arc",
85
+ };
86
+ export const appName = key => APP[key] ?? null;
87
+
88
+ /**
89
+ * A system notification.
90
+ *
91
+ * The text is passed as an argument on every platform rather than built into a
92
+ * script, for the reason above: a host name reaching this function came out of
93
+ * a browser's history and is not the deck's own string.
94
+ */
95
+ export async function notify(title, body, platform = process.platform, deps = {}) {
96
+ const exec = deps.run ?? run;
97
+ if (platform === "darwin") {
98
+ // `-e` with argv, so neither string is interpolated into the source. The
99
+ // shell tool this descends from built its notification by interpolation and
100
+ // that is the one place it had left the pattern it had banned everywhere
101
+ // else.
102
+ const r = await exec("osascript", [
103
+ "-e",
104
+ 'on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run',
105
+ title, body,
106
+ ]).catch(() => null);
107
+ return r?.ok === true;
108
+ }
109
+ if (platform === "win32") {
110
+ // PowerShell's own toast, through the same argv discipline: the strings go
111
+ // in as parameters rather than as script text.
112
+ const r = await exec("powershell.exe", [
113
+ "-NoProfile", "-NonInteractive", "-Command",
114
+ "param($t,$b); [void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime];"
115
+ + "$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0);"
116
+ + "$n = $x.GetElementsByTagName('text'); $n.Item(0).AppendChild($x.CreateTextNode($t)) > $null;"
117
+ + "$n.Item(1).AppendChild($x.CreateTextNode($b)) > $null;"
118
+ + "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ccdeck').Show($x)",
119
+ "-t", title, "-b", body,
120
+ ]).catch(() => null);
121
+ return r?.ok === true;
122
+ }
123
+ const r = await exec("notify-send", [title, body]).catch(() => null);
124
+ return r?.ok === true;
125
+ }
126
+
127
+ /** Close one tab by its exact URL. macOS only; see `available`. */
128
+ export async function closeTab(browserKey, url, platform = process.platform, deps = {}) {
129
+ if (platform !== "darwin") return { ok: false, reason: "unsupported" };
130
+ const app = appName(browserKey);
131
+ if (!app) return { ok: false, reason: "unknown_browser" };
132
+ const exec = deps.run ?? run;
133
+ // Through `-e` rather than stdin: `run` closes the child's stdin immediately
134
+ // and says so in its own contract, so `osascript -` would read an empty
135
+ // script and report success having done nothing. The URL still travels as
136
+ // argv, which is the part that matters.
137
+ const r = await exec("osascript", ["-e", closeTabScript(app), url]).catch(() => null);
138
+ if (!r?.ok) return { ok: false, reason: "script_failed" };
139
+ const said = String(r.stdout ?? "").trim();
140
+ return { ok: said === "closed", reason: said || "unknown" };
141
+ }
142
+
143
+ /** Quit a browser. The only reaction that takes the session back. */
144
+ export async function quitBrowser(browserKey, platform = process.platform, deps = {}) {
145
+ const app = appName(browserKey);
146
+ if (!app) return { ok: false, reason: "unknown_browser" };
147
+ const exec = deps.run ?? run;
148
+ if (platform === "darwin") {
149
+ const r = await exec("osascript", [
150
+ "-e", 'on run argv\ntell application (item 1 of argv) to quit\nend run', app,
151
+ ]).catch(() => null);
152
+ return { ok: r?.ok === true, reason: r?.ok ? "quit" : "script_failed" };
153
+ }
154
+ if (platform === "win32") {
155
+ const r = await exec("taskkill", ["/IM", `${app.replace(/ /g, "")}.exe`, "/F"]).catch(() => null);
156
+ return { ok: r?.ok === true, reason: r?.ok ? "quit" : "taskkill_failed" };
157
+ }
158
+ const r = await exec("pkill", ["-x", app.toLowerCase().replace(/ /g, "-")]).catch(() => null);
159
+ return { ok: r?.ok === true, reason: r?.ok ? "quit" : "pkill_failed" };
160
+ }
161
+
162
+ /**
163
+ * Carry out the armed reaction for one episode.
164
+ *
165
+ * Always notifies, whatever else it does. A tab that closed itself with no
166
+ * explanation is a mystery rather than a warning, and the point of the feature
167
+ * is that the user finds out.
168
+ */
169
+ export async function react(reaction, episode, { platform = process.platform, deps = {} } = {}) {
170
+ const done = [];
171
+ const pages = `${episode.count} page${episode.count === 1 ? "" : "s"}`;
172
+ if (await notify("Browser watch", `${episode.host} — ${pages} while you were away`, platform, deps)) {
173
+ done.push("notified");
174
+ } else {
175
+ done.push("could not notify");
176
+ }
177
+
178
+ if (!performable(reaction, platform) || reaction === "notify") return done;
179
+
180
+ // A REACTION THAT COULD NOT ACT MUST SAY SO. This reported only its
181
+ // successes, so every failure was silent — and there were two whole months of
182
+ // them: `episode.browser` was null until it was fixed, `appName(null)` is
183
+ // null, and both destructive reactions returned `unknown_browser` and pushed
184
+ // nothing. The panel said a finding had been handled and nothing had been.
185
+ //
186
+ // The failures that remain are ordinary and will happen: macOS asks once for
187
+ // permission to control another application and refuses forever if declined;
188
+ // a tab can be closed by hand before the poll reaches it; a browser can quit
189
+ // on its own. Each of those is something the reader has to be able to see,
190
+ // because the alternative is believing a tab was closed that is still open.
191
+ if (reaction === "close-tab") {
192
+ // Every URL in the episode, because an episode is a run and closing only
193
+ // its first page leaves the rest of the run open.
194
+ for (const u of episode.urls ?? []) {
195
+ const out = await closeTab(episode.browser, u.url, platform, deps);
196
+ done.push(out.ok ? `closed ${u.url}` : `could not close ${u.url} — ${out.reason}`);
197
+ }
198
+ return done;
199
+ }
200
+
201
+ const out = await quitBrowser(episode.browser, platform, deps);
202
+ done.push(out.ok ? "quit the browser" : `could not quit the browser — ${out.reason}`);
203
+ return done;
204
+ }