ccdeck 3.36.9 → 3.37.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.
- package/README.md +15 -7
- package/bin/agent-dag.js +19 -6
- package/bin/cli/help.js +1 -0
- package/bin/cli/login-item.js +44 -20
- package/bin/cli/one-shot.js +15 -5
- package/bin/cli/screen.js +10 -5
- package/bin/cli/startup.js +6 -2
- package/bin/cli/uninstall.js +41 -5
- package/bin/deck.js +50 -8
- package/dist/web/assets/BrowserWatchModal-DCHV09J8.js +1 -0
- package/dist/web/assets/UsageHistoryModal-oDjpHw4z.js +1 -0
- package/dist/web/assets/index-B0SOa-XB.css +1 -0
- package/dist/web/assets/index-wCbqcRJd.js +306 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +80 -9
- package/package.json +1 -1
- package/release-notes.json +2 -1
- package/src/server/account-origins.mjs +8 -1
- package/src/server/account-projects.mjs +34 -9
- package/src/server/activation.mjs +39 -10
- package/src/server/app-host.mjs +7 -2
- package/src/server/args.mjs +3 -0
- package/src/server/browser-watch-routes.mjs +23 -16
- package/src/server/browser-watch.mjs +9 -3
- package/src/server/claude-accounts.mjs +27 -7
- package/src/server/claude-verdicts.mjs +12 -1
- package/src/server/cli-versions.mjs +6 -4
- package/src/server/codex-auth.mjs +29 -0
- package/src/server/codex-quota.mjs +57 -7
- package/src/server/deck-home.mjs +23 -1
- package/src/server/deck-prefs.mjs +70 -7
- package/src/server/depth-facts.mjs +178 -0
- package/src/server/detach.mjs +61 -33
- package/src/server/event-log.mjs +15 -0
- package/src/server/event-pipeline.mjs +5 -2
- package/src/server/event-routes.mjs +22 -0
- package/src/server/feedback-form.mjs +36 -13
- package/src/server/index.mjs +6 -2
- package/src/server/install-layout.mjs +35 -0
- package/src/server/install-ref.mjs +39 -0
- package/src/server/installer.mjs +20 -2
- package/src/server/lan-beacon.mjs +18 -3
- package/src/server/lan-deck.mjs +7 -1
- package/src/server/lan-engine.mjs +10 -1
- package/src/server/lan-manifest.mjs +9 -6
- package/src/server/lan-requests.mjs +18 -2
- package/src/server/lan-serve.mjs +4 -2
- package/src/server/lan-sync.mjs +97 -6
- package/src/server/lifecycle.mjs +18 -2
- package/src/server/log-replay.mjs +9 -3
- package/src/server/login-service.mjs +95 -12
- package/src/server/npm-upgrade.mjs +12 -4
- package/src/server/pinned-build.mjs +3 -0
- package/src/server/prefs-routes.mjs +5 -8
- package/src/server/prefs-state.mjs +9 -1
- package/src/server/quota-oauth.mjs +88 -13
- package/src/server/quota-shape.mjs +28 -0
- package/src/server/quota.mjs +68 -17
- package/src/server/rating.mjs +87 -0
- package/src/server/reports-routes.mjs +21 -2
- package/src/server/reports.mjs +672 -64
- package/src/server/ring-bounds.mjs +11 -0
- package/src/server/running-deck.mjs +48 -0
- package/src/server/self-update.mjs +1 -1
- package/src/server/session-enrichment.mjs +14 -6
- package/src/server/stable-node.mjs +68 -0
- package/src/server/static-serve.mjs +5 -0
- package/src/server/supervisor.mjs +21 -15
- package/src/server/tailscale.mjs +8 -4
- package/src/server/transcript-scan.mjs +7 -4
- package/src/server/usage-day.mjs +76 -11
- package/dist/web/assets/BrowserWatchModal-BCrDvuug.js +0 -1
- package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
- package/dist/web/assets/index-BkXTN4dZ.css +0 -1
- package/dist/web/assets/index-tVBMXvDv.js +0 -306
package/dist/web/index.html
CHANGED
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
65
65
|
})();
|
|
66
66
|
</script>
|
|
67
|
-
<script type="module" crossorigin src="/assets/index-
|
|
68
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
67
|
+
<script type="module" crossorigin src="/assets/index-wCbqcRJd.js"></script>
|
|
68
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B0SOa-XB.css">
|
|
69
69
|
</head>
|
|
70
70
|
<body>
|
|
71
71
|
<div id="root"></div>
|
package/hook/hook.js
CHANGED
|
@@ -818,6 +818,15 @@ function readRecord(file, resolvedCwd, found, done) {
|
|
|
818
818
|
|
|
819
819
|
if (!isAlive(d.pid)) return fs.unlink(full, () => done());
|
|
820
820
|
|
|
821
|
+
// A deck started with --no-claude says so in its record, and is not a
|
|
822
|
+
// target for a Claude event. Starting one removes no hooks an earlier run
|
|
823
|
+
// installed, so without this it drew every Claude session live — and could
|
|
824
|
+
// be elected to log them — while its replay, which does read the flag,
|
|
825
|
+
// dropped them all again at the next boot. Refused here, before the
|
|
826
|
+
// election, so the log goes to a deck that wants the session. A record
|
|
827
|
+
// with no `claude` field predates the flag and is taken, as it always was.
|
|
828
|
+
if (PROVIDER === "claude" && d.claude === false) return done();
|
|
829
|
+
|
|
821
830
|
// Where this record lives, for the one later verdict that may forget it —
|
|
822
831
|
// see forgetIfAbandoned.
|
|
823
832
|
RECORD_FILE.set(d, full);
|
|
@@ -942,6 +951,66 @@ function discoverTargets(cwd, cb) {
|
|
|
942
951
|
normPathAsync(cwd, scan);
|
|
943
952
|
}
|
|
944
953
|
|
|
954
|
+
// THE DECK REFUSES AN INGEST BODY OVER 5,000,000 CHARACTERS (event-routes.mjs),
|
|
955
|
+
// and every deck has the same cap, so the hand-on below meets the same 413 on
|
|
956
|
+
// each one and the event reaches nobody. Claude Code's PostToolUse for an Edit
|
|
957
|
+
// carries the whole edited file in `tool_response.originalFile`, so editing a
|
|
958
|
+
// file of a few megabytes was enough: the outcome landed on no canvas and in no
|
|
959
|
+
// log, and the turn's end then settled a successful edit as failed. Kept a
|
|
960
|
+
// little under the deck's figure, so the markers that replace what was cut can
|
|
961
|
+
// never carry a trimmed body back over it.
|
|
962
|
+
const MAX_EVENT_CHARS = 4_900_000;
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* The event as one line under MAX_EVENT_CHARS, or null when cutting its fields
|
|
966
|
+
* cannot get it there. The biggest values are replaced by a marker saying how
|
|
967
|
+
* large they were, biggest first and only until it fits; a top-level object is
|
|
968
|
+
* opened one level, so an Edit keeps its file path and its strings and loses
|
|
969
|
+
* only `originalFile`. What the deck settles a call by — the event's name, the
|
|
970
|
+
* session, the tool, the call id, the cwd — is a short scalar and is never what
|
|
971
|
+
* goes. `serialized` is the event as it would have been sent whole.
|
|
972
|
+
*/
|
|
973
|
+
function withinSizeCap(parsed, serialized) {
|
|
974
|
+
const out = { ...parsed };
|
|
975
|
+
const slots = [];
|
|
976
|
+
for (const [key, value] of Object.entries(out)) {
|
|
977
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
978
|
+
const inner = out[key] = { ...value };
|
|
979
|
+
for (const [k, v] of Object.entries(inner)) slots.push({ holder: inner, key: k, size: JSON.stringify(v)?.length ?? 0 });
|
|
980
|
+
} else if (typeof value === "string" || Array.isArray(value)) {
|
|
981
|
+
slots.push({ holder: out, key, size: JSON.stringify(value).length });
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
slots.sort((a, b) => b.size - a.size);
|
|
985
|
+
out.ccdeck_truncated = true;
|
|
986
|
+
out.ccdeck_truncation_reason = "size";
|
|
987
|
+
// The two markers above add under 64 characters; the line is measured for
|
|
988
|
+
// real before it is returned, so this only has to know when to stop.
|
|
989
|
+
let size = serialized.length + 64;
|
|
990
|
+
for (const slot of slots) {
|
|
991
|
+
if (size <= MAX_EVENT_CHARS) break;
|
|
992
|
+
const marker = { ccdeck_truncated: true, chars: slot.size };
|
|
993
|
+
slot.holder[slot.key] = marker;
|
|
994
|
+
size += JSON.stringify(marker).length - slot.size;
|
|
995
|
+
}
|
|
996
|
+
const text = JSON.stringify(out);
|
|
997
|
+
return text.length <= MAX_EVENT_CHARS ? text : null;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** The event's scalar top-level fields alone, marked as cut and why — what is
|
|
1001
|
+
* sent when the event cannot be sent whole and nothing gentler fits. */
|
|
1002
|
+
function shallowStandIn(parsed, reason) {
|
|
1003
|
+
const shallow = {};
|
|
1004
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
1005
|
+
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
|
|
1006
|
+
shallow[key] = value;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
shallow.ccdeck_truncated = true;
|
|
1010
|
+
shallow.ccdeck_truncation_reason = reason;
|
|
1011
|
+
return JSON.stringify(shallow);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
945
1014
|
// JSON.parse can accept nesting that older supported Node releases cannot
|
|
946
1015
|
// stringify again. Count structural nesting directly in the original text so
|
|
947
1016
|
// the hook never depends on a runtime-specific recursion limit. Strings are
|
|
@@ -1088,15 +1157,17 @@ function main() {
|
|
|
1088
1157
|
// top-level metadata is safe to copy without walking the deep value,
|
|
1089
1158
|
// which preserves the event/session/tool identity, canonical cwd and
|
|
1090
1159
|
// provider while making the loss explicit to the deck. (#1180)
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1160
|
+
taggedInput = shallowStandIn(parsed, "serialization-depth");
|
|
1161
|
+
}
|
|
1162
|
+
// Too large for any deck to take — the same loss by the other door, and
|
|
1163
|
+
// the same answer: send what fits and say what was cut. See
|
|
1164
|
+
// MAX_EVENT_CHARS.
|
|
1165
|
+
if (taggedInput.length > MAX_EVENT_CHARS) {
|
|
1166
|
+
let fitted = null;
|
|
1167
|
+
// Measuring a field walks it, and a field too deep to serialize throws
|
|
1168
|
+
// here as it did above: the stand-in is the answer for that one too.
|
|
1169
|
+
try { fitted = withinSizeCap(parsed, taggedInput); } catch {}
|
|
1170
|
+
taggedInput = fitted ?? shallowStandIn(parsed, "size");
|
|
1100
1171
|
}
|
|
1101
1172
|
|
|
1102
1173
|
// See proveTargets for what the other order cost. A record whose pid is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.37.0",
|
|
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
|
@@ -183,11 +183,18 @@ export function needsSignIn(trouble, collector) {
|
|
|
183
183
|
* the new login has not happened yet, and saying "expired" over a sign-in that
|
|
184
184
|
* just succeeded is the one moment this must stay quiet.
|
|
185
185
|
*/
|
|
186
|
-
export function reauthFor({ entry, trouble, collector, fetchedAt = null, attemptedAt = null }) {
|
|
186
|
+
export function reauthFor({ entry, trouble, collector, fetchedAt = null, attemptedAt = null, verdictAt = null }) {
|
|
187
187
|
if (entry?.origin !== SIGNED_IN_HERE) return null;
|
|
188
188
|
if (!needsSignIn(trouble, collector)) return null;
|
|
189
189
|
const signedInAt = entry.signedInAt ?? 0;
|
|
190
190
|
if (!Number.isFinite(attemptedAt) || attemptedAt <= signedInAt) return null;
|
|
191
|
+
// A collector that stopped is a dead login only on claude-swap's own
|
|
192
|
+
// verdict, and a verdict asked before the last sign-in here is about the
|
|
193
|
+
// login that sign-in replaced. Without this, the first collection after a
|
|
194
|
+
// re-sign-in — which stamps its attempt as it claims the slot, long before
|
|
195
|
+
// it records anything — read as a refusal of the new login, on a row whose
|
|
196
|
+
// last good read is still from before (`cswap add` does not move it).
|
|
197
|
+
if (trouble?.kind === "stopped" && !(Number.isFinite(verdictAt) && verdictAt > signedInAt)) return null;
|
|
191
198
|
const since = Math.floor(Math.max(Number.isFinite(fetchedAt) ? fetchedAt : 0, signedInAt));
|
|
192
199
|
return { since, dismissed: entry.dismissed === since };
|
|
193
200
|
}
|
|
@@ -29,17 +29,19 @@ import { renameWithRetry } from "./atomic-write.mjs";
|
|
|
29
29
|
import { ccProjectSlug, claudeConfigDir } from "./claude-dir.mjs";
|
|
30
30
|
import { accountKey } from "./lan-sync.mjs";
|
|
31
31
|
import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from "./swap-log.mjs";
|
|
32
|
-
// The
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
import {
|
|
32
|
+
// The rules the live transcript scan counts a line's usage by, so this rollup
|
|
33
|
+
// and a session's card cannot disagree about which lines billed anything, nor
|
|
34
|
+
// about the speed they were billed at — see transcript-scan.mjs.
|
|
35
|
+
import {
|
|
36
|
+
billedSpeed, hasSpend, MAX_SPEEDS_PER_MODEL, repeatsRequestUsage, UNRECOGNISED_SPEED,
|
|
37
|
+
} from "./transcript-scan.mjs";
|
|
36
38
|
|
|
37
39
|
/** The pseudo-account for messages the swap log cannot place — everything
|
|
38
40
|
* before tracking began, or a gap. Shown once, apart from any real account, so
|
|
39
41
|
* a per-account total is never quietly inflated by work that is not that
|
|
40
42
|
* account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
|
|
41
43
|
export const UNATTRIBUTED = "\u0000unattributed";
|
|
42
|
-
const STATE_VERSION =
|
|
44
|
+
const STATE_VERSION = 4;
|
|
43
45
|
|
|
44
46
|
/** Where the tally and cursors live, beside cswap-auto's own state. */
|
|
45
47
|
export function statePath(home = homedir()) {
|
|
@@ -94,12 +96,29 @@ function asUsageTotals(c) {
|
|
|
94
96
|
};
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
/** Add `src` into `dst`, and its speed shares into `dst`'s. */
|
|
97
100
|
function addInto(dst, src) {
|
|
98
101
|
dst.i += src.i; dst.o += src.o; dst.cr += src.cr;
|
|
99
102
|
dst.cc += src.cc; dst.c1h += src.c1h; dst.c5m += src.c5m;
|
|
103
|
+
if (src.s) for (const [speed, share] of Object.entries(src.s)) addInto(speedShareOf(dst, speed), share);
|
|
100
104
|
return dst;
|
|
101
105
|
}
|
|
102
106
|
|
|
107
|
+
/** The share of `counters` billed at `speed` (#754), created on first sight:
|
|
108
|
+
* `s[speed]`, a SUBSET of the counters it sits on, the way the 1h/5m split is
|
|
109
|
+
* of `cc`, so the totals read exactly as they did and the client prices the
|
|
110
|
+
* share at its own speed's rate. Capped like a session's buckets in
|
|
111
|
+
* transcript-scan.mjs, the value being bytes out of a transcript; whatever is
|
|
112
|
+
* past the cap is kept as UNRECOGNISED_SPEED, which no rate table names, so it
|
|
113
|
+
* is counted and left unpriced rather than priced at the standard rate. */
|
|
114
|
+
function speedShareOf(counters, speed) {
|
|
115
|
+
const shares = counters.s ??= {};
|
|
116
|
+
if (Object.hasOwn(shares, speed)) return shares[speed];
|
|
117
|
+
const key = Object.keys(shares).length < MAX_SPEEDS_PER_MODEL ? speed : UNRECOGNISED_SPEED;
|
|
118
|
+
if (!Object.hasOwn(shares, key)) shares[key] = zero();
|
|
119
|
+
return shares[key];
|
|
120
|
+
}
|
|
121
|
+
|
|
103
122
|
function zero() { return { i: 0, o: 0, cr: 0, cc: 0, c1h: 0, c5m: 0 }; }
|
|
104
123
|
|
|
105
124
|
/** Local calendar day of a timestamp, `YYYY-MM-DD`. Local because that is the
|
|
@@ -227,7 +246,12 @@ export function foldLine(tally, line, timeline, from) {
|
|
|
227
246
|
const key = who ? accountKey(who.email, who.orgUuid) : UNATTRIBUTED;
|
|
228
247
|
const day = localDay(ts);
|
|
229
248
|
const proj = (((tally[key] ??= {})[cwd] ??= {})[day] ??= {});
|
|
230
|
-
addInto(proj[model] ??= zero(), c);
|
|
249
|
+
const counters = addInto(proj[model] ??= zero(), c);
|
|
250
|
+
// A fast turn costs more than a standard one of the same model, and the
|
|
251
|
+
// board prices it so (#754). Kept as a share rather than a model of its own,
|
|
252
|
+
// so a row's tokens and its model read exactly as before.
|
|
253
|
+
const speed = billedSpeed(obj);
|
|
254
|
+
if (speed) addInto(speedShareOf(counters, speed), c);
|
|
231
255
|
}
|
|
232
256
|
|
|
233
257
|
/** Read the bytes of `path` from `start` to `size`, folding each COMPLETE line,
|
|
@@ -413,10 +437,11 @@ export function createProjectRollup({
|
|
|
413
437
|
if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
|
|
414
438
|
state = disk;
|
|
415
439
|
state.folders ??= {};
|
|
416
|
-
} else if (disk && (disk.version === 1 || disk.version === 2)) {
|
|
440
|
+
} else if (disk && (disk.version === 1 || disk.version === 2 || disk.version === 3)) {
|
|
417
441
|
// Version 1 counted every API content block; version 2 keyed each row
|
|
418
|
-
// by the line's own cwd, a row per folder the agent cd'd into (#1278)
|
|
419
|
-
//
|
|
442
|
+
// by the line's own cwd, a row per folder the agent cd'd into (#1278);
|
|
443
|
+
// version 3 kept no speed, so every fast turn read at the standard
|
|
444
|
+
// rate. None of them can be re-keyed, since it no longer knows which
|
|
420
445
|
// transcript a count came from. Keep the heartbeat so a restart gap
|
|
421
446
|
// remains fenced, but rebuild the tally from transcripts.
|
|
422
447
|
state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, lastAlive: disk.lastAlive };
|
|
@@ -18,31 +18,60 @@
|
|
|
18
18
|
// never a time.
|
|
19
19
|
//
|
|
20
20
|
// The boot (bin/deck.js) is what knows the setup, and it reaches this module
|
|
21
|
-
// through noteSetup.
|
|
21
|
+
// through noteSetup. A respawn installs the hooks only when the package changed,
|
|
22
|
+
// so the supervisor carries the first boot's answer down to it (respawnHooksJob).
|
|
23
|
+
// This file imports nothing, so the boot can load it early.
|
|
22
24
|
|
|
23
25
|
/** Not known yet, until the boot says. */
|
|
24
26
|
let setup = null;
|
|
25
27
|
let settle;
|
|
26
28
|
const known = new Promise(r => { settle = r; });
|
|
27
29
|
|
|
30
|
+
/** What the boot can say about the Claude hooks. */
|
|
31
|
+
const HOOK_STATES = ["ok", "failed", "off"];
|
|
32
|
+
|
|
33
|
+
/** A hook job's answer as its token: null is "off" (not attempted), `{ ok }` is
|
|
34
|
+
* "ok" or "failed", and a token — the one a respawn was handed down — is kept
|
|
35
|
+
* when it is one. Anything else cannot be told, and is left out. */
|
|
36
|
+
function hooksState(job) {
|
|
37
|
+
if (job == null) return "off";
|
|
38
|
+
if (typeof job === "string") return HOOK_STATES.includes(job) ? job : undefined;
|
|
39
|
+
return job.ok ? "ok" : "failed";
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
/**
|
|
29
43
|
* What the boot set up. `claude` is the hook install job — a promise of null
|
|
30
|
-
* (not attempted), `{ ok: true }` or `{ ok: false }
|
|
31
|
-
*
|
|
32
|
-
*
|
|
44
|
+
* (not attempted), `{ ok: true }` or `{ ok: false }`, or on a respawn the
|
|
45
|
+
* first boot's token (respawnHooksJob) — or null when Claude is not wanted;
|
|
46
|
+
* `codex` is whether the rollout watcher runs. Never throws, and a job that
|
|
47
|
+
* rejects reads as "failed". Resolves to the setup once it is known, for the
|
|
48
|
+
* boot to hand the hooks' answer to its supervisor.
|
|
33
49
|
*/
|
|
34
50
|
export function noteSetup({ claude = null, codex = false } = {}) {
|
|
35
|
-
Promise.resolve(claude)
|
|
36
|
-
.then(
|
|
37
|
-
job => (job == null ? "off" : job.ok ? "ok" : "failed"),
|
|
38
|
-
() => "failed",
|
|
39
|
-
)
|
|
51
|
+
return Promise.resolve(claude)
|
|
52
|
+
.then(hooksState, () => "failed")
|
|
40
53
|
.then(claudeHooks => {
|
|
41
|
-
setup = {
|
|
54
|
+
setup = { codexWatch: codex ? "on" : "off" };
|
|
55
|
+
if (claudeHooks) setup.claudeHooks = claudeHooks;
|
|
42
56
|
settle();
|
|
57
|
+
return { ...setup };
|
|
43
58
|
});
|
|
44
59
|
}
|
|
45
60
|
|
|
61
|
+
/**
|
|
62
|
+
* A respawn's hook job, for noteSetup: what this process saw, never a success
|
|
63
|
+
* it did not. A respawn re-installs the hooks only when the package under the
|
|
64
|
+
* session has changed (respawnHooks, bin/cli/startup.js), and then it is that
|
|
65
|
+
* install's own answer. Otherwise it installed nothing, and the answer is the
|
|
66
|
+
* session's first boot's — `carried`, which the supervisor hands down in
|
|
67
|
+
* AGENTS_DECK_BOOT_HOOKS — or, under a supervisor that handed nothing down,
|
|
68
|
+
* none at all.
|
|
69
|
+
*/
|
|
70
|
+
export function respawnHooksJob({ wantClaude, reinstall, carried }) {
|
|
71
|
+
if (!wantClaude) return null;
|
|
72
|
+
return Promise.resolve(reinstall).then(job => job ?? String(carried ?? ""));
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
/** The setup facts, or none while the boot has not said. */
|
|
47
76
|
export function setupFacts() {
|
|
48
77
|
return setup ? { ...setup } : {};
|
package/src/server/app-host.mjs
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
// Environment, not flags: the supervisor re-spawns the worker with its own
|
|
21
21
|
// argv, and the environment is what both of them inherit unchanged.
|
|
22
22
|
|
|
23
|
+
import { stableNodePath } from "./stable-node.mjs";
|
|
24
|
+
|
|
23
25
|
/** "1" when the desktop app started this deck. */
|
|
24
26
|
export const APP_ENV = "CCDECK_APP";
|
|
25
27
|
|
|
@@ -31,7 +33,10 @@ export function inApp(env = process.env) {
|
|
|
31
33
|
return env[APP_ENV] === "1";
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
/** Outside the app, this node under the name an upgrade does not delete — see
|
|
37
|
+
* stable-node.mjs. `execPath` is taken as given when a caller names one. */
|
|
38
|
+
export function hookRuntime(env = process.env, execPath = undefined) {
|
|
35
39
|
const given = env[HOOK_RUNTIME_ENV];
|
|
36
|
-
|
|
40
|
+
if (typeof given === "string" && given.trim()) return given;
|
|
41
|
+
return execPath ?? stableNodePath({ env });
|
|
37
42
|
}
|
package/src/server/args.mjs
CHANGED
|
@@ -245,6 +245,9 @@ export function parseArgs(args) {
|
|
|
245
245
|
else if (a === "--no-codex") out.noCodex = true;
|
|
246
246
|
else if (a === "--claude") out.claude = true;
|
|
247
247
|
else if (a === "--no-claude") out.noClaude = true;
|
|
248
|
+
// The ccdeck.dev page the command was copied from, for the first install
|
|
249
|
+
// report (install-ref.mjs). A value that is not a ref is dropped there.
|
|
250
|
+
else if (a === "--ref") set("ref", "a page name");
|
|
248
251
|
else out.unknown.push(argv[i]); // the token as typed, `=` half included
|
|
249
252
|
}
|
|
250
253
|
return out;
|
|
@@ -45,30 +45,37 @@ export async function handleBrowserWatchSettings(req, res) {
|
|
|
45
45
|
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
46
46
|
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
47
47
|
|
|
48
|
-
const {
|
|
48
|
+
const { updateStore, normalise } = await import(
|
|
49
49
|
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
|
|
50
50
|
);
|
|
51
51
|
const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
|
|
52
52
|
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
|
|
53
53
|
);
|
|
54
|
-
const store = await readStore();
|
|
55
|
-
// normalise() is the one place a value is judged, so a field this route has
|
|
56
|
-
// never heard of cannot arrive through it and a bad one falls back rather
|
|
57
|
-
// than reaching classify().
|
|
58
|
-
const settings = normalise({ ...store.settings, ...body });
|
|
59
54
|
// `dismissed` carried forward, and it has to be spelled: writeStore takes a
|
|
60
55
|
// whole state and writes exactly what it is handed, so a caller that omits
|
|
61
56
|
// this field ERASES it. Measured — changing the reaction wiped every
|
|
62
57
|
// dismissal, so every episode the reader had reviewed came straight back on
|
|
63
58
|
// the next poll, from a settings change that had nothing to do with them.
|
|
64
|
-
// Only the settings are this route's to change.
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
59
|
+
// Only the settings are this route's to change.
|
|
60
|
+
//
|
|
61
|
+
// AND THE PATCH IS MERGED INSIDE THE JOB, against the state the queue hands
|
|
62
|
+
// in. Each control in the panel sends one field, and this used to read the
|
|
63
|
+
// store before the queue and build the whole settings object from that: a
|
|
64
|
+
// second change whose read came before the first one's write put the first
|
|
65
|
+
// field back — the watch switched off, the reaction changed straight after,
|
|
66
|
+
// and the watch was on again. Merged against `cur`, two one-field patches
|
|
67
|
+
// cannot lose each other, and neither can a poll's archive.
|
|
68
|
+
let before;
|
|
69
|
+
const { settings } = await updateStore(cur => {
|
|
70
|
+
before = cur.settings;
|
|
71
|
+
// normalise() is the one place a value is judged, so a field this route
|
|
72
|
+
// has never heard of cannot arrive through it and a bad one falls back
|
|
73
|
+
// rather than reaching classify().
|
|
74
|
+
return { ...cur, settings: normalise({ ...cur.settings, ...body }) };
|
|
75
|
+
});
|
|
69
76
|
// The one line in the log that is somebody acting rather than the deck
|
|
70
77
|
// reading, which is exactly why it is worth its own entry.
|
|
71
|
-
if (settings.enabled !==
|
|
78
|
+
if (settings.enabled !== before.enabled) {
|
|
72
79
|
noteWatchSetting(settings.enabled ? "watch on — keeping its own copy" : "watch off — reading live only");
|
|
73
80
|
} else {
|
|
74
81
|
noteWatchSetting(`settings: quiet ${settings.quietMinutes}m, gap ${settings.gapMinutes}m`);
|
|
@@ -107,10 +114,10 @@ export async function handleBrowserWatchDismiss(req, res) {
|
|
|
107
114
|
const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
|
|
108
115
|
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
|
|
109
116
|
);
|
|
110
|
-
// COMPUTED INSIDE THE JOB, like the settings route
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
117
|
+
// COMPUTED INSIDE THE JOB, like the settings route above, and for the same
|
|
118
|
+
// reason: updateStore re-reads inside the write queue, and only what is
|
|
119
|
+
// computed from that read can never be stale. This read the whole
|
|
120
|
+
// `dismissed` array before the queue, so
|
|
114
121
|
// two Dismiss presses in one turn both read before either job ran and the
|
|
115
122
|
// second wrote an array without the first key — both rows left the list,
|
|
116
123
|
// both answered 200, and the first came back on the next ten-second poll.
|
|
@@ -278,8 +278,9 @@ function occupy(read) {
|
|
|
278
278
|
*
|
|
279
279
|
* It costs a `dig`, a `pgrep` per browser and an `lsof` per running one — up to
|
|
280
280
|
* a dozen subprocesses — and none of what it reports moves quickly: a browser
|
|
281
|
-
* does not get installed twice a minute. Thirty seconds is far
|
|
282
|
-
*
|
|
281
|
+
* does not get installed twice a minute. Thirty seconds is far above the rate
|
|
282
|
+
* a person clicks Refresh. Only the panel's own reads come here: the badge's
|
|
283
|
+
* background poll does not survey (see browserWatchSnapshot).
|
|
283
284
|
*/
|
|
284
285
|
const SURVEY_TTL_MS = 30_000;
|
|
285
286
|
let surveyCache = { atMs: 0, rows: [] };
|
|
@@ -857,7 +858,12 @@ export async function browserWatchSnapshot({
|
|
|
857
858
|
// database and can take a second.
|
|
858
859
|
_checkedMs = now;
|
|
859
860
|
|
|
860
|
-
|
|
861
|
+
// THE SURVEY ONLY FOR SOMEBODY LOOKING. Its first step is a `dig` for the
|
|
862
|
+
// relay's address, a DNS query to whatever resolver the machine uses, which
|
|
863
|
+
// leaves the machine — and the badge's background poll reads none of what it
|
|
864
|
+
// finds, only the episodes and the switch. So that poll answers with the last
|
|
865
|
+
// survey the panel asked for, and asks nobody anything.
|
|
866
|
+
const browsers = readBrowsers ? await surveyBrowsers(platform, env, now, deps) : surveyCache.rows;
|
|
861
867
|
// Two small reads, and the answer to the one question this panel exists
|
|
862
868
|
// beside: whether somebody else's Claude Code can drive this browser (#799).
|
|
863
869
|
const relay = await relayGuard(profiles, { platform, env, deps }).catch(() => null);
|
|
@@ -42,7 +42,7 @@ import { withStoreLock } from "./store-lock.mjs";
|
|
|
42
42
|
// read off the store. The roster puts them on each row; verdictNow and
|
|
43
43
|
// verdictsNow are re-exported because cswap-admin.mjs and lan-deck.mjs reach
|
|
44
44
|
// them through this module.
|
|
45
|
-
import { verdictFor } from "./claude-verdicts.mjs";
|
|
45
|
+
import { verdictFor, verdictsAskedAt } from "./claude-verdicts.mjs";
|
|
46
46
|
export { verdictNow, verdictsNow } from "./claude-verdicts.mjs";
|
|
47
47
|
// Asking claude-swap to collect, which every roster read does when something is
|
|
48
48
|
// due, and when each account will next be read.
|
|
@@ -387,13 +387,20 @@ function originsNow() {
|
|
|
387
387
|
* Local network — exactly what the mark must never be. Safe to decide off one
|
|
388
388
|
* read: claude-swap replaces sequence.json whole (temp file and rename), and an
|
|
389
389
|
* import refreshes a slot in place rather than removing it first. Asked of every
|
|
390
|
-
* account in the store, not of the ordered list drawn above
|
|
391
|
-
*
|
|
392
|
-
*
|
|
390
|
+
* account in the store, not of the ordered list drawn above.
|
|
391
|
+
*
|
|
392
|
+
* AN EMPTIED STORE IS AN ANSWER TOO. Removing the last account leaves
|
|
393
|
+
* sequence.json with `accounts: {}`, and this used to be the one store the
|
|
394
|
+
* read waited out — but a mark kept there is inherited by the same address
|
|
395
|
+
* pasted from a share later, which then reads as one this deck signed in and
|
|
396
|
+
* is prompted for as one. Only a file that could not be read says nothing, and
|
|
397
|
+
* readRoster never gets this far with one. `held` that is not a map of slots is
|
|
398
|
+
* no store at all, and forgets nothing.
|
|
393
399
|
*/
|
|
394
400
|
function tidyOrigins(origins, recovered, held) {
|
|
395
|
-
const
|
|
396
|
-
const
|
|
401
|
+
const isStore = held != null && typeof held === "object" && !Array.isArray(held);
|
|
402
|
+
const present = new Set(Object.values(isStore ? held : {}).map(a => accountKey(a?.email, a?.organizationUuid)));
|
|
403
|
+
const gone = isStore ? Object.keys(origins).filter(k => !present.has(k)) : [];
|
|
397
404
|
if (!recovered.length && !gone.length) return;
|
|
398
405
|
// `seen` rides along so the write changes only what this read judged: a mark
|
|
399
406
|
// re-made, or an incident put off, since then is not this read's to undo.
|
|
@@ -530,7 +537,9 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
|
|
|
530
537
|
].filter(Boolean);
|
|
531
538
|
|
|
532
539
|
const collector = verdictFor(num, now, acct.email, acct.organizationUuid);
|
|
533
|
-
const reauth = reauthFor({
|
|
540
|
+
const reauth = reauthFor({
|
|
541
|
+
entry: origin, trouble, collector, fetchedAt: fetchedAtMs, attemptedAt: attemptedAtMs, verdictAt: verdictsAskedAt(),
|
|
542
|
+
});
|
|
534
543
|
return {
|
|
535
544
|
num: Number(num),
|
|
536
545
|
email: acct.email ?? null,
|
|
@@ -753,6 +762,17 @@ export function invalidateClaudeAccountsCache() {
|
|
|
753
762
|
_lastReadAt = 0;
|
|
754
763
|
}
|
|
755
764
|
|
|
765
|
+
/**
|
|
766
|
+
* How many accounts the last roster read held, for the daily report's count
|
|
767
|
+
* (depth-facts.mjs): 0 when claude-swap is installed with nothing added, null
|
|
768
|
+
* before any read or when the read could not say. Answered from the cache —
|
|
769
|
+
* never starts a read, so the report costs claude-swap nothing.
|
|
770
|
+
*/
|
|
771
|
+
export function knownAccountCount() {
|
|
772
|
+
if (_cache?.ok && Array.isArray(_cache.accounts)) return _cache.accounts.length;
|
|
773
|
+
return _cache?.reason === "no_accounts" ? 0 : null;
|
|
774
|
+
}
|
|
775
|
+
|
|
756
776
|
/**
|
|
757
777
|
* The slot an account argument names, as a number, or null when it names none.
|
|
758
778
|
*
|
|
@@ -106,6 +106,10 @@ export async function verdictNow(email, org, { runner = run, bin = cswapBin } =
|
|
|
106
106
|
* collection that can take a minute on a cold network.
|
|
107
107
|
*/
|
|
108
108
|
async function collectVerdicts({ runner, bin }) {
|
|
109
|
+
// Stamped when the question was ASKED, not when it was answered. A
|
|
110
|
+
// collection that began before a sign-in reports on the login it began
|
|
111
|
+
// with, and the re-sign-in prompt must be able to tell (see verdictsAskedAt).
|
|
112
|
+
const asked = Date.now();
|
|
109
113
|
try {
|
|
110
114
|
const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
|
|
111
115
|
if (!out?.ok) return null;
|
|
@@ -119,7 +123,7 @@ async function collectVerdicts({ runner, bin }) {
|
|
|
119
123
|
identities[String(a.number)] = accountKey(a.email, a.organizationUuid);
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
|
-
_verdicts = { at:
|
|
126
|
+
_verdicts = { at: asked, byNum, identities };
|
|
123
127
|
return (Array.isArray(d?.accounts) ? d.accounts : []).map(a => ({
|
|
124
128
|
number: a?.number,
|
|
125
129
|
email: String(a?.email ?? "").trim().toLowerCase(),
|
|
@@ -143,6 +147,13 @@ export function verdictsNow({ runner = run, bin = cswapBin, fresh = false } = {}
|
|
|
143
147
|
return verdictQueue.ask({ fresh });
|
|
144
148
|
}
|
|
145
149
|
|
|
150
|
+
/** When the cached verdicts were asked for, in ms, or null before the first.
|
|
151
|
+
* A verdict asked before the deck last signed an account in is about the
|
|
152
|
+
* login that sign-in replaced — see reauthFor in account-origins.mjs. */
|
|
153
|
+
export function verdictsAskedAt() {
|
|
154
|
+
return _verdicts.at > 0 ? _verdicts.at : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
146
157
|
/** claude-swap's verdict for a slot, or null when there is none fresh enough. */
|
|
147
158
|
export function verdictFor(num, now, email, org) {
|
|
148
159
|
return cachedVerdictFor(_verdicts, num, now, email, org);
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
//
|
|
4
4
|
// SPAWNED, BUT NEVER ON THE BOOT PATH. claude-dir.mjs measured a Claude Code
|
|
5
5
|
// child at ~3.0s and says out loud that boot is the wrong place to spend one, so
|
|
6
|
-
// this
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// the
|
|
6
|
+
// this runs in the background — reports.mjs starts it once the deck is listening
|
|
7
|
+
// and the prefs say reports are on, and asks again on each new UTC day so a deck
|
|
8
|
+
// left running reports the CLI it has now. The first report waits a few seconds
|
|
9
|
+
// for the answer, beside the wait for the boot's setup, rather than going out
|
|
10
|
+
// without it. A version that is not known by then is simply not sent; the field
|
|
11
|
+
// is optional and the API records nothing for an absent one.
|
|
10
12
|
//
|
|
11
13
|
// WHAT LEAVES IS A VERSION AND NOTHING ELSE. parseVersion keeps only a
|
|
12
14
|
// dotted-number token (with the `^[0-9A-Za-z.+_-]+$` shape the API enforces, and
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// new access token is never treated as success, and nothing here throws —
|
|
14
14
|
// a rejected promise from a background poll would take the server down.
|
|
15
15
|
import { readFile, chmod, unlink } from "node:fs/promises";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
16
17
|
import { join } from "node:path";
|
|
17
18
|
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
18
19
|
import { createTemp, renameWithRetry, resolveWriteTarget } from "./atomic-write.mjs";
|
|
@@ -230,6 +231,16 @@ function refreshErrorCode(body) {
|
|
|
230
231
|
return typeof raw === "string" ? raw.toLowerCase() : null;
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
// The last refresh token the endpoint refused for good, by hash — the token
|
|
235
|
+
// itself is not kept. A refused token is dead until `codex login` replaces it,
|
|
236
|
+
// and the expiry-driven path asks for a refresh on every poll once the access
|
|
237
|
+
// token's `exp` has passed, so without this the same dead token was POSTed to
|
|
238
|
+
// auth.openai.com once a minute for as long as the panel stayed open. Matched
|
|
239
|
+
// against what auth.json holds at each attempt, so it lapses by itself the
|
|
240
|
+
// moment the file carries a different one.
|
|
241
|
+
let _refusedRefresh = null;
|
|
242
|
+
const tokenHash = (token) => createHash("sha256").update(String(token)).digest("hex");
|
|
243
|
+
|
|
233
244
|
/**
|
|
234
245
|
* Spend the refresh token. Never throws — every failure is a return value,
|
|
235
246
|
* because callers include a 60s background poll whose rejection would reach
|
|
@@ -262,6 +273,7 @@ async function doRefresh(auth) {
|
|
|
262
273
|
if (!res.ok) {
|
|
263
274
|
const code = refreshErrorCode(body);
|
|
264
275
|
const permanent = (code && PERMANENT_CODES.has(code)) || res.status === 401;
|
|
276
|
+
if (permanent) _refusedRefresh = tokenHash(auth.tokens.refresh_token);
|
|
265
277
|
return { ok: false, reason: permanent ? "refresh_rejected" : "refresh_failed", code };
|
|
266
278
|
}
|
|
267
279
|
|
|
@@ -322,6 +334,9 @@ function refreshCredentials({ ifStale = false, staleAccessToken = null } = {}) {
|
|
|
322
334
|
if (staleAccessToken && auth.tokens.access_token !== staleAccessToken) {
|
|
323
335
|
return { ok: true, auth }; // someone else already rotated past it
|
|
324
336
|
}
|
|
337
|
+
if (tokenHash(auth.tokens.refresh_token) === _refusedRefresh) {
|
|
338
|
+
return { ok: false, reason: "refresh_rejected", code: "refused_before" };
|
|
339
|
+
}
|
|
325
340
|
return doRefresh(auth);
|
|
326
341
|
});
|
|
327
342
|
}
|
|
@@ -374,6 +389,20 @@ export async function getCodexAuth({ allowRefresh = true } = {}) {
|
|
|
374
389
|
return identityFrom(auth, refreshed);
|
|
375
390
|
}
|
|
376
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Which credential auth.json holds right now, as a hash of its two tokens, or
|
|
394
|
+
* null when it holds neither. Never the tokens themselves.
|
|
395
|
+
*
|
|
396
|
+
* For codex-quota.mjs, whose cooldown after a refused login is about the
|
|
397
|
+
* credential that was refused: `codex login` writes a new pair, and a wait
|
|
398
|
+
* that outlived it answered the user's ↻ with the old refusal.
|
|
399
|
+
*/
|
|
400
|
+
export async function codexCredentialFingerprint() {
|
|
401
|
+
const tokens = (await readAuthFile())?.tokens;
|
|
402
|
+
if (!tokens?.access_token && !tokens?.refresh_token) return null;
|
|
403
|
+
return tokenHash(`${tokens.access_token ?? ""}\n${tokens.refresh_token ?? ""}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
377
406
|
/**
|
|
378
407
|
* Refresh because the backend rejected a token that looked valid locally —
|
|
379
408
|
* OpenAI revokes server-side, so the JWT's own `exp` is not the last word.
|