ccdeck 3.36.0 → 3.36.3

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 (40) hide show
  1. package/LICENSING.md +17 -3
  2. package/README.md +6 -1
  3. package/dist/web/apple-touch-icon.png +0 -0
  4. package/dist/web/assets/{BrowserWatchModal-BnNwmF9s.js → BrowserWatchModal-BJKk2x-P.js} +1 -1
  5. package/dist/web/assets/{UsageHistoryModal-CAsrCgeA.js → UsageHistoryModal-ClT1-0hN.js} +1 -1
  6. package/dist/web/assets/index-1U-Iz_x2.css +1 -0
  7. package/dist/web/assets/index-Dj4JHRcG.js +306 -0
  8. package/dist/web/brand/ccdeck-mark-gradient-small-optical.svg +1 -0
  9. package/dist/web/brand/ccdeck-mark-mono-dark-small-optical.svg +1 -0
  10. package/dist/web/favicon.ico +0 -0
  11. package/dist/web/favicon.svg +1 -0
  12. package/dist/web/icon-192.png +0 -0
  13. package/dist/web/icon-512.png +0 -0
  14. package/dist/web/icon-maskable-512.png +0 -0
  15. package/dist/web/index.html +13 -11
  16. package/dist/web/manifest.webmanifest +1 -2
  17. package/dist/web/state/favicon-error-32.png +0 -0
  18. package/dist/web/state/favicon-error.svg +1 -0
  19. package/dist/web/state/favicon-syncing-32.png +0 -0
  20. package/dist/web/state/favicon-syncing.svg +1 -0
  21. package/dist/web/state/favicon-waiting-32.png +0 -0
  22. package/dist/web/state/favicon-waiting.svg +1 -0
  23. package/hook/hook.js +23 -6
  24. package/package.json +1 -1
  25. package/release-notes.json +8 -1
  26. package/src/server/account-origins.mjs +201 -0
  27. package/src/server/account-routes.mjs +106 -5
  28. package/src/server/claude-accounts.mjs +78 -2
  29. package/src/server/cswap-admin.mjs +62 -1
  30. package/src/server/deck-prefs.mjs +9 -1
  31. package/src/server/feedback-form.mjs +125 -0
  32. package/src/server/http-io.mjs +40 -4
  33. package/src/server/index.mjs +9 -3
  34. package/src/server/prefs-routes.mjs +4 -1
  35. package/src/server/reports-routes.mjs +69 -11
  36. package/src/server/static-serve.mjs +1 -0
  37. package/dist/web/assets/index-CuVOi3gc.css +0 -1
  38. package/dist/web/assets/index-MjcQ231B.js +0 -305
  39. package/dist/web/icon-maskable.svg +0 -21
  40. package/dist/web/icon.svg +0 -24
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="112" height="96" viewBox="0 0 112 96"><title>ccdeck</title><defs><linearGradient id="ccdeck-mark-so-upper" gradientUnits="userSpaceOnUse" x1="34.75" y1="-2.06" x2="62.12" y2="62.42"><stop offset="0" stop-color="#BE68FC"/><stop offset="1" stop-color="#3B8CFA"/></linearGradient><linearGradient id="ccdeck-mark-so-lower" gradientUnits="userSpaceOnUse" x1="33.74" y1="54.81" x2="105.49" y2="32.87"><stop offset="0" stop-color="#0CBCFD"/><stop offset="1" stop-color="#64F4EC"/></linearGradient><linearGradient id="ccdeck-mark-so-node" gradientUnits="userSpaceOnUse" x1="50.77" y1="38.95" x2="61.23" y2="57.05"><stop offset="0" stop-color="#6388FA"/><stop offset="1" stop-color="#3F9BFB"/></linearGradient></defs><path fill="url(#ccdeck-mark-so-upper)" d="M36 0L51 0A12 12 0 0 1 51 24L36 24A12 12 0 0 0 36 48A12 12 0 0 1 36.574 48.014A1 1 0 0 1 37.525 48.961A18.5 18.5 0 0 0 46.458 63.849A1 1 0 0 1 46.846 65.134A12 12 0 0 1 36 72A36 36 0 0 1 36 0Z"/><path fill="url(#ccdeck-mark-so-lower)" d="M76 96L61 96A12 12 0 0 1 61 72L76 72A12 12 0 0 0 76 48A12 12 0 0 1 75.426 47.986A1 1 0 0 1 74.475 47.039A18.5 18.5 0 0 0 65.542 32.151A1 1 0 0 1 65.154 30.866A12 12 0 0 1 76 24A36 36 0 0 1 76 96Z"/><circle fill="url(#ccdeck-mark-so-node)" cx="56" cy="48" r="10"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="112" height="96" viewBox="0 0 112 96"><title>ccdeck</title><g fill="#0B0F14"><path d="M36 0L51 0A12 12 0 0 1 51 24L36 24A12 12 0 0 0 36 48A12 12 0 0 1 36.574 48.014A1 1 0 0 1 37.525 48.961A18.5 18.5 0 0 0 46.458 63.849A1 1 0 0 1 46.846 65.134A12 12 0 0 1 36 72A36 36 0 0 1 36 0Z"/><path d="M76 96L61 96A12 12 0 0 1 61 72L76 72A12 12 0 0 0 76 48A12 12 0 0 1 75.426 47.986A1 1 0 0 1 74.475 47.039A18.5 18.5 0 0 0 65.542 32.151A1 1 0 0 1 65.154 30.866A12 12 0 0 1 76 24A36 36 0 0 1 76 96Z"/><circle cx="56" cy="48" r="10"/></g></svg>
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><title>ccdeck</title><defs><linearGradient id="ccdeck-favicon-upper" gradientUnits="userSpaceOnUse" x1="34.75" y1="-2.06" x2="62.12" y2="62.42"><stop offset="0" stop-color="#BE68FC"/><stop offset="1" stop-color="#3B8CFA"/></linearGradient><linearGradient id="ccdeck-favicon-lower" gradientUnits="userSpaceOnUse" x1="33.74" y1="54.81" x2="105.49" y2="32.87"><stop offset="0" stop-color="#0CBCFD"/><stop offset="1" stop-color="#64F4EC"/></linearGradient><linearGradient id="ccdeck-favicon-node" gradientUnits="userSpaceOnUse" x1="50.77" y1="38.95" x2="61.23" y2="57.05"><stop offset="0" stop-color="#6388FA"/><stop offset="1" stop-color="#3F9BFB"/></linearGradient></defs><rect width="128" height="128" rx="24" fill="#0B0F14"/><g transform="translate(8 16) scale(1.000000)"><path fill="url(#ccdeck-favicon-upper)" d="M36 0L51 0A12 12 0 0 1 51 24L36 24A12 12 0 0 0 36 48A12 12 0 0 1 36.574 48.014A1 1 0 0 1 37.525 48.961A18.5 18.5 0 0 0 46.458 63.849A1 1 0 0 1 46.846 65.134A12 12 0 0 1 36 72A36 36 0 0 1 36 0Z"/><path fill="url(#ccdeck-favicon-lower)" d="M76 96L61 96A12 12 0 0 1 61 72L76 72A12 12 0 0 0 76 48A12 12 0 0 1 75.426 47.986A1 1 0 0 1 74.475 47.039A18.5 18.5 0 0 0 65.542 32.151A1 1 0 0 1 65.154 30.866A12 12 0 0 1 76 24A36 36 0 0 1 76 96Z"/><circle fill="url(#ccdeck-favicon-node)" cx="56" cy="48" r="10"/></g></svg>
Binary file
Binary file
Binary file
@@ -8,15 +8,17 @@
8
8
  module runs, so this is the deck at boot — nothing waiting, nothing
