ccdeck 3.22.14 → 3.23.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.
Files changed (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +252 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -182
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +385 -51
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +495 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BdYsAtN8.js +0 -274
  51. package/dist/web/assets/index-CgwmcBHv.css +0 -1
package/bin/deck.js CHANGED
@@ -71,7 +71,10 @@ if (flags.version) {
71
71
  process.exit(0);
72
72
  }
73
73
 
74
- if (flags.uninstall) {
74
+ // `--purge` enters here too, and on its own. It is `--uninstall` plus the deck's
75
+ // own key, and a `--purge` that started a server instead would be a flag whose
76
+ // entire effect was silence.
77
+ if (flags.uninstall || flags.purge) {
75
78
  const { uninstallHooks, hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
76
79
  // Anything that could not be taken out. An uninstall that removed nothing
77
80
  // because it could not read the file has not uninstalled anything, and both
@@ -159,6 +162,55 @@ if (flags.uninstall) {
159
162
  if (hasCodexInstalled()) {
160
163
  report(await uninstallHooks({ provider: "codex" }), "Codex");
161
164
  }
165
+ // ── THE PRIVATE KEY ────────────────────────────────────────────────────────
166
+ //
167
+ // The one thing left on the disk that is a CREDENTIAL rather than data. Every
168
+ // deck paired with this one has pinned the key in prefs.json, and until #959
169
+ // nothing in this command — or in the `--help` text, or in the README's
170
+ // uninstall paragraph — named the file, so somebody who followed the
171
+ // instructions to the letter believed the machine was clean and it was not.
172
+ //
173
+ // NAMED, NOT REMOVED, unless somebody asked. prefs.json also holds the
174
+ // pairings, the aliases and which accounts this deck offers, and deleting a
175
+ // user's settings out from under a command documented as "hook entries only"
176
+ // is a different complaint of the same size. So the default is to say exactly
177
+ // where the key is — resolved for THIS machine, both copies, because
178
+ // `migrateDeckFiles` copies rather than moves and the old one is in a
179
+ // directory nothing calls the deck's state — and `--purge` is the sentence
180
+ // somebody types when they mean it.
181
+ {
182
+ const { keyDirs, findKeyFiles, purgeKeyFiles } =
183
+ await import(pathToFileURL(join(PKG_ROOT, "src/server/purge-key.mjs")).href);
184
+ const found = await findKeyFiles(keyDirs());
185
+ if (flags.purge) {
186
+ const { removed, failed } = await purgeKeyFiles(found);
187
+ for (const path of removed) console.log(`${PRODUCT}: removed ${path}`);
188
+ for (const f of failed) {
189
+ refused = true;
190
+ // `gDash`, not an em dash: top-level block, possibly the legacy Windows
191
+ // console (#797).
192
+ console.error(`${PRODUCT}: could NOT remove ${f.path} ${gDash} ${f.why}`);
193
+ console.error(`${PRODUCT}: this deck's LAN private key is still in that file.`);
194
+ }
195
+ if (removed.length === 0 && failed.length === 0) {
196
+ console.log(`${PRODUCT}: no ${PRODUCT} state to purge`);
197
+ }
198
+ } else {
199
+ // "no-key" files are not mentioned. Telling somebody their private key is
200
+ // on the machine when the file has none is the same defect as the silence,
201
+ // pointing the other way. "unknown" IS mentioned: an unparseable file is
202
+ // the one nobody can rule a key out of.
203
+ const holding = found.filter(f => f.holds !== "no-key");
204
+ if (holding.length > 0) {
205
+ console.log(`${PRODUCT}: this deck's LAN private key is still on this machine:`);
206
+ for (const f of holding) {
207
+ console.log(`${PRODUCT}: ${f.path}${f.holds === "unknown" ? ` (unreadable ${gDash} ${f.why})` : ""}`);
208
+ }
209
+ console.log(`${PRODUCT}: every deck you paired with has pinned that key. \`${INVOKED_AS ?? PRODUCT} --uninstall --purge\` removes the file(s) above.`);
210
+ }
211
+ }
212
+ }
213
+
162
214
  // The remedy last and once, after every symptom above it, rather than once
163
215
  // per refusal in the middle of the list.
164
216
  if (named.size > 0) {
@@ -191,6 +243,47 @@ if (flags.stop || flags.status || flags.logs || flags.install || flags.installSe
191
243
  const tone = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
192
244
  const say = (line) => process.stdout.write(`${line}\n`);
193
245
 
246
+ // WHAT THE ONE-SHOTS USED TO SWALLOW.
247
+ //
248
+ // reportUnknownFlags and reportIncompleteFlags live below this block and
249
+ // cannot be called from it — they reach for `G`, `P` and `write`, all
250
+ // declared a thousand lines down, so a call here is a temporal dead zone
251
+ // (the same reason the `--port` guard builds its own glyphs, #797). So the
252
+ // rows are written here, with this block's own tone.
253
+ //
254
+ // Without them `--help`'s closing promise — "Anything else on the command
255
+ // line is reported as an unknown option and then ignored" — held for a boot
256
+ // and for none of the nine one-shots: `ccdeck --status --prot 4317` printed
257
+ // the status and never mentioned the flag.
258
+ for (const token of flags.unknown ?? []) {
259
+ process.stderr.write(` ${tone.warn}${gWarn} unknown option${tone.reset} ${token} ${dash} see \`${INVOKED_AS ?? PRODUCT} --help\`\n`);
260
+ }
261
+ for (const { flag, expects } of flags.incomplete ?? []) {
262
+ process.stderr.write(` ${tone.warn}${gWarn} missing value${tone.reset} ${flag} ${dash} expected ${expects}\n`);
263
+ }
264
+
265
+ // AND `--stop` FAILS CLOSED ON A PORT IT CANNOT USE.
266
+ //
267
+ // The selector below reads "no usable port" and "no port asked for" as the
268
+ // same thing, and the second means every deck — so `ccdeck --stop --port
269
+ // 431x`, or `--port $UNSET`, ended every deck on the machine and exited 0.
270
+ // The guard that refuses a bad port sits ~270 lines below this block, which
271
+ // always exits before reaching it.
272
+ //
273
+ // A narrowing flag that fails open to "everything" is the wrong default for
274
+ // an off switch: failing closed costs a retype, failing open costs a deck
275
+ // somebody else was watching.
276
+ if (flags.stop) {
277
+ const askedPort = (flags.incomplete ?? []).some(x => x.flag === "--port" || x.flag === "-p");
278
+ const badPort = flags.port != null && !isPortValue(flags.port);
279
+ if (badPort || askedPort) {
280
+ const shown = badPort ? ` ${flags.port}` : "";
281
+ console.error(`${INVOKED_AS ?? PRODUCT}: --port${shown}: not a port number ${dash} expected 0-65535.`);
282
+ console.error(`${INVOKED_AS ?? PRODUCT}: refusing to stop every deck when you asked for one.`);
283
+ process.exit(1);
284
+ }
285
+ }
286
+
194
287
  const { deckDataDir, deckLogDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
195
288
 
196
289
  // ── --install ─────────────────────────────────────────────────────────────
@@ -309,7 +402,7 @@ if (flags.stop || flags.status || flags.logs || flags.install || flags.installSe
309
402
  process.exit(0);
310
403
  }
311
404
  const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-writer.mjs")).href);
312
- const { hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
405
+ const { hasCodexInstalled, codexHomeField } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
313
406
  const { hasClaudeInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
314
407
  const { liveDecks, sameShape } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
315
408
 
@@ -324,6 +417,12 @@ if (flags.stop || flags.status || flags.logs || flags.install || flags.installSe
324
417
  codex: hasCodexInstalled(),
325
418
  claude: hasClaudeInstalled(),
326
419
  };
420
+ // And the Codex tree, the one field of a start's shape that is not a flag. A
421
+ // bare start has passed it since #1123 and replaces a deck on another tree, so
422
+ // without it here every tree matched, and `--status` could mark as "opens this
423
+ // one" exactly the deck the next `ccdeck` stops (#1134). Worked out by the
424
+ // function the start and the discovery record use, so all three agree.
425
+ mine.codexHome = codexHomeField(mine.codex);
327
426
 
328
427
  // ── --logs ────────────────────────────────────────────────────────────────
329
428
  // What the deck wrote where a terminal would have shown it. Printed RAW,
@@ -437,7 +536,11 @@ if (flags.stop || flags.status || flags.logs || flags.install || flags.installSe
437
536
  say("");
438
537
  say(` ${tone.muted}${dash} ${left.length} other deck${left.length === 1 ? "" : "s"} still running:${tone.reset}`);
439
538
  for (const d of left) say(` ${tone.muted}pid ${d.pid} ${bullet} ${d.port} ${bullet} ${where(d)}${tone.reset}`);
440
- say(` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --stop --port <n>\` ends one ${bullet} \`--stop --all\` ends every deck${tone.reset}`);
539
+ // `--stop --all` used to be named here as the way to reach every deck. It
540
+ // is not: `--all` is a parsed no-op, and a bare `--stop` already ends every
541
+ // deck — which is why this line is only reachable after `--stop --port <n>`
542
+ // narrowed one. Naming the narrowing form is the half that is true.
543
+ say(` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --stop\` ends every deck ${bullet} \`--stop --port <n>\` ends one${tone.reset}`);
441
544
  }
442
545
  say("");
443
546
  process.exit(refused ? 1 : 0);
@@ -509,7 +612,18 @@ const { deckDataDir, deckLogDir, legacyDeckDir, migrateDeckFiles, sweepTempFiles
509
612
  // The litter an atomic write leaves when its process is killed between the
510
613
  // write and the rename. Nothing has ever swept it, because the code that
511
614
  // makes it is not running any more when it is made.
512
- await sweepTempFiles({ dirs: [legacy, data, log], fs: fsp }).catch(() => 0);
615
+ //
616
+ // THE WATCH'S OWN DIRECTORY IS NAMED SEPARATELY because the sweep does not
617
+ // recurse: it walks the directories it is handed and nothing under them, and
618
+ // `browser-watch/` is a subdirectory of `legacy`. Its temp files are the
619
+ // biggest ones the deck makes — about 2.5 MB each with a full 500-episode
620
+ // archive, by that module's own measurement — and until now they were outside
621
+ // the reach of the sweep that exists because ninety-seven of them were found
622
+ // in one directory, the oldest six days old.
623
+ const { storeDir: watchStoreDir } = await import(
624
+ pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
625
+ );
626
+ await sweepTempFiles({ dirs: [legacy, data, log, watchStoreDir()], fs: fsp }).catch(() => 0);
513
627
  }
514
628
  // CANONICALISED here rather than left as typed: the discovery file publishes
515
629
  // this path so the hook can tell which decks share one log and elect a single
@@ -529,7 +643,7 @@ const persist = flags.noPersist
529
643
  // share a path still elect one writer for it.
530
644
  : canonicalLogPath(flags.history ?? join(deckLogDir(), "events.jsonl"));
531
645
 
532
- const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled } =
646
+ const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled, leftoverCodexHooks, codexHomeField } =
533
647
  await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
534
648
  // CODEX_SESSIONS_DIR comes along because the banner below names the directory
535
649
  // the watcher tails, and the watcher lives in that module. Recomputing the path
@@ -573,6 +687,11 @@ const wantCodex = flags.noCodex
573
687
  const wantClaude = flags.noClaude
574
688
  ? false
575
689
  : (flags.claude === true || hasClaudeInstalled());
690
+ // The Codex tree this deck would tail, spelled the way its discovery record will
691
+ // spell it — the one field of a start's shape that is not a flag. Resolved here,
692
+ // with the other three, because the second-start question below is asked of
693
+ // nothing but inputs already settled. A --no-codex start reads no tree: null.
694
+ const codexHome = codexHomeField(wantCodex);
576
695
 
577
696
  const WEB_DIST = join(PKG_ROOT, "dist", "web", "index.html");
578
697
  if (!existsSync(WEB_DIST)) {
@@ -642,7 +761,7 @@ process.on("SIGHUP", () => { showCursor(); dieOfSignal("SIGHUP"); });
642
761
  // into each string as trailing spaces, so any new row, or any label a character
643
762
  // longer, silently broke the alignment of every other one.
644
763
  const LABELS = [
645
- "workspace", "Claude hooks", "Codex sessions", "claude-swap", "accounts",
764
+ "workspace", "Claude hooks", "Codex sessions", "Codex hooks", "claude-swap", "accounts",
646
765
  "ccusage", "update", "name", "server ready", "log", "unknown option",
647
766
  "missing value",
648
767
  ];
@@ -791,6 +910,27 @@ function startupWork() {
791
910
  return { cs, seed: await seedFirstAccount().catch(() => ({ state: "failed" })) };
792
911
  })().catch(() => null);
793
912
 
913
+ // WHEN THE TOOL IS QUIET, which is not the same as when the job above settles
914
+ // (#1043). The auto-switch drives this same claude-swap — every tick is
915
+ // `cswap auto --once`, which moves the user's live Claude credentials — and
916
+ // the server arms it the moment the port binds, while this job is still in
917
+ // ensureCswap. So the server holds every tick until this resolves, and it has
918
+ // two halves because the job covers only the first: ensureCswap answers
919
+ // "upgrading" as soon as it has FIRED an upgrade it deliberately does not
920
+ // await, and that upgrade is a uv or pipx environment being rewritten for
921
+ // tens of seconds after this job has returned. upgradeSettled() is the handle
922
+ // on it, and null when nothing was started.
923
+ //
924
+ // Not markDeckReady, the other place "the boot is over" gets said:
925
+ // reportStartup stops waiting for claude-swap the moment the install
926
+ // announces itself, so the deck is marked ready with the install still
927
+ // running. Never rejects — a job that failed has nothing left running.
928
+ const cswapQuiet = cswap.then(async () => {
929
+ if (!wantClaude) return;
930
+ const { upgradeSettled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
931
+ await upgradeSettled();
932
+ }).catch(() => {});
933
+
794
934
  // ccusage backs the usage-history modal. Primed at boot rather than on first
795
935
  // open so a cold machine pays the install while the deck is still starting.
796
936
  // Nothing is lost by skipping the prime: runCcusage falls back to npx, so the
@@ -814,7 +954,13 @@ function startupWork() {
814
954
  new Promise(r => setTimeout(() => r(null), 1200)),
815
955
  ]);
816
956
 
817
- return { hooks, cswap, cswapInstalling, ccusage, update };
957
+ // An older deck's Codex forwarders, looked for and never touched — see
958
+ // leftoverCodexHooks for why a boot names them rather than removing them.
959
+ // Asked with --no-codex too: the entries fire whether or not this deck is
960
+ // watching the rollouts, and one file read is the whole cost.
961
+ const codexHooks = leftoverCodexHooks().catch(() => null);
962
+
963
+ return { hooks, cswap, cswapInstalling, cswapQuiet, ccusage, update, codexHooks };
818
964
  }
819
965
 
820
966
  /** The same work, said out loud, in a fixed order — a boot whose rows arrive in
@@ -872,6 +1018,27 @@ async function reportStartup(jobs) {
872
1018
  write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
873
1019
  }
874
1020
 
1021
+ // #983. A machine that ran a deck from before #253 still has that deck's
1022
+ // forwarders in Codex's hooks.json, and nothing but `--uninstall` ever looked.
1023
+ // Where Codex honours the file, each one posts the session to /api/event while
1024
+ // the watcher above reads the same session off disk, so it arrives twice. Both
1025
+ // copies go into the ring and events.jsonl; the reducer's two-second
1026
+ // redelivery windows fold some of them on the card and not others, and a
1027
+ // duplicate that shows only sometimes reads as a reducer bug, which is where
1028
+ // it would otherwise be chased. Saying nothing was the one answer the issue
1029
+ // ruled out. So: one row, in the place the deck already talks about Codex,
1030
+ // naming the file and the command. `keep`, because a remedy cut off by an
1031
+ // ellipsis is no remedy. The clause about arriving twice is said only while
1032
+ // the watcher runs, since without it the forwarders are the only copy.
1033
+ const leftover = await jobs.codexHooks;
1034
+ if (leftover) {
1035
+ const twice = wantCodex ? ", so Codex sessions can arrive twice" : "";
1036
+ write(row({
1037
+ mark: G.warn, tone: P.warn, label: "Codex hooks", keep: true,
1038
+ detail: `left by an older deck in ${fileLink(leftover.settingsPath)}${twice} ${G.dash} \`${INVOKED_AS ?? PRODUCT} --uninstall\` takes them out`,
1039
+ }));
1040
+ }
1041
+
875
1042
  // Bounded, because this is the job that made a first boot look hung: on a
876
1043
  // machine with neither claude-swap nor a Python toolchain it fetches a uv
877
1044
  // binary and then builds an environment with it, and the report used to wait
@@ -969,7 +1136,13 @@ function writeSwapRows(swap, { late = false } = {}) {
969
1136
  ? `installed via ${cs.via} but not on PATH ${G.dash} add ${
970
1137
  process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
971
1138
  }`
972
- : `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
1139
+ // The deck asked for one version and a different one answered, so it
1140
+ // declines to drive it. Naming both is the whole content of the row:
1141
+ // "install failed" would be a lie about an install that succeeded, and
1142
+ // the number that arrived is the thing a person needs to see.
1143
+ : cs?.reason === "unexpected_version"
1144
+ ? `refused v${cs.version} ${G.dash} asked for ${cs.want}, not driving it`
1145
+ : `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
973
1146
  out += row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how });
974
1147
  // A URL is not an answer when someone just wants the panel to work. Print
975
1148
  // the command for THIS machine, picked from what is already on it.
@@ -1183,6 +1356,28 @@ let discoveryFile = null;
1183
1356
  // this deck is registered, and given back by the exit handler on every way out
1184
1357
  // before that. See src/server/boot-lock.mjs.
1185
1358
  let bootLock = null;
1359
+ // HERE, not at the bottom with SIGINT and SIGTERM, and this line is the whole
1360
+ // of the fix for #980.
1361
+ //
1362
+ // This file is an ES module with top-level `await`, so its statements run in
1363
+ // source order and a handler registered at the bottom does not exist until the
1364
+ // boot has got there. Three `process.exit()` calls sit between the gate below
1365
+ // and that point — the yield when another deck came up first, the ATTACH that
1366
+ // every `ccdeck` typed beside a running deck takes, and the exit of a boot
1367
+ // whose server could not bind — and the explicit release further down is on the
1368
+ // one path none of them take. So the handler that was written to cover "every
1369
+ // way out" covered only the ways out it was already past.
1370
+ //
1371
+ // What the leftover file costs is a start that has to wait the lock out: the
1372
+ // next `ccdeck` finds it, and recovers through `!alive(holder.pid)` — unless
1373
+ // the OS has recycled that pid onto a live process, which Windows does
1374
+ // routinely. Then the only way past is BOOT_LOCK_STALE_MS measured from when
1375
+ // the lock was WRITTEN, so a start a few seconds after an attach polls for the
1376
+ // remainder of thirty before it can so much as ask whether a deck is up.
1377
+ //
1378
+ // Synchronous, because `exit` allows nothing else, and a no-op once the lock
1379
+ // has been given back at the registration below.
1380
+ process.on("exit", () => { bootLock?.release(); });
1186
1381
 
1187
1382
  // This worker does not outlive the supervisor that started it (#702).
1188
1383
  //
@@ -1267,7 +1462,7 @@ bootLock = await takeBootLock({ dir: deckRegistryDir() }).catch(() => null);
1267
1462
  const askedPort = flags.port != null && isPortValue(flags.port) ? Number(flags.port) : null;
1268
1463
  const plan = secondStart({
1269
1464
  live: await liveDecks().catch(() => []),
1270
- want: { workspace, persist, codex: wantCodex, claude: wantClaude },
1465
+ want: { workspace, persist, codex: wantCodex, claude: wantClaude, codexHome },
1271
1466
  port: askedPort,
1272
1467
  ours: PKG_VERSION,
1273
1468
  fresh: flags.new === true,
@@ -1346,6 +1541,15 @@ bootLock = await takeBootLock({ dir: deckRegistryDir() }).catch(() => null);
1346
1541
  }
1347
1542
  }
1348
1543
 
1544
+ // The one fact the auto-switch has to wait for and the server cannot see for
1545
+ // itself: that the claude-swap it drives is not being installed or upgraded
1546
+ // underneath it (#1043). startupWork is what knows, and it has not run yet — it
1547
+ // runs beside the server rather than before it (#483) — so the server is handed
1548
+ // a promise here and startupWork settles it below. A respawn runs no startup
1549
+ // work and installs nothing, so there it settles at once. See cswapQuiet.
1550
+ let settleCswap;
1551
+ const cswapQuiet = new Promise(r => { settleCswap = r; });
1552
+
1349
1553
  const starting = startServer({
1350
1554
  port, persist, workspace, codex: wantCodex, claude: wantClaude,
1351
1555
  // Withheld when nothing is supervising us: without a parent, exiting is just
@@ -1357,6 +1561,8 @@ const starting = startServer({
1357
1561
  // shutdown() is a hoisted declaration precisely so it is callable from the
1358
1562
  // first instruction of this module — see the long note beside it (#448).
1359
1563
  onStop: () => shutdown(0),
1564
+ // Every auto-switch tick waits on this until claude-swap is quiet — see above.
1565
+ cswapQuiet,
1360
1566
  }).then(s => ({ ok: true, s }), err => ({ ok: false, err }));
1361
1567
 
1362
1568
  // Once-per-session setup — hook install, tool probes, registry lookups, and the
@@ -1365,8 +1571,11 @@ const starting = startServer({
1365
1571
  // restart that feels instant and one that makes you wonder whether it worked.
1366
1572
  if (!RESPAWN) {
1367
1573
  const jobs = startupWork();
1574
+ jobs.cswapQuiet.then(settleCswap);
1368
1575
  await printBanner();
1369
1576
  await reportStartup(jobs);
1577
+ } else {
1578
+ settleCswap();
1370
1579
  }
1371
1580
 
1372
1581
  // Usually settled long ago by the time we get here, which is the point: `step`
@@ -1507,9 +1716,6 @@ if (!RESPAWN) {
1507
1716
  script: join(PKG_ROOT, "bin", "agent-dag.js"),
1508
1717
  logPath: join(deckLogDir(), "deck.log"),
1509
1718
  product: PRODUCT,
1510
- // The directories that decide WHICH deck this is, so the one started at
1511
- // login is the one started from this shell — see scopeEnv.
1512
- serviceEnv: svc.scopeEnv(process.env),
1513
1719
  });
1514
1720
  svc.writeServiceRecord(deckDataDir(), out.ok
1515
1721
  ? { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path }
@@ -1661,6 +1867,25 @@ async function shutdown(code = 0) {
1661
1867
  if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
1662
1868
  write(`\n\n ${P.warn}${G.stop} shutting down${G.ellipsis}${P.reset}\n`);
1663
1869
  }
1870
+ // WHAT THIS DECK STARTED, ENDED WITH IT. Every deadline in exec.mjs lives
1871
+ // in a timer in THIS process — `run` states the outcome and only then kills
1872
+ // — so an exit that reaps nothing leaves the hung tool a deadline exists
1873
+ // for with nothing left anywhere that will ever stop it. Measured on a
1874
+ // sandboxed deck (#1012): SIGINT at 05:26:22.706, deck gone within 200ms,
1875
+ // and its `claude --print /usage` child — spawned at 05:26:18.078 under a
1876
+ // 15-second deadline — still running at 05:26:35.204, reparented to init.
1877
+ // A whole Claude Code process, orphaned by a Ctrl+C.
1878
+ //
1879
+ // FIRST, so it is ahead of both exits below, and cheap enough to be: a
1880
+ // POSIX signal lands synchronously, and on Windows killTree's taskkill is
1881
+ // already a running process by the time spawn() returns and outlives this
1882
+ // one. No corpse is waited for, and the only await is a module already in
1883
+ // the cache — anything that spawned a child loaded exec.mjs to do it — so
1884
+ // the ~200ms exit that is the rest of shutdown's good behaviour stays that.
1885
+ try {
1886
+ const { killLiveChildren } = await import(pathToFileURL(join(PKG_ROOT, "src/server/exec.mjs")).href);
1887
+ killLiveChildren();
1888
+ } catch { /* exec.mjs never loaded, so nothing was ever spawned */ }
1664
1889
  // Stopped first, always: a tick landing after the unlink would re-register a
1665
1890
  // deck that is on its way out, and leave the file behind for the hooks to
1666
1891
  // find once nothing is listening.
@@ -1679,6 +1904,18 @@ async function shutdown(code = 0) {
1679
1904
  await discovery?.stop();
1680
1905
  if (discoveryFile) await removeDiscovery(discoveryFile);
1681
1906
  } catch { /* the sweep at the next boot gets it */ }
1907
+ // THE LINES WE ALREADY SAID WE HAD. /api/event answers {ok:true, seq}
1908
+ // before the append lands — right for the hook, which holds a 1.9s cap and
1909
+ // must not wait on a filesystem — so an exit that waits only for the
1910
+ // listener abandons whatever is still queued. Measured: 40 posts
1911
+ // acknowledged, 12 written. The restart path is the one that costs most,
1912
+ // since the replacement deck rebuilds its canvas from the log before it
1913
+ // binds. Bounded inside drainAppends, so a wedged filesystem cannot hold
1914
+ // the exit; a deadline reached is the old behaviour.
1915
+ try {
1916
+ const { drainAppends } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-writer.mjs")).href);
1917
+ await drainAppends();
1918
+ } catch { /* nothing queued, or the module never loaded */ }
1682
1919
  // No server yet means nothing to drain and nothing to hand the port over to,
1683
1920
  // so the exit is the whole of the shutdown.
1684
1921
  if (!server) return process.exit(code);
@@ -1696,11 +1933,10 @@ async function shutdown(code = 0) {
1696
1933
  process.on("SIGINT", () => shutdown(0));
1697
1934
  process.on("SIGTERM", () => shutdown(0));
1698
1935
  process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDiscovery(discoveryFile); });
1699
- // Every way out, including the attach and the boot that fails before it
1700
- // registers: a lock left behind would hold the next start for thirty seconds
1701
- // before it could judge the holder gone. Synchronous, because `exit` allows
1702
- // nothing else, and a no-op once the lock has been given back.
1703
- process.on("exit", () => { bootLock?.release(); });
1936
+ // The boot lock's `exit` handler is NOT here with its siblings. It is armed up
1937
+ // beside `let bootLock = null;`, above the start gate, because the three exits
1938
+ // this file takes before reaching this line are the ones it exists for — see
1939
+ // the note there (#980).
1704
1940
 
1705
1941
  // ── helpers ───────────────────────────────────────────────────────────────────
1706
1942
 
@@ -1807,8 +2043,9 @@ Options:
1807
2043
  --uninstall-service Stop starting at login. \`--uninstall\` does this too
1808
2044
  --workspace <path> Only capture sessions whose cwd is inside <path>
1809
2045
  --scope Restrict to current working directory
1810
- --all Capture every session (default). Beside --stop it
1811
- means every deck rather than every session
2046
+ --all Capture every session (default). Accepted and
2047
+ ignored — it is what a bare run already does, and
2048
+ \`--stop --port <n>\` is the only way to narrow a stop
1812
2049
  --history <path> Override events log file (default: this platform's log directory)
1813
2050
  --no-persist Don't write or replay events log (RAM-only)
1814
2051
  --codex Force-enable Codex capture even if ~/.codex/ missing
@@ -1817,8 +2054,14 @@ Options:
1817
2054
  --no-claude Skip Claude entirely: no hooks, no claude-swap, no accounts panel
1818
2055
  --uninstall Remove ${PRODUCT}'s hooks from ~/.claude/settings.json and
1819
2056
  ~/.codex/hooks.json, and restore any sound hooks of yours it parked.
1820
- Hook entries only: the forwarder script, ~/.claude/agent-dag/,
1821
- the events log, ~/.agents-deck/ and claude-swap all stay
2057
+ Hook entries only: the forwarder script under
2058
+ ~/.claude/agent-dag/, the deck's own state and log
2059
+ directories (\`--status\` names them), ~/.agents-deck/
2060
+ and claude-swap all stay. It NAMES the prefs.json
2061
+ files that still hold this deck's LAN private key
2062
+ --purge With --uninstall: delete those prefs.json files too, so the
2063
+ private key every deck you paired with has pinned does not
2064
+ outlive the uninstall. Your ${PRODUCT} settings go with them
1822
2065
  -h, --help Show this help
1823
2066
  -v, --version Print the version and exit
1824
2067
 
@@ -0,0 +1 @@
1
+ import{r as l,j as e,u as se,h as te,s as ne,i as ae}from"./index-Dsca2TwS.js";import{S as ve,k as ye}from"./index-Dsca2TwS.js";function ie(n){const i=/^#?([0-9a-f]{6})$/i.exec((n??"").trim());if(!i)return!1;const o=parseInt(i[1],16),r=[o>>16&255,o>>8&255,o&255].map(b=>{const t=b/255;return t<=.03928?t/12.92:Math.pow((t+.055)/1.055,2.4)});return .2126*r[0]+.7152*r[1]+.0722*r[2]>.5}const V=1e4,re=12e3;function oe({browsers:n,findings:i,watching:o,palette:r}){const b=l.useRef(null),t=l.useRef(null);return t.current={browsers:n,findings:i,watching:o},l.useEffect(()=>{var L;const w=b.current;if(!w)return;const a=w.getContext("2d");if(!a)return;const v=((L=window.matchMedia)==null?void 0:L.call(window,"(prefers-reduced-motion: reduce)").matches)??!1;let y=0,d=!1;const N=()=>{var H;if(d)return;const W=t.current;if(!W)return;const{browsers:D,findings:U,watching:O}=W,B=r["--accent"]||"#7dd3fc",G=r["--line"]||"#1f2229",_=r["--text-dim"]||"#6e727c",P=ie(r["--bg"]),F=P?42:26,J=P?.85:.65,A=Date.now(),k=Math.min(window.devicePixelRatio||1,2),M=w.clientWidth,R=w.clientHeight;(w.width!==M*k||w.height!==R*k)&&(w.width=M*k,w.height=R*k),a.setTransform(k,0,0,k,0,0),a.clearRect(0,0,M,R);const m=M/2,g=R/2,p=Math.min(M,R)/2-6;a.strokeStyle=G,a.lineWidth=1,a.globalAlpha=P?1:.9;for(const h of[.4,.7,1])a.beginPath(),a.arc(m,g,p*h,0,Math.PI*2),a.stroke();a.beginPath(),a.moveTo(m-p,g),a.lineTo(m+p,g),a.moveTo(m,g-p),a.lineTo(m,g+p),a.stroke(),a.globalAlpha=1;const j=(O&&!v?A%V/V:0)*Math.PI*2-Math.PI/2;if(O){const h=(H=a.createConicGradient)==null?void 0:H.call(a,j-.85,m,g);h&&(h.addColorStop(0,"transparent"),h.addColorStop(.85/(Math.PI*2),`color-mix(in srgb, ${B} ${F}%, transparent)`),h.addColorStop(1,"transparent"),a.fillStyle=h,a.beginPath(),a.moveTo(m,g),a.arc(m,g,p,j-.85,j),a.closePath(),a.fill()),a.strokeStyle=B,a.globalAlpha=J,a.beginPath(),a.moveTo(m,g),a.lineTo(m+Math.cos(j)*p,g+Math.sin(j)*p),a.stroke(),a.globalAlpha=1}D.forEach((h,S)=>{const $=S/Math.max(D.length,1)*Math.PI*2-Math.PI/2,T=h.lastReadMs===null?1:Math.min(1,(A-h.lastReadMs)/12e4),E=p*(.42+T*.5),C=m+Math.cos($)*E,I=g+Math.sin($)*E,q=Math.abs((j-$+Math.PI*3)%(Math.PI*2)-Math.PI),K=O?Math.max(0,1-q/.55):0;a.beginPath(),a.arc(C,I,3+K*1.6,0,Math.PI*2),a.fillStyle=h.running?B:_,a.globalAlpha=h.running?.55+K*.45:.35,a.fill(),a.globalAlpha=1});for(const h of U){const S=(A-h.atMs)/re;if(S<0||S>1)continue;const T=Math.max(0,D.findIndex(I=>I.key===h.browser))/Math.max(D.length,1)*Math.PI*2-Math.PI/2,E=m+Math.cos(T)*p*.6,C=g+Math.sin(T)*p*.6;a.beginPath(),a.arc(E,C,4+S*22,0,Math.PI*2),a.strokeStyle=B,a.globalAlpha=(1-S)*.8,a.lineWidth=1.5,a.stroke(),a.globalAlpha=1}y=requestAnimationFrame(N)};return N(),()=>{d=!0,cancelAnimationFrame(y)}},[r]),e.jsx("canvas",{ref:b,className:"bw-radar",role:"img","aria-label":o?`Watching ${n.length} browser${n.length===1?"":"s"}`:"Not watching"})}function le(n,i,o){if(n===null)return null;const r=n+i-o;return r>0?r:null}function ce(n){const i=Math.ceil(n/1e3),o=Math.floor(i/60),r=i%60;return o>0?`${o}m ${r}s`:`${r}s`}function Z(n){return(n??[]).filter(i=>i.installed&&i.profiles>0)}function z(n,i){return i?{kind:"saving",word:"Saving",detail:""}:n.settings.enabled?{kind:"on",word:"Watching",detail:""}:{kind:"off",word:"Paused",detail:"nothing new is recorded"}}function de(n){return!Number.isFinite(n)||n<=0?"empty":n<1024?`${Math.round(n)} B`:n<1024*1024?`${Math.max(1,Math.round(n/1024))} KB`:`${(n/1024/1024).toFixed(1)} MB`}function he(n,i){return n.map(o=>({key:o.key,name:o.name,running:o.running,visits:i.filter(r=>r.browser===o.key).reduce((r,b)=>r+b.programVisits,0)}))}function ue(n,i){if(n===null)return null;const o=Math.max(0,Math.round((i-n)/1e3));if(o<5)return"just now";if(o<60)return`${o} sec ago`;const r=Math.round(o/60);return r<60?`${r} min ago`:`${Math.round(r/60)} hr ago`}function we(n){const i=n.profiles.filter(r=>r.degraded);if(i.length>0){const r=i[0],b=i.length-1;return{kind:"unreadable",text:`${r.name}/${r.profile} could not be read — ${r.reason??"no reason given"}`+(b>0?`, and ${b} more`:"")+". The watch continues on the profiles it can read."}}const o=Z(n.browsers);return o.length===0?{kind:"no-profiles",text:"No browser on this machine has a profile to read. Open one of the browsers below once and it will be watched from then on."}:o.some(r=>r.running)?null:{kind:"none-running",text:"No watched browser is running, so nothing can be navigated right now. Their history is still read, and anything found while they were open is still here."}}function me(n){const i=o=>new Date(o).toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit",hour12:!1});return n.count===1||n.endMs-n.startMs<6e4?i(n.startMs):`${i(n.startMs)} → ${i(n.endMs)}`}function pe(n){return new Date(n).toLocaleDateString(void 0,{weekday:"short",day:"numeric",month:"short"})}function X(n){const i=Math.round((n.endMs-n.startMs)/6e4);return i<1?"":`${i} min`}function fe({onClose:n,onSeen:i,onWatching:o,palette:r}){const b=se(n),[t,w]=l.useState(null),[a,v]=l.useState(null),[y,d]=l.useState(null),[N,L]=l.useState(!1),W=l.useRef(!1),[D,U]=l.useState(null),[O,B]=l.useState(null),[G,_]=l.useState(!1),P=l.useRef(!1),[F,J]=l.useState(!1),[A,k]=l.useState(!1),[M,R]=l.useState(!1),[m,g]=l.useState(!1),[p,Y]=l.useState(!1),[j,H]=l.useState(()=>Date.now());l.useEffect(()=>{const s=setInterval(()=>H(Date.now()),1e3);return()=>clearInterval(s)},[]);const h=l.useCallback(async s=>{var c,x;if(!(s&&!te(W.current))){W.current=s,L(!0);try{const u=await fetch(`/api/browser-watch${s?"?refresh=1":""}`);if(!u.ok)throw new Error(`the deck answered ${u.status}`);const f=await u.json();w(f),P.current||U(((c=f==null?void 0:f.settings)==null?void 0:c.quietMinutes)??15),o(((x=f==null?void 0:f.settings)==null?void 0:x.enabled)===!0),v(null)}catch(u){v(u instanceof Error?u.message:String(u))}finally{W.current=!1,L(!1)}}},[o]);l.useEffect(()=>{h(!1)},[h]),l.useEffect(()=>{const s=setInterval(()=>{h(!1)},1e4);return()=>clearInterval(s)},[h]);const S=l.useCallback(async s=>{try{await fetch("/api/browser-watch/dismiss",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({host:s.host,startMs:s.startMs})})}finally{await h(!1)}},[h]),$=l.useCallback(async s=>{P.current=!0,_(!0);try{const c=await fetch("/api/browser-watch",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(s)});if(!c.ok)throw new Error(`the deck answered ${c.status}`);await h(!1),d(null)}catch(c){d(c instanceof Error?c.message:String(c))}finally{P.current=!1,_(!1)}},[h]),T=l.useRef(i);T.current=i,l.useEffect(()=>()=>T.current(Date.now()),[]);const E=l.useMemo(()=>{const s=[];for(const c of(t==null?void 0:t.episodes)??[]){const x=pe(c.startMs),u=s[s.length-1];u&&u.label===x?u.episodes.push(c):s.push({label:x,episodes:[c]})}return s},[t]),C=Z(t==null?void 0:t.browsers),I=((t==null?void 0:t.browsers)??[]).filter(s=>!(s.installed&&s.profiles>0)),q=t?we(t):null,K=C.reduce((s,c)=>s+c.profiles,0),Q=t?le(t.coverage.lastHumanMs,t.coverage.quietMs,j):null;return e.jsx("div",{className:"modal-backdrop",onClick:n,role:"presentation",children:e.jsxs("div",{ref:b,className:"modal bw-modal",onClick:s=>s.stopPropagation(),role:"dialog","aria-modal":"true","aria-labelledby":"bw-title",children:[e.jsxs("header",{className:"modal-head",children:[e.jsxs("div",{className:"modal-title",children:[e.jsx("span",{className:"modal-tool-name",id:"bw-title",children:"Browser watch"}),e.jsx("span",{className:"modal-tool-id",children:t?`${K} ${K===1?"profile":"profiles"} · local only`:"local only"})]}),e.jsxs("div",{className:"modal-actions",children:[e.jsx("button",{className:"glyph-btn",onClick:()=>void h(!0),...ne(N),"aria-label":"Re-read every profile now",title:"Re-read every profile now",children:N?"…":"↻"}),e.jsx("button",{className:"glyph-btn",onClick:n,"aria-label":"Close (Esc)",title:"Close (Esc)",children:"×"})]})]}),e.jsxs("div",{className:"modal-body bw-body",children:[!t&&!a&&e.jsxs("div",{className:"bw-loading",children:[e.jsx("span",{className:"bw-loading-bar","aria-hidden":!0}),e.jsx("p",{children:"Reading each browser's history. The first look copies the file, which takes a moment."})]}),a&&e.jsx("div",{className:"bw-state",children:e.jsxs("div",{className:"bw-row err",children:[e.jsx("span",{className:"bw-dot","aria-hidden":!0}),e.jsx("span",{className:"bw-row-label",children:"Could not read"}),e.jsx("span",{className:"bw-row-detail",children:a})]})}),t&&q&&e.jsx("p",{className:`bw-trouble ${q.kind}`,role:"status",children:q.text}),t&&e.jsxs("div",{className:"bw-work",children:[e.jsxs("aside",{className:"bw-side",children:[e.jsxs("section",{className:"bw-sec",children:[e.jsxs("h4",{className:"bw-sec-head",children:["Activity overview",e.jsx("button",{className:"bw-help",onClick:()=>R(s=>!s),"aria-expanded":M,"aria-label":"What the sweep and the dots mean",children:"?"})]}),e.jsxs("div",{className:"bw-glance",children:[e.jsx(oe,{browsers:C.map(s=>{var c;return{key:s.key,name:s.name,running:s.running,lastReadMs:((c=t.profiles.find(x=>x.browser===s.key))==null?void 0:c.lastWrittenMs)??null}}),findings:t.episodes.slice(0,6).map(s=>({browser:s.browser??null,atMs:s.endMs})),watching:t.settings.enabled,palette:r}),e.jsx("ul",{className:"bw-counts",children:he(C,t.profiles).map(s=>e.jsxs("li",{children:[e.jsx("span",{className:"bw-count-n",children:s.visits.toLocaleString("en-US")}),e.jsx("span",{className:"bw-count-of",children:s.name})]},s.key))})]}),e.jsx("p",{className:"bw-since",children:"Pages a program opened, since this deck started"}),t.coverage.checkedMs>0&&e.jsxs("p",{className:"bw-since",children:["Last checked ",ue(t.coverage.checkedMs,j),t.coverage.checks>0&&e.jsxs(e.Fragment,{children:[" · ",t.coverage.checks.toLocaleString("en-US")," ",t.coverage.checks===1?"check":"checks"]})]}),Q!==null&&e.jsxs("p",{className:"bw-gate",children:["You are browsing — a program page would count in ",ce(Q)]}),M&&e.jsxs("dl",{className:"bw-key",children:[e.jsx("dt",{children:"Sweep"}),e.jsx("dd",{children:"one poll of every watched profile"}),e.jsx("dt",{children:"Dot"}),e.jsx("dd",{children:"a browser, further out the longer since it last wrote history"}),e.jsx("dt",{children:"Ring"}),e.jsx("dd",{children:"a finding, leaving the browser it came from"})]})]}),e.jsxs("section",{className:"bw-sec",children:[e.jsxs("h4",{className:"bw-sec-head",children:["Watched profiles",e.jsx("button",{className:"bw-help",onClick:()=>g(s=>!s),"aria-expanded":m,"aria-label":"What running, idle and connected to Anthropic mean",children:"?"})]}),m&&e.jsxs("dl",{className:"bw-key",children:[e.jsx("dt",{children:"Running"}),e.jsx("dd",{children:"the browser has a process on this machine right now"}),e.jsx("dt",{children:"Connected"}),e.jsx("dd",{children:"an open connection to an address Anthropic's relay uses — which it shares with claude.ai, so an open tab looks the same as an agent channel"})]}),e.jsx("ul",{className:"bw-profiles",children:C.map(s=>e.jsxs("li",{children:[e.jsx("span",{className:`bw-prof-dot${s.running?" on":""}`,"aria-hidden":!0}),e.jsx("span",{className:"bw-prof-name",children:s.name}),e.jsx("span",{className:"bw-prof-state",children:s.running?"running":"idle"}),s.running&&s.relay.state==="live"&&e.jsx("span",{className:"bw-relay",children:"connected to Anthropic"})]},s.key))}),I.length>0&&e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:"bw-why bw-rest-head",onClick:()=>Y(s=>!s),"aria-expanded":p,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:p?"▾":"▸"})," ",I.length," not watched"]}),p&&e.jsxs("p",{className:"bw-rest",children:[I.map(s=>s.name).join(", ")," — not installed, or installed and never opened, so there is no history to read."]})]})]}),t.relay&&e.jsx(be,{relay:t.relay})]}),e.jsxs("div",{className:"bw-main",children:[e.jsxs("section",{className:"bw-findings",children:[e.jsxs("h4",{className:"bw-sec-head bw-head-row",children:["Findings",t.episodes.length>0&&e.jsxs("span",{className:"bw-feed-count",children:[t.episodes.length," ",t.episodes.length===1?"episode":"episodes"]})]}),e.jsx("div",{className:"bw-eps",children:E.length===0?e.jsxs("div",{className:"bw-empty",children:[e.jsx("p",{className:"bw-empty-head",children:"Nothing found yet"}),e.jsxs("p",{className:"bw-empty-note",children:["An episode lands here when a program opens pages in a browser nobody has touched for"," ",t.settings.quietMinutes," ",t.settings.quietMinutes===1?"minute":"minutes",". On most machines that is rare, so an empty list is the ordinary result rather than a sign something is wrong."]}),!t.settings.enabled&&e.jsx("p",{className:"bw-empty-note",children:"Watching is off, so this shows only what this deck has seen since it started. Anything an earlier run recorded comes back when you switch it on."})]}):E.map(s=>e.jsxs("div",{className:"bw-day",children:[e.jsx("h4",{className:"bw-sec-head",children:s.label}),s.episodes.map(c=>{const x=`${c.host}-${c.startMs}`,u=O===x;return e.jsxs("div",{className:`bw-ep${u?" open":""}`,children:[e.jsxs("div",{className:"bw-ep-row",children:[e.jsxs("button",{className:"bw-ep-head",onClick:()=>B(u?null:x),"aria-expanded":u,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:u?"▾":"▸"}),e.jsx("span",{className:"bw-ep-host",children:c.host}),e.jsxs("span",{className:"bw-ep-meta",children:[me(c),X(c)&&e.jsxs(e.Fragment,{children:[" · ",X(c)]})]}),e.jsxs("span",{className:"bw-ep-count",children:[c.count," ",c.count===1?"page":"pages"]})]}),e.jsx("button",{className:"glyph-btn bw-ep-x",onClick:()=>void S(c),"aria-label":`Dismiss ${c.host}`,title:"Dismiss — it leaves this list for good, and stays in the log file",children:"×"})]}),u&&e.jsx("ul",{className:"bw-urls",children:c.urls.map((f,ee)=>e.jsxs("li",{children:[e.jsx("span",{className:"bw-url-time",children:new Date(f.timeMs).toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit",hour12:!1})}),e.jsx("span",{className:"bw-url",children:f.url})]},`${f.url}-${f.timeMs}-${ee}`))})]},x)})]},s.label))})]}),e.jsxs("section",{className:"bw-feed",children:[e.jsxs("h4",{className:"bw-sec-head bw-head-row",children:["Live activity",e.jsxs("span",{className:"bw-feed-count",children:[t.log.length," ",t.log.length===1?"event":"events"]})]}),e.jsx("p",{className:"bw-since bw-feed-note",children:"Pages you opened yourself — counted to run the rule, never written down. Addresses appear under Findings, and only for pages a program opened."}),e.jsx("div",{className:"bw-log","aria-live":"polite","aria-relevant":"additions",children:t.log.length===0?e.jsx("p",{className:"bw-note",children:"The deck writes a line here each time it looks at a profile."}):t.log.map(s=>e.jsxs("div",{className:`bw-log-line ${s.level}${s.parts?"":" sys"}`,children:[e.jsx("span",{className:"bw-log-time",children:new Date(s.atMs).toLocaleTimeString("en-GB",{hour12:!1})}),s.parts?e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"bw-log-browser",children:s.parts.browser}),e.jsx("span",{className:"bw-log-profile",title:s.parts.profile,children:s.parts.profile}),e.jsxs("span",{className:"bw-log-value",children:[s.parts.value,s.parts.flagged>0&&e.jsxs("span",{className:"bw-log-flag",children:[" · ",s.parts.flagged," flagged"]})]})]}):e.jsx("span",{className:"bw-log-sys",children:s.text})]},`${s.atMs}-${s.level}-${s.text}`))})]})]})]}),t&&F&&e.jsxs("div",{className:"bw-settings",children:[e.jsxs("label",{title:"A program opening a page counts as a finding only if nobody had touched the browser for this long. Shorter catches more and reports more of your own work; longer is quieter.",children:[e.jsx("span",{children:"Nobody browsing for"}),e.jsxs("select",{value:D??t.settings.quietMinutes,onChange:s=>{U(Number(s.target.value)),$({quietMinutes:Number(s.target.value)})},children:[e.jsx("option",{value:1,children:"1 min"}),e.jsx("option",{value:5,children:"5 min"}),e.jsx("option",{value:15,children:"15 min"}),e.jsx("option",{value:30,children:"30 min"}),e.jsx("option",{value:60,children:"60 min"})]})]}),e.jsxs("label",{children:[e.jsx("span",{children:"When it finds one"}),e.jsx("select",{value:t.settings.reaction,onChange:s=>void $({reaction:s.target.value}),disabled:!t.settings.enabled,title:t.settings.enabled?"What to do besides writing it down.":"Turn watching on to arm a reaction.",children:(t.reactions??["notify"]).map(s=>e.jsx("option",{value:s,children:s==="notify"?"notify me":s==="close-tab"?"close the tab":"quit the browser"},s))})]}),e.jsxs("div",{className:"bw-settings-note",children:[e.jsxs("p",{children:["Chrome marks a navigation that came from an extension or a command rather than from a click. These are the ones that happened while nobody had touched the browser for the time above. ",e.jsx("strong",{children:"Usually that is your own agent doing what you asked."})]}),e.jsxs("button",{className:"bw-why",onClick:()=>k(s=>!s),"aria-expanded":A,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:A?"▾":"▸"})," What Browser Watch can access"]}),A&&e.jsxs("dl",{className:"bw-access",children:[e.jsx("dt",{children:"Reads"}),e.jsxs("dd",{children:["A copy of each browser's own history database — the live file is locked while the browser holds it. Only rows newer than the moment this deck started:"," ",new Date(t.coverage.startedMs).toLocaleString(),"."]}),e.jsx("dt",{children:"Keeps"}),e.jsxs("dd",{children:["Only while the switch is on, and only the episodes it flagged — never your ordinary browsing. In ",e.jsx("code",{className:"bw-path",children:t.coverage.logPath}),typeof t.coverage.logBytes=="number"?` (${de(t.coverage.logBytes)})`:"",", with every address written in full so you can check it yourself."]}),e.jsx("dt",{children:"Sends"}),e.jsx("dd",{children:"Nothing. No part of this reads or writes over the network; the deck serves on 127.0.0.1 and this panel talks only to it."}),e.jsx("dt",{children:"Never reads"}),e.jsx("dd",{children:"Anything from before this deck started, cookies, saved passwords, page contents, or any browser profile with no history file."})]})]})]}),y&&e.jsx("div",{className:"bw-state bw-write-err",role:"alert",children:e.jsxs("div",{className:"bw-row err",children:[e.jsx("span",{className:"bw-dot","aria-hidden":!0}),e.jsx("span",{className:"bw-row-label",children:"Could not save that setting"}),e.jsxs("span",{className:"bw-row-detail",children:[y," — the switches below show what is stored, not what you pressed."]}),e.jsx("button",{className:"glyph-btn bw-write-err-x",onClick:()=>d(null),"aria-label":"Dismiss",title:"Dismiss",children:"×"})]})}),t&&e.jsxs("footer",{className:"bw-status",children:[e.jsx("span",{className:`bw-mode-dot${t.settings.enabled?" on":""}`,"aria-hidden":!0}),e.jsx("span",{className:"bw-mode-word",children:z(t,G).word},z(t,G).kind),e.jsx("span",{className:"bw-status-text",children:t.coverage.archived===0?t.settings.enabled?"No episodes recorded yet":"No episodes on disk":`${t.coverage.archived} ${t.coverage.archived===1?"episode":"episodes"} on disk`}),e.jsx("button",{className:`glyph-btn bw-gear${F?" on":""}`,onClick:()=>J(s=>!s),"aria-expanded":F,"aria-label":"Settings",title:"Settings",children:e.jsxs("svg",{width:"13",height:"13",viewBox:"0 0 14 14",fill:"none",stroke:"currentColor",strokeWidth:"1.3","aria-hidden":!0,children:[e.jsx("circle",{cx:"7",cy:"7",r:"2.1"}),e.jsx("path",{d:"M7 1.2v1.6M7 11.2v1.6M1.2 7h1.6M11.2 7h1.6M2.9 2.9l1.1 1.1M10 10l1.1 1.1M11.1 2.9L10 4M4 10l-1.1 1.1"})]})}),e.jsxs("label",{className:"bw-switch",htmlFor:"bw-enabled",children:[e.jsx("span",{className:"bw-switch-label",id:"bw-enabled-label",children:"Watch browser activity"}),e.jsx("button",{id:"bw-enabled",type:"button",role:"switch","aria-checked":t.settings.enabled,"aria-labelledby":"bw-enabled-label",className:"switch",onClick:()=>void $({enabled:!t.settings.enabled}),title:t.settings.enabled?"On — every episode it finds is written down, so the list outlives the browsing history being cleared":"Off — showing only what this deck has seen since it started. Anything an earlier run archived is hidden until you switch back on, and nothing new is kept",children:e.jsx("span",{className:"switch-knob"})})]})]})]})]})})}function be({relay:n}){const[i,o]=l.useState(!1),[r,b]=l.useState(!1),t=n.killswitch.blocked?"unblock":"block",w=n.command[t],a=l.useCallback(async()=>{await ae(w.command)&&(b(!0),window.setTimeout(()=>b(!1),1600))},[w.command]),v=n.profiles.filter(d=>{var N;return(N=d.report)==null?void 0:N.present}),y=n.profiles.filter(d=>d.report===null);return e.jsxs("section",{className:"bw-sec",children:[e.jsx("h4",{className:"bw-sec-head",children:"Remote control"}),e.jsx("p",{className:`bw-rc-verdict ${n.verdict}`,role:"status",children:n.verdict==="exposed"?"Claude in Chrome is live here, so any Claude Code session signed in to your Anthropic account can drive this browser — from any machine, with no prompt on this one.":n.verdict==="protected"?`The relay is black-holed in ${n.hostsPath}, so no new connection to it can be made from this machine.`:"Claude in Chrome is not installed and enabled in any profile here, so there is nothing for the relay to drive."}),n.killswitch.foreign.length>0&&e.jsxs("p",{className:"bw-rc-foreign",children:["Another entry for this host is already in ",n.hostsPath,", and ccdeck will not touch it:",n.killswitch.foreign.map(d=>e.jsx("code",{className:"bw-path",children:d},d))]}),e.jsxs("button",{className:"bw-why",onClick:()=>o(d=>!d),"aria-expanded":i,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:i?"▾":"▸"})," ",n.verdict==="protected"?"How to undo the block":"What it can reach, and how to stop it"]}),i&&e.jsxs("div",{className:"bw-rc-body",children:[v.length>0&&e.jsx("dl",{className:"bw-key",children:v.map(d=>e.jsxs(l.Fragment,{children:[e.jsxs("dt",{children:[d.name,d.profile==="Default"?"":` · ${d.profile}`]}),e.jsxs("dd",{children:[d.report.enabled?"enabled":"installed but switched off",d.report.allUrls?", can act on every site":", limited to the sites it was granted",d.report.sensitiveApis.length>0&&e.jsxs(e.Fragment,{children:[" — ",d.report.sensitiveApis.join(", ")]})]})]},`${d.browser}/${d.profile}`))}),y.length>0&&e.jsxs("p",{className:"bw-rc-note",children:[y.map(d=>d.name).join(", ")," — its settings file could not be read, so nothing is claimed about it either way."]}),!n.hostsRead&&e.jsxs("p",{className:"bw-rc-note",children:[n.hostsPath," could not be read, so whether the relay is already blocked is unknown."]}),v.some(d=>d.report.allUrls)&&e.jsxs("p",{className:"bw-rc-note",children:["Narrowing the sites in ",e.jsx("code",{className:"bw-path",children:"chrome://extensions"})," does not change this: the grant is held at the browser level and the per-site list is enforced inside the extension, by the extension."]}),e.jsxs("div",{className:"bw-rc-cmd",children:[e.jsx("code",{children:w.command}),e.jsx("button",{className:"btn bw-rc-copy",onClick:a,children:r?"Copied":"Copy"})]}),e.jsx("p",{className:"bw-rc-note",children:w.note})]})]})}export{ve as SEEN_KEY,ue as agoLabel,le as armsIn,fe as default,de as logBytesLabel,ye as unseenEpisodes,ce as untilLabel,he as visitTotals,we as watchTrouble,Z as watchedBrowsers};
@@ -0,0 +1 @@
1
+ import{a as z,f as j,r as c,u as J,j as e,b as K,s as D,e as Q,c as X,d as x,g as F,h as Y,p as Z}from"./index-Dsca2TwS.js";function _(){let t=0;return{begin(){const a=++t;return()=>a===t},cancel(){t++}}}function ee(t){return!t.answer||t.answer.range!==t.want?{phase:"busy",stale:!1}:t.answer.ok?{phase:t.answer.days>0?"chart":"empty",stale:t.loading}:{phase:"error",stale:t.loading}}function I(t){const a=new Map;for(const n of t)for(const o of n.agents??[]){if(!o||typeof o.agent!="string"||o.agent==="")continue;const r=a.get(o.agent)??{id:o.agent,cost:0,tokens:0};r.cost+=Number.isFinite(o.totalCost)?o.totalCost:0,r.tokens+=Number.isFinite(o.totalTokens)?o.totalTokens:0,a.set(o.agent,r)}return[...a.values()].sort((n,o)=>o.cost-n.cost||n.id.localeCompare(o.id))}function se(t){const a=I([{agents:t}]);return a.length<2?null:a.map(n=>`${z(n.id)} ${j(n.cost)}`).join(" · ")}function U(t){switch(t.toLowerCase()){case"claude":return"var(--usage-purple)";case"codex":return"var(--usage-orange)";case"gemini":return"var(--usage-indigo)";case"copilot":return"var(--usage-green)";default:return"var(--usage-zinc)"}}function ae(t,a){if(!(a>0)||!(t>0))return"0%";const n=t/a*100;return n<.1?"<0.1%":`${n.toFixed(1)}%`}const d=t=>typeof t=="number"&&Number.isFinite(t)?t:0,L=t=>typeof t=="string"?t:"",O=t=>Array.isArray(t)?t.filter(a=>typeof a=="string"):[];function te(t){const a=t??{};return{modelName:L(a.modelName),cost:d(a.cost),inputTokens:d(a.inputTokens),outputTokens:d(a.outputTokens),cacheCreationTokens:d(a.cacheCreationTokens),cacheReadTokens:d(a.cacheReadTokens)}}function ne(t){const a=t??{},n=Array.isArray(a.agents)?a.agents.map(o=>{const r=o??{};return{agent:L(r.agent),totalCost:d(r.totalCost),totalTokens:d(r.totalTokens)}}):void 0;return{period:L(a.period),totalCost:d(a.totalCost),totalTokens:d(a.totalTokens),inputTokens:d(a.inputTokens),outputTokens:d(a.outputTokens),cacheCreationTokens:d(a.cacheCreationTokens),cacheReadTokens:d(a.cacheReadTokens),modelsUsed:O(a.modelsUsed),modelBreakdowns:Array.isArray(a.modelBreakdowns)?a.modelBreakdowns.map(te):[],...n?{agents:n}:{},...a.metadata&&typeof a.metadata=="object"?{metadata:{agents:O(a.metadata.agents)}}:{}}}function oe(t){const a=t??{};return{...a,ok:a.ok===!0,days:Array.isArray(a.days)?a.days.map(ne):void 0}}function M(t){const a=t.toLowerCase();return a.includes("opus")?"var(--usage-purple)":a.includes("sonnet")?"var(--usage-blue)":a.includes("haiku")?"var(--usage-green)":a.includes("gpt-5")||a.includes("gpt5")?"var(--usage-amber)":a.includes("gpt")?"var(--usage-red)":a.includes("gemini")?"var(--usage-indigo)":a.includes("codex")?"var(--usage-orange)":"var(--usage-zinc)"}const le=[7,14,30,90];function re(t){const[a,n]=c.useState(null),[o,r]=c.useState(!1),C=c.useState(_)[0],h=c.useRef(!1),f=(y=!1)=>{if(y&&!Y(h.current))return;const b=C.begin();h.current=y;const T=t;r(!0);const i=`/api/ccusage?since=${Z(T)}${y?"&refresh=1":""}`;fetch(i).then(v=>v.json()).then(v=>{b()&&n({range:T,resp:oe(v)})}).catch(()=>{b()&&n({range:T,resp:{ok:!1,reason:"unreachable"}})}).finally(()=>{b()&&(h.current=!1,r(!1))})};return c.useEffect(()=>(f(!1),()=>C.cancel()),[t]),{landed:a,loading:o,reload:()=>f(!0)}}function ie({onClose:t,providers:a}){var P;const[n,o]=c.useState(30),[r,C]=c.useState(null),{landed:h,loading:f,reload:y}=re(n),b=c.useRef(null),T=J(t,{focusRef:b}),g=ee({loading:f,want:n,answer:h&&{range:h.range,ok:h.resp.ok===!0,days:((P=h.resp.days)==null?void 0:P.length)??0}}),i=g.phase==="chart"?h.resp.days??[]:[],v=c.useMemo(()=>i.reduce((s,l)=>Math.max(s,l.totalCost),0),[i]),B=c.useRef(null);c.useLayoutEffect(()=>{const s=B.current;s&&(s.scrollLeft=s.scrollWidth)},[i]);const{totalCost:R,totalTok:q,inOut:G,cacheRead:H,modelCosts:E}=c.useMemo(()=>{let s=0,l=0,k=0,p=0;const w=new Map;for(const m of i){s+=m.totalCost,l+=m.totalTokens,k+=m.inputTokens+m.outputTokens,p+=m.cacheReadTokens;for(const N of m.modelBreakdowns)w.set(N.modelName,(w.get(N.modelName)??0)+N.cost)}return{totalCost:s,totalTok:l,inOut:k,cacheRead:p,modelCosts:w}},[i]),V=c.useMemo(()=>Array.from(E.entries()).sort((s,l)=>l[1]-s[1]),[E]),$=c.useMemo(()=>I(i),[i]),W=$.length>1,u=r?i.find(s=>s.period===r)??null:null;return e.jsx("div",{className:"modal-backdrop",onClick:t,role:"presentation",children:e.jsxs("div",{ref:T,className:"modal uh-modal",onClick:s=>s.stopPropagation(),role:"dialog","aria-modal":"true","aria-label":"Usage history",children:[e.jsxs("header",{className:"uh-head",children:[e.jsxs("div",{className:"uh-titlewrap",children:[e.jsx("div",{className:"uh-title",children:"Usage history"}),e.jsx("div",{className:"uh-sub",children:K(a,$.map(s=>s.id))})]}),e.jsx("div",{className:"uh-range",role:"group","aria-label":"Range",children:le.map(s=>e.jsxs("button",{type:"button","aria-pressed":n===s,className:"uh-range-btn",onClick:()=>{o(s),C(null)},children:[s,"d"]},s))}),e.jsx("button",{className:"glyph-btn uh-reload",onClick:y,...D(f),title:"Re-run ccusage","aria-label":"Reload",children:f?"…":"↻"}),e.jsx("button",{ref:b,className:"glyph-btn uh-close",onClick:t,"aria-label":"Close",children:"×"})]}),g.phase==="busy"?e.jsxs("div",{className:"uh-status uh-status-wait","aria-busy":"true",children:[e.jsx("span",{className:"uh-wait-word",children:h?"running ccusage…":"running ccusage… (first run downloads the package)"}),e.jsx("span",{className:"uh-wait-axis","aria-hidden":!0})]}):g.phase==="error"?e.jsxs("div",{className:"uh-status uh-err",title:X(h.resp)||void 0,children:[Q(h.resp,"ccusage did not report usage"),e.jsx("div",{children:e.jsx("button",{className:"btn uh-retry",onClick:y,...D(f),children:f?"trying…":"Try again"})})]}):g.phase==="empty"?e.jsx("div",{className:`uh-status${g.stale?" uh-stale":""}`,"aria-busy":g.stale||void 0,children:"no usage in this range"}):e.jsxs("div",{className:g.stale?"uh-stale":void 0,"aria-busy":g.stale||void 0,children:[e.jsxs("div",{className:"uh-totals",children:[e.jsx(S,{label:"total cost",val:j(R),accent:!0}),e.jsx(S,{label:"tokens",val:x(q)}),e.jsx(S,{label:"input+output",val:x(G)}),e.jsx(S,{label:"cache reads",val:x(H)})]}),W&&e.jsxs("div",{className:"uh-agents",role:"group","aria-label":"Cost by CLI",children:[e.jsx("div",{className:"uh-agent-bar","aria-hidden":"true",children:$.map(s=>e.jsx("span",{className:"uh-agent-seg",style:{width:`${R>0?s.cost/R*100:0}%`,background:U(s.id)}},s.id))}),e.jsx("div",{className:"uh-agent-keys",children:$.map(s=>e.jsxs("span",{className:"uh-agent-key",title:`${s.id} · ${x(s.tokens)} tokens`,children:[e.jsx("span",{className:"uh-legend-dot",style:{background:U(s.id)}}),z(s.id),e.jsx("span",{className:"uh-agent-cost",children:j(s.cost)}),e.jsx("span",{className:"uh-agent-share",children:ae(s.cost,R)})]},s.id))})]}),e.jsx("div",{ref:B,className:"uh-chart",role:"group","aria-label":"Daily cost by model",children:i.map((s,l)=>{const k=v>0?s.totalCost/v*100:0,p=s.period===r,w=`${s.period}, ${j(s.totalCost)}`;return e.jsxs("button",{className:`uh-bar-col${p?" sel":""}`,onClick:()=>C(p?null:s.period),"aria-pressed":p,"aria-label":w,title:`${s.period} · ${j(s.totalCost)}`,style:{flexBasis:`${100/i.length}%`,"--uh-i":l,"--uh-step":`${Math.min(10,200/Math.max(i.length,1))}ms`},children:[e.jsx("div",{className:"uh-bar",style:{height:`${Math.max(k,s.totalCost>0?2:0)}%`},children:s.modelBreakdowns.slice().sort((m,N)=>N.cost-m.cost).map(m=>{const N=s.totalCost>0?m.cost/s.totalCost*100:0;return e.jsx("div",{className:"uh-bar-seg",style:{height:`${N}%`,background:M(m.modelName)}},m.modelName)})}),e.jsx("span",{className:"uh-bar-label",children:s.period.slice(5)})]},s.period)})}),e.jsx("div",{className:"uh-legend",children:V.map(([s,l])=>e.jsxs("span",{className:"uh-legend-item",title:s,children:[e.jsx("span",{className:"uh-legend-dot",style:{background:M(s)}}),F(s)," ",e.jsx("span",{className:"uh-legend-cost",children:j(l)})]},s))}),u&&e.jsxs("div",{className:"uh-detail",children:[e.jsxs("div",{className:"uh-detail-head",children:[e.jsx("span",{className:"uh-detail-date",children:u.period}),e.jsx("span",{className:"uh-detail-cost",children:j(u.totalCost)}),(()=>{var k,p;const l=se(u.agents)??((p=(k=u.metadata)==null?void 0:k.agents)!=null&&p.length?u.metadata.agents.join(" · "):null);return l?e.jsx("span",{className:"uh-detail-agents",title:l,children:l}):null})()]}),e.jsxs("div",{className:"uh-detail-mini",children:[e.jsx(A,{label:"input",val:x(u.inputTokens)}),e.jsx(A,{label:"output",val:x(u.outputTokens)}),e.jsx(A,{label:"cache write",val:x(u.cacheCreationTokens)}),e.jsx(A,{label:"cache read",val:x(u.cacheReadTokens)})]}),e.jsx("div",{className:"uh-detail-models",children:u.modelBreakdowns.slice().sort((s,l)=>l.cost-s.cost).map(s=>{const l=u.totalCost>0?s.cost/u.totalCost*100:0;return e.jsxs("div",{className:"uh-model-row",title:s.modelName,children:[e.jsxs("span",{className:"uh-model-name",children:[e.jsx("span",{className:"uh-legend-dot",style:{background:M(s.modelName)}}),e.jsx("span",{className:"uh-model-label",children:F(s.modelName)})]}),e.jsx("span",{className:"uh-model-bar",children:e.jsx("span",{className:"uh-model-bar-fill",style:{width:`${l}%`,background:M(s.modelName)}})}),e.jsx("span",{className:"uh-model-cost",children:j(s.cost)})]},s.modelName)})})]})]})]})})}function S({label:t,val:a,accent:n}){return e.jsxs("div",{className:"uh-stat",children:[e.jsx("span",{className:`uh-stat-val${n?" accent":""}`,children:a}),e.jsx("span",{className:"uh-stat-label",children:t})]})}function A({label:t,val:a}){return e.jsxs("div",{className:"uh-ministat",children:[e.jsx("span",{className:"uh-ministat-val",children:a}),e.jsx("span",{className:"uh-ministat-label",children:t})]})}export{ie as default};