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.
- package/LICENSING.md +17 -3
- package/README.md +6 -1
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/{BrowserWatchModal-BnNwmF9s.js → BrowserWatchModal-BJKk2x-P.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-CAsrCgeA.js → UsageHistoryModal-ClT1-0hN.js} +1 -1
- package/dist/web/assets/index-1U-Iz_x2.css +1 -0
- package/dist/web/assets/index-Dj4JHRcG.js +306 -0
- package/dist/web/brand/ccdeck-mark-gradient-small-optical.svg +1 -0
- package/dist/web/brand/ccdeck-mark-mono-dark-small-optical.svg +1 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/index.html +13 -11
- package/dist/web/manifest.webmanifest +1 -2
- package/dist/web/state/favicon-error-32.png +0 -0
- package/dist/web/state/favicon-error.svg +1 -0
- package/dist/web/state/favicon-syncing-32.png +0 -0
- package/dist/web/state/favicon-syncing.svg +1 -0
- package/dist/web/state/favicon-waiting-32.png +0 -0
- package/dist/web/state/favicon-waiting.svg +1 -0
- package/hook/hook.js +23 -6
- package/package.json +1 -1
- package/release-notes.json +8 -1
- package/src/server/account-origins.mjs +201 -0
- package/src/server/account-routes.mjs +106 -5
- package/src/server/claude-accounts.mjs +78 -2
- package/src/server/cswap-admin.mjs +62 -1
- package/src/server/deck-prefs.mjs +9 -1
- package/src/server/feedback-form.mjs +125 -0
- package/src/server/http-io.mjs +40 -4
- package/src/server/index.mjs +9 -3
- package/src/server/prefs-routes.mjs +4 -1
- package/src/server/reports-routes.mjs +69 -11
- package/src/server/static-serve.mjs +1 -0
- package/dist/web/assets/index-CuVOi3gc.css +0 -1
- package/dist/web/assets/index-MjcQ231B.js +0 -305
- package/dist/web/icon-maskable.svg +0 -21
- 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>
|
package/dist/web/icon-192.png
CHANGED
|
Binary file
|
package/dist/web/icon-512.png
CHANGED
|
Binary file
|
|
Binary file
|
package/dist/web/index.html
CHANGED
|
@@ -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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<link rel="icon" href="
|
|
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-
|
|
66
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
}
|
|
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-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>
|
|
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-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>
|
|
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-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
|
|
390
|
-
* the
|
|
391
|
-
*
|
|
392
|
-
*
|
|
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
|
|
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.
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
//
|
|
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,
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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);
|