9
9
  known to be running — and App.tsx's ambient effect corrects both within
10
10
  the first frame it has state for. The title is the one copy of the name
11
- that no constant can reach (display-name.test.ts pins it to PRODUCT);
12
- the href is ambient.ts's own idle mark, generated by FAVICON_HREF and
13
- pinned to it by ambient-signal.test.ts, which is why it is an
14
- unreadable percent-encoded run rather than something to hand-edit. It
15
- was a `<text>◉</text>` glyph until #338 — a font-dependent shape that
16
- renders at three different weights on the three platforms and as tofu
17
- where the codepoint is missing, which stopped being acceptable the
18
- moment the icon started carrying state. -->
19
- <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Ccircle%20cx%3D%2216%22%20cy%3D%2216%22%20r%3D%2212%22%20fill%3D%22none%22%20stroke%3D%22%237e828c%22%20stroke-width%3D%225%22%2F%3E%3C%2Fsvg%3E" />
11
+ that no constant can reach (display-name.test.ts pins it to PRODUCT).
12
+ The icons are the brand kit's 05-web files, linked as the kit links
13
+ them: the ICO for a browser without SVG favicons, the SVG every other
14
+ browser shows, and the home-screen icon for iOS. use-tab-ambient.ts
15
+ swaps both icon links to the kit's state files. The ICO says 32x32,
16
+ never `any`: with `any`, Chrome shows the ICO and keeps it after the
17
+ SVG's href changes, so the tab would never show a state
18
+ (ambient-signal.test.ts holds it). -->
19
+ <link rel="icon" href="/favicon.ico" sizes="32x32" />
20
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
21
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
20
22
  <!-- Installed, this deck is an application: a Dock tile on macOS, a taskbar
21
23
  and Start entry on Windows, a .desktop file in the launcher on Linux —
22
24
  which is the one that matters most, because a launcher entry works on
@@ -62,8 +64,8 @@
62
64
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
63
65
  })();
64
66
  </script>
65
- <script type="module" crossorigin src="/assets/index-MjcQ231B.js"></script>
66
- <link rel="stylesheet" crossorigin href="/assets/index-CuVOi3gc.css">
67
+ <script type="module" crossorigin src="/assets/index-Dj4JHRcG.js"></script>
68
+ <link rel="stylesheet" crossorigin href="/assets/index-1U-Iz_x2.css">
67
69
  </head>
68
70
  <body>
69
71
  <div id="root"></div>
