agent-dag 3.2.1 → 3.3.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.
@@ -662,7 +662,7 @@ export const MAX_SCAN_CHUNK = 8 * 1024 * 1024;
662
662
  // pointing one POST at something arbitrarily large: the read still terminates,
663
663
  // the cursor keeps whatever it reached, and the next throttled pass continues
664
664
  // from there.
665
- export const MAX_SCAN_BYTES_PER_PASS = 256 * 1024 * 1024;
665
+ const MAX_SCAN_BYTES_PER_PASS = 256 * 1024 * 1024;
666
666
 
667
667
  const NEWLINE = 0x0a;
668
668
 
@@ -1458,7 +1458,7 @@ const pendingNameReads = new Set(); // sid currently being read
1458
1458
  /** The naming the cursor has folded so far, or null when the scan has nothing.
1459
1459
  * Exported beside readContextFromTranscript for the same reason: the rule is
1460
1460
  * worth pinning directly rather than through a live server. */
1461
- export async function readSessionNamingFromTranscript(path) {
1461
+ async function readSessionNamingFromTranscript(path) {
1462
1462
  const state = await scanTranscript(path);
1463
1463
  if (!state) return null;
1464
1464
  if (!state.agentName && !state.aiTitle) return null;
@@ -3470,7 +3470,13 @@ export function replayScope(workspace, platform = process.platform) {
3470
3470
  * that case cheap needs an index of where a workspace's lines are, which is a
3471
3471
  * different change from this one.
3472
3472
  */
3473
- async function replayLog(filePath, workspace = "") {
3473
+ /* Exported for the suite, with both ceilings as parameters. The byte budget is
3474
+ * 128 MiB, so a test that wanted to reach it honestly would have to write 128
3475
+ * MiB — which is why the count bound was the only one anything pinned, and why
3476
+ * the byte bound was the one that broke. Production passes neither argument. */
3477
+ export async function replayLog(filePath, workspace = "", {
3478
+ maxEvents = MAX_BUFFER, maxChars = MAX_BUFFER_CHARS,
3479
+ } = {}) {
3474
3480
  if (!existsSync(filePath)) return 0;
3475
3481
  let skipped = 0;
3476
3482
  let skippedBytes = 0;
@@ -3499,17 +3505,32 @@ async function replayLog(filePath, workspace = "") {
3499
3505
  }
3500
3506
  } else {
3501
3507
  // Newest first, so this is filled back to front and then walked in reverse
3502
- // to push. Bounded by MAX_BUFFER, which is what makes the memory here a
3503
- // property of the ring rather than of the file.
3508
+ // to push. Bounded by BOTH of the ring's limits, which is what makes the
3509
+ // memory here a property of the ring rather than of the file.
3510
+ //
3511
+ // The count alone was not enough, and the comment that used to say it was
3512
+ // predates #625. Eviction is the only thing that applies MAX_BUFFER_CHARS,
3513
+ // and eviction happens inside pushEvent — which does not run until this
3514
+ // array is already full. Measured on a 187 MB log of 40 events of 4.9M
3515
+ // characters each, every one of them under the ingest cap: RSS went from
3516
+ // 215 MB to a peak of 505 MB, and the ring that survived held 27 events and
3517
+ // 126 MiB. About 290 MB staged for a ring capped at 128.
3518
+ //
3519
+ // Rotation at 50 MB normally keeps logs well under this, but rotation is
3520
+ // best-effort and its failure is only logged, and this file's own header
3521
+ // records logs reaching gigabytes.
3504
3522
  const newestFirst = [];
3523
+ let stagedChars = 0;
3505
3524
  for await (const line of linesFromEnd(filePath)) {
3506
3525
  if (!line) continue;
3507
3526
  const evt = parse(line);
3508
3527
  if (!usable(evt) || !admits(evt.payload)) continue;
3509
3528
  newestFirst.push(evt);
3529
+ stagedChars += ENVELOPE_CHARS + payloadChars(evt.payload);
3510
3530
  // Everything older than this would be evicted by the events already held,
3511
3531
  // so reading further is work whose only result is throwing it away.
3512
- if (newestFirst.length >= MAX_BUFFER) break;
3532
+ // Either limit reaching its ceiling means exactly that.
3533
+ if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
3513
3534
  }
3514
3535
  for (let i = newestFirst.length - 1; i >= 0; i--) replay(newestFirst[i]);
3515
3536
  count = newestFirst.length;
@@ -4116,7 +4137,7 @@ async function handleBrowserWatchSettings(req, res) {
4116
4137
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
4117
4138
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
4118
4139
 
4119
- const { readStore, writeStore, normalise } = await import(
4140
+ const { readStore, updateStore, normalise } = await import(
4120
4141
  pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
4121
4142
  );
4122
4143
  const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
@@ -4132,7 +4153,11 @@ async function handleBrowserWatchSettings(req, res) {
4132
4153
  // this field ERASES it. Measured — changing the reaction wiped every
4133
4154
  // dismissal, so every episode the reader had reviewed came straight back on
4134
4155
  // the next poll, from a settings change that had nothing to do with them.
4135
- await writeStore({ settings, episodes: store.episodes, dismissed: store.dismissed });
4156
+ // Only the settings are this route's to change. `updateStore` re-reads inside
4157
+ // the write queue, so a poll that landed between the read above and this line
4158
+ // cannot have its archive thrown away by a settings change — which is what
4159
+ // writing a whole state read seconds earlier used to do.
4160
+ await updateStore(cur => ({ ...cur, settings }));
4136
4161
  // The one line in the log that is somebody acting rather than the deck
4137
4162
  // reading, which is exactly why it is worth its own entry.
4138
4163
  if (settings.enabled !== store.settings.enabled) {
@@ -4168,7 +4193,7 @@ async function handleBrowserWatchDismiss(req, res) {
4168
4193
  const startMs = typeof body?.startMs === "number" && Number.isFinite(body.startMs) ? body.startMs : null;
4169
4194
  if (host === null || startMs === null) return send(res, 400, { ok: false, reason: "bad_request" });
4170
4195
 
4171
- const { readStore, writeStore, episodeKey } = await import(
4196
+ const { readStore, updateStore, episodeKey } = await import(
4172
4197
  pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
4173
4198
  );
4174
4199
  const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
@@ -4177,7 +4202,7 @@ async function handleBrowserWatchDismiss(req, res) {
4177
4202
  const store = await readStore();
4178
4203
  const key = episodeKey(host, startMs);
4179
4204
  const dismissed = [...new Set([...(store.dismissed ?? []), key])];
4180
- await writeStore({ settings: store.settings, episodes: store.episodes, dismissed });
4205
+ await updateStore(cur => ({ ...cur, dismissed }));
4181
4206
  // The reader acting on their own list, which is exactly the kind of line the
4182
4207
  // `act` level exists for.
4183
4208
  noteWatchSetting(`dismissed ${host}`);
@@ -4188,6 +4213,12 @@ async function handleBrowserWatchDismiss(req, res) {
4188
4213
  async function handleBrowserWatch(req, res) {
4189
4214
  const url = new URL(req.url, "http://localhost");
4190
4215
  const force = url.searchParams.get("refresh") === "1";
4216
+ // `live=0` is the badge's five-minute poll saying "the archive is enough".
4217
+ // With the watch OFF that is honoured and no browser is read at all — see
4218
+ // browserWatchSnapshot: the switch used to gate only what was KEPT, so a deck
4219
+ // nobody had switched on still copied every History database every five
4220
+ // minutes. A forced read overrides it, because that is the user pressing ↻.
4221
+ const readBrowsers = force || url.searchParams.get("live") !== "0";
4191
4222
 
4192
4223
  // Numbers from a query string are refused rather than coerced: NaN would
4193
4224
  // silently widen the quiet gate to "everything counts", which is the failure
@@ -4217,6 +4248,7 @@ async function handleBrowserWatch(req, res) {
4217
4248
  const ports = await registeredDeckPorts();
4218
4249
  return send(res, 200, await fetchBrowserWatch({
4219
4250
  force,
4251
+ readBrowsers,
4220
4252
  deckOrigins: deckOwnOrigins(undefined, ports),
4221
4253
  quietMs: minutes("quiet"),
4222
4254
  gapMs: minutes("gap"),
@@ -4676,9 +4708,19 @@ function handleHookChallenge(_req, res, url) {
4676
4708
  send(res, 200, { proof: challengeProof(HOOK_TOKEN, nonce) });
4677
4709
  }
4678
4710
 
4711
+ // Signal 0 delivers nothing; it asks whether the pid could be signalled.
4712
+ //
4713
+ // BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
4714
+ // answers EPERM for a process this account may not signal. On Windows
4715
+ // `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
4716
+ // maps that to EACCES — so a deck started from an elevated terminal, or under
4717
+ // another account, read as DEAD to every probe in this repo. What followed was
4718
+ // silent: the live deck's discovery file was unlinked on the next hook fire,
4719
+ // rewritten five seconds later by keepDiscovery, and its banner went on
4720
+ // claiming it was receiving events it had stopped receiving.
4679
4721
  function isProcessAlive(pid) {
4680
4722
  try { process.kill(pid, 0); return true; }
4681
- catch (e) { return e && e.code === "EPERM"; }
4723
+ catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
4682
4724
  }
4683
4725
 
4684
4726
  async function sweepStaleDiscovery() {
@@ -4990,6 +5032,72 @@ function isDeckUiRequest({ origin, host, secFetchSite } = {}) {
4990
5032
  // presents nothing at all no longer changes anything — and what is opened is
4991
5033
  // the honest door, so a script of the user's own authenticates by reading the
4992
5034
  // token instead of impersonating a page.
5035
+ /**
5036
+ * May this request read the deck's OWN data — the events, the accounts, the
5037
+ * browsing episodes?
5038
+ *
5039
+ * The mutation gate exists because `curl -XPOST localhost:4317/…/admin` handed
5040
+ * a live OAuth refresh token to a sandboxed subprocess with loopback egress.
5041
+ * The same caller could still `curl localhost:4317/api/events` and read the
5042
+ * whole ring — prompt text, the Bash command lines the agent ran, the paths and
5043
+ * contents it wrote, the contents of every file it read back — plus the account
5044
+ * roster and the browsing episodes. The threat model had been applied to half
5045
+ * the surface.
5046
+ *
5047
+ * Same shape as isAuthorizedMutation, with one difference forced by the
5048
+ * browser: a same-origin GET carries no `Origin` header at all, so the UI
5049
+ * cannot be recognised the way a POST is. `Sec-Fetch-Site: same-origin` is what
5050
+ * a page's own fetch and its EventSource both send, on every browser new enough
5051
+ * to run this bundle, and it is a header no non-browser client sends by
5052
+ * accident. A caller that sends neither it nor the token is not a page.
5053
+ *
5054
+ * WHAT STAYS OPEN, deliberately: /api/health (the hook's readiness probe),
5055
+ * /api/hook-challenge (the handshake itself), the static files, and every
5056
+ * measurement route — the machine panel's numbers are about the machine, not
5057
+ * about what the user is doing on it.
5058
+ */
5059
+ function isAuthorizedDataRead(req) {
5060
+ const headers = req?.headers ?? {};
5061
+ if (presentsDeckToken(headers)) return true;
5062
+ // Addressed to this machine by a name that can only be this machine — so a
5063
+ // rebound page, which also reports same-origin, does not qualify.
5064
+ if (!isLoopbackHost(headers.host)) return false;
5065
+
5066
+ const site = typeof headers["sec-fetch-site"] === "string"
5067
+ ? headers["sec-fetch-site"].trim().toLowerCase() : "";
5068
+ if (site === "same-origin") return true;
5069
+ // ANY FETCH METADATA AT ALL, AND IT SAID SOMETHING ELSE. `cross-site`,
5070
+ // `same-site` and `none` are all a page that is not this one — or a top-level
5071
+ // navigation typed into the address bar, which has no business reading the
5072
+ // ring.
5073
+ if (site !== "") return false;
5074
+
5075
+ // THE BROWSER THAT SENDS NO FETCH METADATA, and this is the whole reason this
5076
+ // branch exists. Sec-Fetch-Site is Safari 16.4 and newer; Safari 16.0-16.3
5077
+ // runs this bundle perfectly well (Vite's default target is Safari 16) and
5078
+ // sends none of it. Without a fallback those users get an empty canvas and a
5079
+ // 401 they cannot act on — an impediment for a browser that is otherwise
5080
+ // fine.
5081
+ //
5082
+ // Referer is what they do send, on a page's own fetches and on its
5083
+ // EventSource, and it must name THIS origin. A cross-site page's Referer
5084
+ // names its own; a rebound page's names the attacker's host, which is not a
5085
+ // loopback identity. It is forgeable by a non-browser client — and so is
5086
+ // Sec-Fetch-Site, which curl sets as easily; neither is the control that
5087
+ // stops a deliberate local caller. That control is the token, and this only
5088
+ // decides which BROWSERS are recognised as the deck's own page.
5089
+ return originMatchesHost(headers.referer, headers.host);
5090
+ }
5091
+
5092
+ /** The reads that carry the user's own work, rather than the machine's. */
5093
+ const GUARDED_READS = new Set([
5094
+ "/events",
5095
+ "/api/events",
5096
+ "/api/claude-accounts",
5097
+ "/api/claude-accounts/login",
5098
+ "/api/browser-watch",
5099
+ ]);
5100
+
4993
5101
  function isAuthorizedMutation(req) {
4994
5102
  const headers = req?.headers ?? {};
4995
5103
  if (presentsDeckToken(headers)) return true;
@@ -5200,6 +5308,14 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5200
5308
  return send(res, 401, { error: "unauthenticated" });
5201
5309
  }
5202
5310
 
5311
+ // And the reads that carry the same secrets. See isAuthorizedDataRead: the
5312
+ // gate above was written for a sandboxed subprocess with loopback egress,
5313
+ // and that caller was reading the ring through a GET the whole time.
5314
+ if ((req.method === "GET" || req.method === "HEAD")
5315
+ && GUARDED_READS.has(url.pathname) && !isAuthorizedDataRead(req)) {
5316
+ return send(res, 401, { error: "unauthenticated" });
5317
+ }
5318
+
5203
5319
  // `?persist=0` — another deck was elected to write this event to the log
5204
5320
  // the two of them share. Absent, this deck writes it.
5205
5321
  if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res, url.searchParams.get("persist") !== "0"), res);
@@ -353,7 +353,31 @@ async function writeFileAtomic(rawTarget, text) {
353
353
  // to stay 600. No-op on Windows, where chmod only toggles the read-only bit.
354
354
  const mode = await stat(target).then(s => s.mode, () => null);
355
355
  if (mode !== null) await chmod(tmp, mode).catch(() => {});
356
+ // A READ-ONLY TARGET IS A DEAD END ON WINDOWS, and only there. libuv's
357
+ // rename is one MoveFileExW(MOVEFILE_REPLACE_EXISTING), which refuses to
358
+ // replace a destination carrying FILE_ATTRIBUTE_READONLY; POSIX rename(2)
359
+ // over a 0444 file succeeds, because only the parent directory's write bit
360
+ // decides. A settings.json picks that attribute up from a OneDrive restore,
361
+ // a copy off a network share, or read-only media — and EACCES is in the
362
+ // retry ladder, so the whole ~1.4s was spent before throwing, on every
363
+ // boot, forever. Every settings writer goes through here, so hooks never
364
+ // installed and the sound-hook retirement could never repair a stale entry
365
+ // either.
366
+ //
367
+ // chmod on Windows toggles exactly that attribute and nothing else, which
368
+ // is why this is safe to do unconditionally there: the mode carried above
369
+ // is re-applied to the new file after the rename, so a file the user marked
370
+ // read-only stays read-only.
371
+ // ONLY WHEN THE TARGET IS ACTUALLY READ-ONLY. Two extra syscalls on the
372
+ // path between the temp write and the rename are not free on Windows:
373
+ // discovery-live.test.ts hammers writeDiscovery while a reader holds the
374
+ // destination open, and the wider window turned a rename the retry ladder
375
+ // used to win into an EPERM it gave up on. The attribute is what this
376
+ // clears, so a file that does not carry it has nothing to clear.
377
+ const readOnly = process.platform === "win32" && mode !== null && (mode & 0o200) === 0;
378
+ if (readOnly) await chmod(target, 0o666).catch(() => {});
356
379
  await renameWithRetry(tmp, target);
380
+ if (readOnly) await chmod(target, mode).catch(() => {});
357
381
  } catch (err) {
358
382
  // Cleanup covers the write and the fsync as well as the rename: a full disk
359
383
  // used to leave the half-written temp file sitting beside the target.
@@ -405,7 +429,7 @@ function dedupeOurEntries(group) {
405
429
  }
406
430
 
407
431
  /** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}. */
408
- export async function installHooks({ provider = "claude" } = {}) {
432
+ export async function installHooks({ provider = "claude", beforeWrite = null } = {}) {
409
433
  const cfg = PROVIDERS[provider];
410
434
  if (!cfg) throw new Error(`unknown provider: ${provider}`);
411
435
  // An uninstall-only provider has no event list. Saying so beats the
@@ -481,7 +505,38 @@ export async function installHooks({ provider = "claude" } = {}) {
481
505
  // compare against the exact bytes we read and, when they match, do nothing.
482
506
  const next = JSON.stringify(current, null, 2) + "\n";
483
507
  const changed = next !== before;
484
- if (changed) await writeFileAtomic(cfg.settingsPath, next);
508
+ if (changed) {
509
+ // COMPARE AGAINST THE FILE, NOT AGAINST THE SNAPSHOT, at the last moment.
510
+ //
511
+ // Everything above was computed from bytes read at the top of this
512
+ // function, and two decks booting together — the ordinary case on a machine
513
+ // where one was already running — interleave inside that window. The one
514
+ // that loses is unrecoverable rather than merely stale: deck A restores the
515
+ // user's own sound hooks from the parked file and deletes the park, and
516
+ // deck B then writes a settings object computed before that restore, with
517
+ // an empty park behind it. The user's hook is gone from settings.json and
518
+ // from the only other copy of it.
519
+ //
520
+ // So the file is re-read immediately before the write and, if another
521
+ // writer has touched it, this pass declines. Declining is safe by
522
+ // construction: every boot reinstalls, so the next one recomputes against
523
+ // the new bytes and converges — and the entries this function adds are
524
+ // identical on both decks, which is why the loser has nothing of its own to
525
+ // lose.
526
+ // The seam the suite needs, and the only way to test this deterministically:
527
+ // the window between the read at the top and the write below is filled with
528
+ // real fs work, so a test that raced it by wall clock would pass or fail by
529
+ // how fast the machine is. Production passes nothing.
530
+ if (beforeWrite) await beforeWrite();
531
+ const { raw: onDisk } = await readSettingsForWrite(cfg.settingsPath).catch(() => ({ raw: before }));
532
+ if (onDisk !== before) {
533
+ return {
534
+ settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider,
535
+ changed: false, raced: true, retire: { ...retire, pending: false },
536
+ };
537
+ }
538
+ await writeFileAtomic(cfg.settingsPath, next);
539
+ }
485
540
  // After the write, never before it: the notify script an older deck installed
486
541
  // is what a live session's cached command still names until the new entry is
487
542
  // on disk, and deleting it early turns a stale sound into a missing module.
@@ -773,4 +828,4 @@ export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
773
828
  // repo for the same reasons settings.json is, and "never rename onto a link" is
774
829
  // one rule: codex-auth.mjs called a realpath of its own before this existed, and
775
830
  // two spellings of a rule are two things that can drift.
776
- export { readSettingsForWrite, writeFileAtomic, installScript, renameWithRetry, createTemp, resolveWriteTarget };
831
+ export { readSettingsForWrite, writeFileAtomic, renameWithRetry, createTemp, resolveWriteTarget };
@@ -193,11 +193,6 @@ export function macmonAsset(release) {
193
193
  return { version: typeof tag === "string" ? tag : "unknown", url: a.browser_download_url, sha256: m[1] };
194
194
  }
195
195
 
196
- /** A macmon this function installed earlier, or null. */
197
- export function existingBootstrappedMacmon() {
198
- const bin = join(MACMON_DIR, "macmon");
199
- return existsSync(bin) ? bin : null;
200
- }
201
196
 
202
197
  /**
203
198
  * Download macmon into ~/.agents-deck/tools/macmon.
@@ -210,9 +205,25 @@ export function existingBootstrappedMacmon() {
210
205
  * ioreg and never reaches this file at all.
211
206
  */
212
207
  export async function bootstrapMacmon({
213
- platform = process.platform, env = process.env, fetchFn = fetch, dir = MACMON_DIR,
208
+ platform = process.platform, arch = process.arch, env = process.env,
209
+ fetchFn = fetch, dir = MACMON_DIR, findBin = macmonBin,
214
210
  } = {}) {
215
211
  if (platform !== "darwin") return { ok: false, reason: "unsupported_platform" };
212
+ // ARM64 ONLY, checked rather than merely documented. The release publishes
213
+ // one asset and it is an arm64 Mach-O, so an Intel Mac downloaded 746 KB it
214
+ // could not execute and failed its own --version check afterwards. That is
215
+ // the small half. The larger half is the promise: the README says an Intel
216
+ // Mac never downloads anything, and this reached api.github.com on any Mac
217
+ // whose sensors happened to stay silent — which an Intel Mac's do whenever
218
+ // ioreg publishes no Temperature(C).
219
+ if (arch !== "arm64") return { ok: false, reason: "unsupported_arch" };
220
+ // A macmon the user already has is the other thing the README promises to
221
+ // notice, and until now the skip was emergent rather than checked: a working
222
+ // copy produces a reading, the reading sets thermalEverAnswered, and the
223
+ // give-up branch never fires. That chain breaks on a macmon which runs but
224
+ // reports values this deck rejects as implausible — and then a machine with
225
+ // macmon on PATH downloaded a second one.
226
+ if (await findBin()) return { ok: false, reason: "already_installed" };
216
227
  // Both switches, for the reason uv-bootstrap has both: downloading an
217
228
  // executable is a bigger step than installing a package with a tool the user
218
229
  // already chose, so somebody may want the managed installs and not this.
@@ -194,7 +194,12 @@ export function npxPrefetch(spec, {
194
194
  timer.unref?.();
195
195
 
196
196
  child.on("error", err => settle({ ok: false, error: `could not run npx: ${err?.message ?? err}`, hint: null }));
197
- child.on("exit", (code, signal) => {
197
+ // 'close' rather than 'exit': 'exit' fires when the process ends and says
198
+ // nothing about its pipes, so past one pipe buffer the tail this kept —
199
+ // deliberately the TAIL, because npm's reason is on its last lines — was
200
+ // still only the first 8 KiB when npxFailureSummary read it. 'close' is
201
+ // emitted after both streams have been drained.
202
+ child.on("close", (code, signal) => {
198
203
  if (code === 0) { settle({ ok: true, error: null, hint: null }); return; }
199
204
  settle({
200
205
  ok: false,
@@ -166,11 +166,31 @@ export function startCommand(url, env = process.env, comspec) {
166
166
  * it and `start` would run it.
167
167
  */
168
168
  export function isOpenable(url) {
169
+ return normalizeOpenable(url) !== null;
170
+ }
171
+
172
+ /**
173
+ * The address to actually hand the desktop, or null when it is not one.
174
+ *
175
+ * The guard used to parse into a local `u`, check its protocol and throw the
176
+ * parse away — so what reached `launchers` was the RAW string. `new URL()`
177
+ * accepts characters it normalizes only in `href`, a `"` among them, and on
178
+ * Windows `startCommand` builds `start "" "<url>"` with
179
+ * `windowsVerbatimArguments: true`, where a quote is syntax. Today the only
180
+ * caller passes a loopback URL this deck built itself, so nothing is reachable;
181
+ * the function's own doc says it exists "for the day a second caller passes a
182
+ * path", and as written it would not have stopped that caller.
183
+ *
184
+ * Returning the normalized `href` means the string that was checked is the
185
+ * string that is launched.
186
+ */
187
+ export function normalizeOpenable(url) {
169
188
  try {
170
189
  const u = new URL(String(url));
171
- return u.protocol === "http:" || u.protocol === "https:";
190
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
191
+ return u.href;
172
192
  } catch {
173
- return false;
193
+ return null;
174
194
  }
175
195
  }
176
196
 
@@ -183,8 +203,9 @@ export function isOpenable(url) {
183
203
  * the same recovery an error row would have offered.
184
204
  */
185
205
  export function openUrl(url, { platform = process.platform, env = process.env, spawnFn = spawn } = {}) {
186
- if (!isOpenable(url)) return;
187
- const tries = launchers(url, { platform, env });
206
+ const href = normalizeOpenable(url);
207
+ if (href === null) return;
208
+ const tries = launchers(href, { platform, env });
188
209
 
189
210
  const attempt = (i) => {
190
211
  if (i >= tries.length) return;
@@ -35,6 +35,11 @@ import { readFile } from "node:fs/promises";
35
35
  import { join } from "node:path";
36
36
  import { homedir } from "node:os";
37
37
  import { PRODUCT } from "./brand.mjs";
38
+ // One ANSI stripper for the whole deck. The private copy that used to live
39
+ // here accepted only the BEL terminator for an OSC sequence, while term.mjs's
40
+ // also accepts ESC \\ — so a hyperlink written the other legal way survived
41
+ // into text this module then parsed for quota lines.
42
+ import { stripAnsi } from "./term.mjs";
38
43
  import { resetLabelIso } from "./reset-label.mjs";
39
44
 
40
45
  const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
@@ -83,6 +88,73 @@ async function readOAuthToken() {
83
88
  }
84
89
  }
85
90
 
91
+ /**
92
+ * Whether we may spend a request of the user's budget right now.
93
+ *
94
+ * Exported for tests — this is the rule that stopped the deck from starving
95
+ * claude-swap, and it is worth pinning down.
96
+ */
97
+ export function maySelfPoll({ now, force, lastSelfPollAt, rateLimitedUntil }) {
98
+ if (now < rateLimitedUntil) return false;
99
+ return now - lastSelfPollAt >= (force ? FORCE_POLL_MS : SELF_POLL_MS);
100
+ }
101
+
102
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
103
+
104
+ /**
105
+ * A cooldown from a `retry-after`, kept inside limits the deck can live with.
106
+ *
107
+ * Unclamped, the header decided the poller's fate in both directions: `0` (or a
108
+ * value the server rounds down to it) defeats the cooldown entirely and the
109
+ * next tick asks again immediately, which is the loop a 429 exists to stop; a
110
+ * large one — a day is a legal value — freezes the reader for the life of the
111
+ * process, and nothing here re-reads it. Both are the remote side deciding how
112
+ * this deck behaves, which a header is not entitled to do.
113
+ *
114
+ * The floor is the deck's own minimum backoff and the ceiling is an hour: long
115
+ * enough to be a real retreat, short enough that a quota panel is not dead for
116
+ * the rest of the day because one reply said so.
117
+ */
118
+ export function cooldownFromHeader(raw, fallbackMs, minMs = 30_000, maxMs = 3600_000) {
119
+ const seconds = parseInt(String(raw ?? ""), 10);
120
+ if (!Number.isFinite(seconds)) return fallbackMs;
121
+ return Math.min(Math.max(seconds * 1000, minMs), maxMs);
122
+ }
123
+
124
+ /**
125
+ * WHETHER THIS MACHINE HAS A SUBSCRIPTION TO REPORT ON AT ALL.
126
+ *
127
+ * Every source here needs a Claude.ai OAuth credential: the claude-swap store
128
+ * holds one, `claudeAiOauth` in the credentials file is one, and
129
+ * `claude --print /usage` prints windows only for a session signed in with one.
130
+ * An API-key, Bedrock or Vertex install has none — and there is no quota to
131
+ * read, because those are billed per token rather than in five-hour windows.
132
+ *
133
+ * That mattered because of what the CLI does on such a machine: it RUNS, prints
134
+ * no quota lines, and the branch below used to read that as "genuine <1%" and
135
+ * publish `ok: true` with two zeroes. The panel then drew empty bars, which is
136
+ * a measurement nobody took. Codex already answers this properly, with
137
+ * `api_key_mode` as its own reason and its own sentence.
138
+ *
139
+ * Cheap and synchronous: environment first, because a machine configured for
140
+ * Bedrock or Vertex says so there, then the presence of the OAuth block in the
141
+ * credentials file. `readOAuthToken` above answers a different question — it
142
+ * also rejects an EXPIRED token, and an expired subscription is still a
143
+ * subscription.
144
+ */
145
+ export async function hasSubscriptionCredential(env = process.env) {
146
+ if (env.CLAUDE_CODE_USE_BEDROCK === "1" || env.CLAUDE_CODE_USE_VERTEX === "1") return false;
147
+ try {
148
+ const raw = await readFile(credentialsPath(), "utf8");
149
+ if (JSON.parse(raw)?.claudeAiOauth?.accessToken) return true;
150
+ } catch { /* absent or unreadable, decided below */ }
151
+ // A key in the environment and no OAuth block beside it is the API-key
152
+ // install. Without either, this deck simply has not been signed in yet, and
153
+ // "sign in" is the right thing to say — which is the `waiting` branch, not
154
+ // this one.
155
+ return !(env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN);
156
+ }
157
+
86
158
  // ISO-8601 → "Jun 19, 1:19pm" (local time, matching the CLI display format).
87
159
  //
88
160
  // The body moved to reset-label.mjs in #374: codex-quota.mjs had a copy that
@@ -156,9 +228,7 @@ async function fetchOAuthUsage() {
156
228
  });
157
229
 
158
230
  if (res.status === 429) {
159
- const retryAfter = parseInt(res.headers.get("retry-after") ?? "", 10);
160
- const cooldownMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 5 * 60_000;
161
- _rateLimitedUntil = Date.now() + cooldownMs;
231
+ _rateLimitedUntil = Date.now() + cooldownFromHeader(res.headers.get("retry-after"), 5 * 60_000);
162
232
  return null;
163
233
  }
164
234
  if (!res.ok) return null;
@@ -236,25 +306,6 @@ export function quotaFromStore(entry) {
236
306
  return out;
237
307
  }
238
308
 
239
- /**
240
- * Whether we may spend a request of the user's budget right now.
241
- *
242
- * Exported for tests — this is the rule that stopped the deck from starving
243
- * claude-swap, and it is worth pinning down.
244
- */
245
- export function maySelfPoll({ now, force, lastSelfPollAt, rateLimitedUntil }) {
246
- if (now < rateLimitedUntil) return false;
247
- return now - lastSelfPollAt >= (force ? FORCE_POLL_MS : SELF_POLL_MS);
248
- }
249
-
250
- const sleep = (ms) => new Promise(r => setTimeout(r, ms));
251
-
252
- function stripAnsi(s) {
253
- return s
254
- .replace(/\x1B\[[0-9;]*[A-Za-z]/g, "")
255
- .replace(/\x1B\][^\x07]*\x07/g, "")
256
- .replace(/\x1B[()][AB012]/g, "");
257
- }
258
309
 
259
310
  // Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
260
311
  // Claude shows times in the user's local timezone, so parsing as local is correct.
@@ -671,12 +722,23 @@ async function _doFetch(now, force = false, gen = _generation) {
671
722
  return publish(gen, { ..._lastGood, stale: true }, now - (CACHE_MS - 5_000));
672
723
  }
673
724
 
674
- // Never had good data. CLI ran but lines absent treat as genuine <1%.
675
- // CLI failed entirely ok:false. Either way short-cache for a quick retry.
676
- const result = cliOk
725
+ // Never had good data. A CLI that RAN and printed no quota lines is two
726
+ // different machines, and they need two different answers:
727
+ //
728
+ // * a subscription install on a cold invocation — the lines come back on a
729
+ // later call, and until then "<1%" is the honest reading of a window that
730
+ // has genuinely just reset;
731
+ // * an API-key, Bedrock or Vertex install, which has no windows at all.
732
+ // Publishing two zeroes there drew empty bars for a measurement nobody
733
+ // took, on a machine where no amount of retrying will ever produce one.
734
+ //
735
+ // A CLI that failed entirely is `ok: false` as it always was, and the reason
736
+ // says which of the two the reader is looking at.
737
+ const subscribed = cliOk ? await hasSubscriptionCredential() : false;
738
+ const result = cliOk && subscribed
677
739
  ? { ok: true, session5hPct: 0, session5hWindowSec: 18000,
678
740
  week7dPct: 0, week7dWindowSec: 604800, fetchedAt: now }
679
- : { ok: false, fetchedAt: now };
741
+ : { ok: false, reason: cliOk ? "no_subscription" : "cli_failed", fetchedAt: now };
680
742
  return publish(gen, result, now - (CACHE_MS - 5_000));
681
743
  }
682
744
 
@@ -94,10 +94,44 @@ const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
94
94
  const commandsOf = (entry) =>
95
95
  (entry?.hooks ?? []).map(h => (typeof h?.command === "string" ? h.command : ""));
96
96
 
97
+ /**
98
+ * THE TAIL, NOT THE WHOLE PATH — and this is a data-loss fix, not a tidy-up.
99
+ *
100
+ * The stored command was written by `shellQuoteArg`, whose POSIX branch
101
+ * single-quotes the argument and rewrites every `'` as `'\''`. So on a config
102
+ * dir like `/mnt/Bob's SSD/claude` the command contains `Bob'\''s` and a
103
+ * `cmd.includes(NOTIFY_PATH)` against the raw path is false — on macOS and
104
+ * Linux, for a perfectly ordinary directory name.
105
+ *
106
+ * What follows is unrecoverable. A mark-less `Stop` entry — the case this
107
+ * module exists for — is then not recognised as ours, so it is kept; and
108
+ * `anythingStillNamesOurScripts`, which has no mark to fall back on, also says
109
+ * no, so the sweep deletes `notify.mjs`. Claude Code throws
110
+ * `Cannot find module` at the end of every turn afterwards, forever, with the
111
+ * deck already uninstalled.
112
+ *
113
+ * The last two segments are fixed whatever the prefix is — the install
114
+ * directory is always `<config dir>/agent-dag` — and they contain no character
115
+ * any quoting rewrites. Case is folded where the filesystem folds it, which is
116
+ * the other half: `exec.mjs`'s own `sameCommand` lowercases "because Windows
117
+ * paths are", and this comparison did not.
118
+ */
119
+ const SCRIPT_TAILS = ["agent-dag/notify.mjs", "agent-dag/notify.js"];
120
+
121
+ export function namesOurScript(cmd, platform = process.platform) {
122
+ if (typeof cmd !== "string" || cmd === "") return false;
123
+ // Backslashes become separators only where they ARE separators. On POSIX a
124
+ // backslash is an ordinary filename character, and rewriting it there could
125
+ // invent a match that the filesystem does not have.
126
+ let hay = platform === "win32" ? cmd.replace(/\\/g, "/") : cmd;
127
+ if (platform === "win32" || platform === "darwin") hay = hay.toLowerCase();
128
+ return SCRIPT_TAILS.some(tail => hay.includes(tail));
129
+ }
130
+
97
131
  /** An entry this deck put there: by its mark, or by the script it runs. */
98
132
  function isOurs(entry) {
99
133
  if (entry?.[MARK] === true) return true;
100
- return commandsOf(entry).some(cmd => OUR_SCRIPTS.some(p => cmd.includes(p)));
134
+ return commandsOf(entry).some(cmd => namesOurScript(cmd));
101
135
  }
102
136
 
103
137
  /** Anywhere in the file — not just `Stop` — that still runs one of our scripts.
@@ -109,7 +143,7 @@ function anythingStillNamesOurScripts(settings) {
109
143
  for (const group of Object.values(groups)) {
110
144
  if (!Array.isArray(group)) continue;
111
145
  for (const entry of group) {
112
- if (commandsOf(entry).some(cmd => OUR_SCRIPTS.some(p => cmd.includes(p)))) return true;
146
+ if (commandsOf(entry).some(cmd => namesOurScript(cmd))) return true;
113
147
  }
114
148
  }
115
149
  return false;