@@ -11,7 +11,6 @@
11
11
  "icons": [
12
12
  { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
13
13
  { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
14
- { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
15
- { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
14
+ { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
16
15
  ]
17
16
  }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><title>ccdeck</title><defs><linearGradient id="ccdeck-favicon-error-upper" gradientUnits="userSpaceOnUse" x1="34.75" y1="-2.06" x2="62.12" y2="62.42"><stop offset="0" stop-color="#BE68FC"/><stop offset="1" stop-color="#3B8CFA"/></linearGradient><linearGradient id="ccdeck-favicon-error-lower" gradientUnits="userSpaceOnUse" x1="33.74" y1="54.81" x2="105.49" y2="32.87"><stop offset="0" stop-color="#0CBCFD"/><stop offset="1" stop-color="#64F4EC"/></linearGradient><linearGradient id="ccdeck-favicon-error-node" gradientUnits="userSpaceOnUse" x1="50.77" y1="38.95" x2="61.23" y2="57.05"><stop offset="0" stop-color="#6388FA"/><stop offset="1" stop-color="#3F9BFB"/></linearGradient><mask id="ccdeck-favicon-error-cut" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" x="0" y="0" width="128" height="128"><rect width="128" height="128" fill="#FFFFFF"/><path fill="#000000" d="M96 0H112A8 8 0 0 1 120 8V40A8 8 0 0 1 112 48H96A8 8 0 0 1 88 40V8A8 8 0 0 1 96 0Z"/></mask></defs><rect width="128" height="128" rx="24" fill="#0B0F14"/><g mask="url(#ccdeck-favicon-error-cut)"><g transform="translate(8 16) scale(1.000000)"><path fill="url(#ccdeck-favicon-error-upper)" d="M36 0L51 0A12 12 0 0 1 51 24L36 24A12 12 0 0 0 36 48A12 12 0 0 1 36.574 48.014A1 1 0 0 1 37.525 48.961A18.5 18.5 0 0 0 46.458 63.849A1 1 0 0 1 46.846 65.134A12 12 0 0 1 36 72A36 36 0 0 1 36 0Z"/><path fill="url(#ccdeck-favicon-error-lower)" d="M76 96L61 96A12 12 0 0 1 61 72L76 72A12 12 0 0 0 76 48A12 12 0 0 1 75.426 47.986A1 1 0 0 1 74.475 47.039A18.5 18.5 0 0 0 65.542 32.151A1 1 0 0 1 65.154 30.866A12 12 0 0 1 76 24A36 36 0 0 1 76 96Z"/><circle fill="url(#ccdeck-favicon-error-node)" cx="56" cy="48" r="10"/></g></g><path fill="#D32F2F" d="M100 8H108A4 4 0 0 1 112 12V21A4 4 0 0 1 108 25H100A4 4 0 0 1 96 21V12A4 4 0 0 1 100 8ZM100 32H108A4 4 0 0 1 112 36V36A4 4 0 0 1 108 40H100A4 4 0 0 1 96 36V36A4 4 0 0 1 100 32Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><title>ccdeck</title><defs><linearGradient id="ccdeck-favicon-syncing-upper" gradientUnits="userSpaceOnUse" x1="34.75" y1="-2.06" x2="62.12" y2="62.42"><stop offset="0" stop-color="#BE68FC"/><stop offset="1" stop-color="#3B8CFA"/></linearGradient><linearGradient id="ccdeck-favicon-syncing-lower" gradientUnits="userSpaceOnUse" x1="33.74" y1="54.81" x2="105.49" y2="32.87"><stop offset="0" stop-color="#0CBCFD"/><stop offset="1" stop-color="#64F4EC"/></linearGradient><linearGradient id="ccdeck-favicon-syncing-node" gradientUnits="userSpaceOnUse" x1="50.77" y1="38.95" x2="61.23" y2="57.05"><stop offset="0" stop-color="#6388FA"/><stop offset="1" stop-color="#3F9BFB"/></linearGradient><mask id="ccdeck-favicon-syncing-cut" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" x="0" y="0" width="128" height="128"><rect width="128" height="128" fill="#FFFFFF"/><path fill="#000000" d="M80 24A24 24 0 1 0 128 24A24 24 0 1 0 80 24Z"/></mask></defs><rect width="128" height="128" rx="24" fill="#0B0F14"/><g mask="url(#ccdeck-favicon-syncing-cut)"><g transform="translate(8 16) scale(1.000000)"><path fill="url(#ccdeck-favicon-syncing-upper)" d="M36 0L51 0A12 12 0 0 1 51 24L36 24A12 12 0 0 0 36 48A12 12 0 0 1 36.574 48.014A1 1 0 0 1 37.525 48.961A18.5 18.5 0 0 0 46.458 63.849A1 1 0 0 1 46.846 65.134A12 12 0 0 1 36 72A36 36 0 0 1 36 0Z"/><path fill="url(#ccdeck-favicon-syncing-lower)" d="M76 96L61 96A12 12 0 0 1 61 72L76 72A12 12 0 0 0 76 48A12 12 0 0 1 75.426 47.986A1 1 0 0 1 74.475 47.039A18.5 18.5 0 0 0 65.542 32.151A1 1 0 0 1 65.154 30.866A12 12 0 0 1 76 24A36 36 0 0 1 76 96Z"/><circle fill="url(#ccdeck-favicon-syncing-node)" cx="56" cy="48" r="10"/></g></g><path fill="#2563EB" d="M119.455 28.141A16 16 0 1 1 108.141 8.545A4 4 0 0 1 106.071 16.273A8 8 0 1 0 111.727 26.071A4 4 0 0 1 119.455 28.141Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><title>ccdeck</title><defs><linearGradient id="ccdeck-favicon-waiting-upper" gradientUnits="userSpaceOnUse" x1="34.75" y1="-2.06" x2="62.12" y2="62.42"><stop offset="0" stop-color="#BE68FC"/><stop offset="1" stop-color="#3B8CFA"/></linearGradient><linearGradient id="ccdeck-favicon-waiting-lower" gradientUnits="userSpaceOnUse" x1="33.74" y1="54.81" x2="105.49" y2="32.87"><stop offset="0" stop-color="#0CBCFD"/><stop offset="1" stop-color="#64F4EC"/></linearGradient><linearGradient id="ccdeck-favicon-waiting-node" gradientUnits="userSpaceOnUse" x1="50.77" y1="38.95" x2="61.23" y2="57.05"><stop offset="0" stop-color="#6388FA"/><stop offset="1" stop-color="#3F9BFB"/></linearGradient><mask id="ccdeck-favicon-waiting-cut" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" x="0" y="0" width="128" height="128"><rect width="128" height="128" fill="#FFFFFF"/><path fill="#000000" d="M85 26A21 21 0 1 0 127 26A21 21 0 1 0 85 26Z"/></mask></defs><rect width="128" height="128" rx="24" fill="#0B0F14"/><g mask="url(#ccdeck-favicon-waiting-cut)"><g transform="translate(8 16) scale(1.000000)"><path fill="url(#ccdeck-favicon-waiting-upper)" d="M36 0L51 0A12 12 0 0 1 51 24L36 24A12 12 0 0 0 36 48A12 12 0 0 1 36.574 48.014A1 1 0 0 1 37.525 48.961A18.5 18.5 0 0 0 46.458 63.849A1 1 0 0 1 46.846 65.134A12 12 0 0 1 36 72A36 36 0 0 1 36 0Z"/><path fill="url(#ccdeck-favicon-waiting-lower)" d="M76 96L61 96A12 12 0 0 1 61 72L76 72A12 12 0 0 0 76 48A12 12 0 0 1 75.426 47.986A1 1 0 0 1 74.475 47.039A18.5 18.5 0 0 0 65.542 32.151A1 1 0 0 1 65.154 30.866A12 12 0 0 1 76 24A36 36 0 0 1 76 96Z"/><circle fill="url(#ccdeck-favicon-waiting-node)" cx="56" cy="48" r="10"/></g></g><path fill="#C2410C" d="M93 26A13 13 0 1 0 119 26A13 13 0 1 0 93 26Z"/></svg>
package/hook/hook.js CHANGED
@@ -373,7 +373,8 @@ const POST_TIMEOUT_MS = 1000;
373
373
  // The third phase that can stall, and the one that had no deadline at all:
374
374
  // reading the registry and canonicalising the paths in it. See discoverTargets
375
375
  // for what it is bounding and why one unanswering path must not cost the event
376
- // for every other deck on the machine.
376
+ // for every other deck on the machine — and for why a window that found nothing
377
+ // gets a second one.
377
378
  const DISCOVERY_TIMEOUT_MS = 400;
378
379
 
379
380
  // How long the canonical spelling of the session's cwd is worth waiting for
@@ -386,10 +387,11 @@ const CWD_TIMEOUT_MS = 200;
386
387
  * from when main() ran — see the deadline main() sets.
387
388
  *
388
389
  * 1900ms is not the sum of the phase deadlines, and it is not meant to be. Four
389
- * phases now carry one — discovery 400, the challenge's two attempts 400 + 400,
390
- * the POST 1000 — and they only stack to 2200 if all three surfaces stall on
391
- * the same run: an unanswering filesystem, a port that accepts and says
392
- * nothing, and a deck that takes the body and never replies. Each deadline
390
+ * phases now carry one — discovery 400 (and 400 more when the first window found
391
+ * nothing), the challenge's two attempts 400 + 400, the POST 1000 — and they
392
+ * only stack to 2600 if all three surfaces stall on the same run: an
393
+ * unanswering filesystem, a port that accepts and says nothing, and a deck that
394
+ * takes the body and never replies. Each deadline
393
395
  * bounds its own pathology; this one is the backstop over all of them, and it
394
396
  * is what a reader should believe rather than the addition.
395
397
  *
@@ -874,6 +876,17 @@ function readRecord(file, resolvedCwd, found, done) {
874
876
  * is a symlink, a junction or an 8.3 short name. Giving up on the whole phase
875
877
  * instead would hand every deck on the machine nothing.
876
878
  *
879
+ * A DEADLINE WITH NOTHING FOUND IS NOT AN ANSWER EITHER (#1872). The deadline
880
+ * is there so a stalled record cannot take the event from a deck that answered;
881
+ * when nothing has answered yet there is no such deck to protect, only reads
882
+ * still out — and on a loaded machine those are usually slow, not stalled: a
883
+ * busy disk, an antivirus scanning a record a deck rewrote a moment ago, a
884
+ * threadpool queued behind someone else's build. Handing back the empty set
885
+ * there dropped the event for every deck on the machine, with nothing on screen
886
+ * to say so. So an empty first window gets one more, the way prove()'s deadline
887
+ * does, and the second ends like the first: at its deadline with whatever
888
+ * answered, or as soon as every read has.
889
+ *
877
890
  * `cb` runs exactly once, with a snapshot — a read that lands after the
878
891
  * deadline may still push, and must not change the set already being acted on.
879
892
  */
@@ -891,7 +904,11 @@ function discoverTargets(cwd, cb) {
891
904
  clearTimeout(timer);
892
905
  cb(found.slice(), usedCwd);
893
906
  };
894
- const timer = setTimeout(finish, DISCOVERY_TIMEOUT_MS);
907
+ const expire = retried => {
908
+ if (found.length || retried) return finish();
909
+ timer = setTimeout(() => expire(true), DISCOVERY_TIMEOUT_MS);
910
+ };
911
+ let timer = setTimeout(() => expire(false), DISCOVERY_TIMEOUT_MS);
895
912
 
896
913
  let scanned = false;
897
914
  // Assigned below, and read from a callback normPathAsync may run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.36.0",
3
+ "version": "3.36.3",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,7 +61,14 @@
61
61
  "3.33.1",
62
62
  "3.35.0",
63
63
  "3.35.1",
64
- "3.36.0"
64
+ "3.36.0",
65
+ "3.36.1"
66
+ ],
67
+ "3.36.3": [
68
+ {
69
+ "title": "🔑 An expired login asks to be signed in again",
70
+ "body": "When an account you added with + → Sign in stops working, the deck now tells you in one dialog, with every affected account together and Sign in again beside each. That opens the usual sign-in with the address already filled in, and the account keeps its slot, its alias and its history. Not now leaves it marked in the Accounts panel and does not ask again until it has worked and stopped again. A paired deck holding a working copy gets to repair it first, and accounts that came from a share or Local network are never asked about."
71
+ }
65
72
  ],
66
73
  "3.34.1": [
67
74
  {
@@ -0,0 +1,201 @@
1
+ // Which accounts this deck signed in itself, and whether one of them is waiting
2
+ // on its owner to sign in again (#1893).
3
+ //
4
+ // claude-swap's store says nothing about how an account got there. A slot made
5
+ // by the deck's own `+ → Sign in`, one pasted from a share, one a paired deck
6
+ // delivered over Local network and one somebody added with `cswap add` long
7
+ // before the deck existed all look the same in sequence.json. So the deck keeps
8
+ // the one fact it can vouch for — "I signed this one in" — in prefs.json, keyed
9
+ // by the identity every deck agrees on (accountKey) and never by slot number,
10
+ // which claude-swap reuses.
11
+ //
12
+ // THE PROMPT FOLLOWS EVIDENCE, NOT THE CLOCK. An account needs its owner when
13
+ // claude-swap has had its stored login refused — `invalid_grant` or
14
+ // `no_refresh_token` on its last read, or its own `relogin_required` verdict on
15
+ // a slot it stopped collecting — and that refusal came on an attempt made AFTER
16
+ // the deck last signed it in. Nothing here reads a token's expiry, and time
17
+ // passing never makes a login dead on its own.
18
+ //
19
+ // AN INCIDENT IS NAMED BY WHAT IT FOLLOWS. `since` is the later of the last good
20
+ // read and the last sign-in through the deck: the moment the login was last
21
+ // known to work. Every refusal after it is one incident, however many polls,
22
+ // refreshes or restarts it spans — which is what lets "Not now" stick to it. A
23
+ // good read or a new sign-in moves `since`, so a refusal after THAT is a new
24
+ // incident and may ask again.
25
+ //
26
+ // Pure: no file, no clock, no subprocess. The roster read (claude-accounts.mjs)
27
+ // asks reauthFor about each row and says what it found out of date,
28
+ // cswap-admin.mjs reports sign-ins, and account-routes.mjs hands these mutators
29
+ // to prefs.json's queued writer.
30
+ import { accountKey } from "./lan-copies.mjs";
31
+
32
+ /** The provenance a completed `+ → Sign in` leaves. The only one there is:
33
+ * everything else is "the deck cannot say", which is no entry at all. */
34
+ export const SIGNED_IN_HERE = "ccdeck_signin";
35
+
36
+ /** claude-swap's two words for a stored refresh token only a person can
37
+ * replace. The panel's errorText calls both "login expired". */
38
+ const DEAD_LOGIN = new Set(["invalid_grant", "no_refresh_token"]);
39
+
40
+ /** Bounds on a file a person can edit. An email is at most 254 characters and
41
+ * an organization uuid 36, so a key past this is not one the deck wrote. */
42
+ const MAX_KEY = 320;
43
+ const MAX_ENTRIES = 500;
44
+
45
+ /**
46
+ * prefs.json's `accounts`, coerced: `{ [accountKey]: { origin, signedInAt,
47
+ * dismissed? } }`, and nothing that is not one.
48
+ *
49
+ * Built with Object.fromEntries rather than by assignment, so a key read out of
50
+ * a hand-edited file defines a property and can never reach a prototype.
51
+ */
52
+ export function normaliseOrigins(raw) {
53
+ const src = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
54
+ const kept = [];
55
+ for (const [key, v] of Object.entries(src)) {
56
+ if (kept.length >= MAX_ENTRIES) break;
57
+ if (!key.includes("@@") || key.length > MAX_KEY) continue;
58
+ if (!v || typeof v !== "object" || v.origin !== SIGNED_IN_HERE) continue;
59
+ kept.push([key, {
60
+ origin: SIGNED_IN_HERE,
61
+ signedInAt: Number.isFinite(v.signedInAt) && v.signedInAt > 0 ? v.signedInAt : 0,
62
+ ...(Number.isFinite(v.dismissed) && v.dismissed >= 0 ? { dismissed: v.dismissed } : {}),
63
+ }]);
64
+ }
65
+ return Object.fromEntries(kept);
66
+ }
67
+
68
+ // ── what changes it ─────────────────────────────────────────────────────────
69
+ //
70
+ // Each of these is an `updatePrefs` mutator: it reads the file's own `accounts`
71
+ // and answers the WHOLE map, or null for no change. Whole, because prefs.json
72
+ // merges a patch one top-level field deep — a map that only named the account
73
+ // it changed would drop every other one.
74
+
75
+ /**
76
+ * The deck's own sign-in finished and claude-swap recorded the account.
77
+ *
78
+ * Marked only when the sign-in ADDED it, or when the deck had marked it before.
79
+ * An account that was already in the store when somebody signed into it here
80
+ * was put there some other way — a share, Local network, a terminal — and a
81
+ * sign-in that only refreshed it does not change where it came from.
82
+ *
83
+ * A re-sign-in of a marked account keeps the mark and moves `signedInAt`, which
84
+ * is what closes its incident: a refusal from before that moment is about the
85
+ * login it replaced. Any "Not now" goes with it.
86
+ */
87
+ export function withSignIn({ email, org, added, now }) {
88
+ return prev => {
89
+ if (!String(email ?? "").trim()) return null;
90
+ const all = normaliseOrigins(prev?.accounts);
91
+ const key = accountKey(email, org);
92
+ if (!added && !Object.hasOwn(all, key)) return null;
93
+ return { accounts: { ...all, [key]: { origin: SIGNED_IN_HERE, signedInAt: now } } };
94
+ };
95
+ }
96
+
97
+ /** "Not now" on these incidents. Only for an account the deck marked: a key it
98
+ * does not hold is not one it would ever ask about. */
99
+ export function withDismissed(incidents) {
100
+ return prev => {
101
+ const all = normaliseOrigins(prev?.accounts);
102
+ let changed = false;
103
+ const next = Object.fromEntries(Object.entries(all).map(([key, entry]) => {
104
+ const hit = incidents.find(i => i.key === key);
105
+ if (!hit || entry.dismissed === hit.since) return [key, entry];
106
+ changed = true;
107
+ return [key, { ...entry, dismissed: hit.since }];
108
+ }));
109
+ return changed ? { accounts: next } : null;
110
+ };
111
+ }
112
+
113
+ /**
114
+ * What a roster read found out of date, put right in one write.
115
+ *
116
+ * `recovered`: accounts read successfully after the incident somebody put off,
117
+ * so the put-off is forgotten rather than kept for an incident that is over.
118
+ * `gone`: accounts no longer in claude-swap's store, however they left — the
119
+ * mark goes with them, so the same address arriving later by a share or Local
120
+ * network is not mistaken for one this deck signed in.
121
+ *
122
+ * `seen` is the map the read judged, when there is one, and an entry that has
123
+ * changed since — signed in again, or put off for a newer incident — is left as
124
+ * it is: the read's verdict was about the entry it saw.
125
+ */
126
+ export function withTidied({ recovered = [], gone = [], seen = null }) {
127
+ const judged = (key, entry) => !seen || sameEntry(entry, seen[key]);
128
+ return prev => {
129
+ const all = normaliseOrigins(prev?.accounts);
130
+ let changed = false;
131
+ const next = Object.fromEntries(Object.entries(all).flatMap(([key, entry]) => {
132
+ if (!judged(key, entry)) return [[key, entry]];
133
+ if (gone.includes(key)) { changed = true; return []; }
134
+ if (!recovered.includes(key) || entry.dismissed == null) return [[key, entry]];
135
+ changed = true;
136
+ const { dismissed: _dropped, ...rest } = entry;
137
+ return [[key, rest]];
138
+ }));
139
+ return changed ? { accounts: next } : null;
140
+ };
141
+ }
142
+
143
+ const sameEntry = (a, b) => Boolean(a && b)
144
+ && a.origin === b.origin && a.signedInAt === b.signedInAt && a.dismissed === b.dismissed;
145
+
146
+ /** Pick out the incidents a request named, dropping anything that is not one.
147
+ * The route's boundary: a page sends these, and nothing else reaches a write. */
148
+ export function incidentsFrom(raw) {
149
+ if (!Array.isArray(raw)) return [];
150
+ return raw
151
+ .filter(i => i && typeof i.key === "string" && i.key.includes("@@") && i.key.length <= MAX_KEY
152
+ && Number.isFinite(i.since) && i.since >= 0)
153
+ .slice(0, MAX_ENTRIES)
154
+ .map(i => ({ key: i.key, since: i.since }));
155
+ }
156
+
157
+ // ── what it means for a row ─────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Whether claude-swap's verdict on a row is a stored login only a sign-in can
161
+ * bring back. `trouble` is authTrouble's answer for the row and `collector` the
162
+ * slot's cached verdict.
163
+ *
164
+ * The server's copy of account-issue.ts's deadLogin, held to it by
165
+ * reauth-prompt-1893.test.ts: a stale copy is never this (the reader IS signed
166
+ * in and the deck re-captures it alone, #721), a collector that stopped counts
167
+ * only on claude-swap's own `relogin_required`, and a refusal counts only when
168
+ * it is one of the two refresh-token words.
169
+ */
170
+ export function needsSignIn(trouble, collector) {
171
+ if (trouble?.kind === "auth") return DEAD_LOGIN.has(trouble.error);
172
+ if (trouble?.kind === "stopped") return collector === "relogin_required";
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * The incident one roster row is in, or null.
178
+ *
179
+ * `{ since, dismissed }`: `since` names the incident (see the header) and
180
+ * `dismissed` says somebody put this very one off. Null for every account the
181
+ * deck did not sign in, every login that works or fails for another reason, and
182
+ * a login whose refusal predates the last sign-in here — the read that proves
183
+ * the new login has not happened yet, and saying "expired" over a sign-in that
184
+ * just succeeded is the one moment this must stay quiet.
185
+ */
186
+ export function reauthFor({ entry, trouble, collector, fetchedAt = null, attemptedAt = null }) {
187
+ if (entry?.origin !== SIGNED_IN_HERE) return null;
188
+ if (!needsSignIn(trouble, collector)) return null;
189
+ const signedInAt = entry.signedInAt ?? 0;
190
+ if (!Number.isFinite(attemptedAt) || attemptedAt <= signedInAt) return null;
191
+ const since = Math.floor(Math.max(Number.isFinite(fetchedAt) ? fetchedAt : 0, signedInAt));
192
+ return { since, dismissed: entry.dismissed === since };
193
+ }
194
+
195
+ /** A good read after the incident that was put off: the login works again.
196
+ * Floored as `since` is — claude-swap stamps fractional seconds, and the read
197
+ * that STARTED the incident must not count as the one that ended it. */
198
+ export function hasRecovered(entry, fetchedAt) {
199
+ return Number.isFinite(entry?.dismissed) && Number.isFinite(fetchedAt)
200
+ && Math.floor(fetchedAt) > entry.dismissed;
201
+ }
@@ -9,15 +9,22 @@
9
9
  // loads, so moving the routes here puts no import on the path to a listening
10
10
  // socket and takes none out of the pin.
11
11
  //
12
- // Four things are exported beyond the handlers. Three because index.mjs reaches
12
+ // Five things are exported beyond the handlers. Four because index.mjs reaches
13
13
  // for them: wireStaleCopyRepair, which startServer runs to hand the roster its
14
- // stale-copy repair, cswapAutoModule, which it uses to start auto-switch, and
15
- // getProjectRollup, which it starts at boot. The fourth is CHECKS_IMPORTS,
16
- // which lan-deck.mjs reads because a LAN round checks the imports it lands the
17
- // same way the paste box does.
14
+ // stale-copy repair, wireAccountOrigins, which hands it and the sign-in flow
15
+ // where accounts came from (#1893), cswapAutoModule, which it uses to start
16
+ // auto-switch, and getProjectRollup, which it starts at boot. The fifth is
17
+ // CHECKS_IMPORTS, which lan-deck.mjs reads because a LAN round checks the
18
+ // imports it lands the same way the paste box does.
18
19
  import { dirname, join, resolve } from "node:path";
19
20
  import { fileURLToPath, pathToFileURL } from "node:url";
20
21
  import { readBody, send } from "./http-io.mjs";
22
+ // Where the deck keeps which accounts it signed in (#1893): prefs.json, through
23
+ // the one in-memory copy and its queued writer. Both are already on the boot
24
+ // path, which the lazily imported modules below are kept off.
25
+ import { heldPrefs } from "./prefs-state.mjs";
26
+ import { PRODUCT } from "./brand.mjs";
27
+ import { incidentsFrom, withDismissed, withSignIn, withTidied } from "./account-origins.mjs";
21
28
 
22
29
  // Resolved the way pinned-build.mjs resolves it, from a file in the same
23
30
  // directory, so every lazy import below is the URL the pin has already evaluated.
@@ -158,6 +165,97 @@ export function wireStaleCopyRepair() {
158
165
  })();
159
166
  }
160
167
 
168
+ /**
169
+ * Hand the roster and the sign-in flow what the deck remembers about where its
170
+ * accounts came from (#1893), so a completed `+ → Sign in` is recorded, a
171
+ * removal forgets it, and each roster row can say whether it is an incident to
172
+ * ask its owner about.
173
+ *
174
+ * Run beside wireStaleCopyRepair and for its reasons: best-effort, imported one
175
+ * module at a time, and never at import, so no test reading a fixture store can
176
+ * reach the user's own prefs.json.
177
+ */
178
+ export function wireAccountOrigins() {
179
+ void (async () => {
180
+ let accounts, admin;
181
+ try {
182
+ accounts = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
183
+ admin = await cswapAdminModule();
184
+ } catch { return; }
185
+ accounts.accountOriginsWith({
186
+ // Already coerced: every read and write of prefs.json goes through
187
+ // normalise, and this is the copy it left.
188
+ entries: () => heldPrefs.current()?.accounts ?? {},
189
+ tidy: found => { void tidyOrigins(found); },
190
+ });
191
+ admin.accountOriginsWith({
192
+ signedIn: ({ email, org, added }) => writeOrigins(withSignIn({ email, org, added, now: Date.now() })),
193
+ });
194
+ })();
195
+ }
196
+
197
+ /**
198
+ * One prefs.json write, and only when it would change something.
199
+ *
200
+ * updatePrefs rewrites the file even for a mutator that answers "no change" —
201
+ * a temp file, an fsync and a rename — and most of what reaches here is a no
202
+ * change: a sign-in that refreshed an account the deck never marked, a second
203
+ * "Not now" from another tab. So the mutator is asked of the copy in memory
204
+ * first, and again of the file inside the queued write, which is the answer
205
+ * that is kept.
206
+ */
207
+ async function writeOrigins(mutate) {
208
+ if (!mutate(heldPrefs.current())) return;
209
+ await heldPrefs.update(mutate);
210
+ }
211
+
212
+ /**
213
+ * What a roster read found out of date — see tidyOrigins in claude-accounts.mjs.
214
+ *
215
+ * ONE AT A TIME. Reads come every few seconds and the copy in memory moves only
216
+ * when a write lands, so without this each read during a slow write would queue
217
+ * the same write again. A read that arrives while one is out is dropped; the
218
+ * read after it lands sees what is left.
219
+ */
220
+ let _tidying = false;
221
+ async function tidyOrigins(found) {
222
+ if (_tidying) return;
223
+ _tidying = true;
224
+ try { await writeOrigins(withTidied(found)); }
225
+ catch (err) { noteOriginFailure(err); }
226
+ finally { _tidying = false; }
227
+ }
228
+
229
+ /** A settings file the deck cannot write costs a remembered "Not now", which
230
+ * the next read asks about again — said in the log and nowhere else. */
231
+ function noteOriginFailure(err) {
232
+ console.error(`${PRODUCT}: could not update where an account came from:`, err?.message ?? err);
233
+ }
234
+
235
+ /**
236
+ * "Not now" on the accounts the re-sign-in prompt named (#1893).
237
+ *
238
+ * Each incident is `{ key, since }` as the roster row sent it. Written to the
239
+ * account's entry, so the prompt stays down for that incident across a reload,
240
+ * another tab and a restart, and comes back only for a new one. The roster is
241
+ * forgotten after, so the next read already carries `dismissed`.
242
+ */
243
+ async function dismissReauth(raw) {
244
+ const incidents = incidentsFrom(raw);
245
+ if (!incidents.length) return { ok: false, reason: "bad_request" };
246
+ try {
247
+ await writeOrigins(withDismissed(incidents));
248
+ } catch (err) {
249
+ noteOriginFailure(err);
250
+ return { ok: false, reason: "prefs_unwritable" };
251
+ }
252
+ const { invalidateClaudeAccountsCache } = await import(
253
+ pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
254
+ );
255
+ invalidateClaudeAccountsCache();
256
+ return { ok: true };
257
+ }
258
+
161
259
  // Reading the login's progress. The browser polls this while its dialog is
162
260
  // open, the same way the upgrade notice polls /api/version.
163
261
  export async function handleAccountLoginState(_req, res) {
@@ -210,6 +308,9 @@ export async function handleClaudeAccountAdmin(req, res) {
210
308
  case "recapture": result = await admin.recaptureActive(); break;
211
309
  case "alias": result = await admin.setAlias(parsed.account, parsed.alias); break;
212
310
  case "move": result = await admin.moveAccount(parsed.account, parsed.slot); break;
311
+ // #1893. Touches prefs.json, never the store: "Not now" on a re-sign-in
312
+ // prompt, remembered for that incident.
313
+ case "reauth-later": result = await dismissReauth(parsed.incidents); break;
213
314
  default: return send(res, 400, { ok: false, reason: "unknown_action" });
214
315
  }
215
316
  send(res, result.ok ? 200 : 400, result);