ccdeck 3.43.3 → 3.44.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 +74 -20
- package/THIRD_PARTY_NOTICES.md +43 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +60 -16
- package/dist/web/assets/{BrowserWatchModal-DZTMfoGp.js → BrowserWatchModal-DMIUygWz.js} +1 -1
- package/dist/web/assets/{RadioPanel-DFqW-MUV.js → RadioPanel-DNB_AHSs.js} +1 -1
- package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
- package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
- package/dist/web/assets/app-CQIstkMF.js +318 -0
- package/dist/web/assets/app-Ch-0CYlK.css +1 -0
- package/dist/web/assets/{index-DqxnkNs2.js → index-BQbOfxbL.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +40 -0
- package/src/server/account-notify.mjs +206 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +48 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +115 -14
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +30 -6
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/index.mjs +17 -1
- package/src/server/installer.mjs +149 -8
- package/src/server/lan-copies.mjs +43 -5
- package/src/server/lan-deck.mjs +3 -0
- package/src/server/lan-engine.mjs +7 -2
- package/src/server/lan-manifest.mjs +2 -2
- package/src/server/lan-serve.mjs +17 -2
- package/src/server/lan-sync.mjs +5 -5
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/pinned-build.mjs +5 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/process-command.mjs +4 -1
- package/src/server/reports.mjs +7 -1
- package/src/server/request-gates.mjs +7 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/traffic-radar-wire.mjs +4 -3
- package/src/server/usage-day.mjs +1 -1
- package/dist/web/assets/TrafficRadar-CPzsjc2O.js +0 -1
- package/dist/web/assets/UsageHistoryModal-CfVS-N_Z.js +0 -1
- package/dist/web/assets/app-DsHSufBt.css +0 -1
- package/dist/web/assets/app-HpFVXK_g.js +0 -310
|
@@ -39,10 +39,11 @@ import { currentIdentity } from "./claude-identity.mjs";
|
|
|
39
39
|
// cycle is a lock that may not be there yet when a mutation wants it.
|
|
40
40
|
import { withStoreLock } from "./store-lock.mjs";
|
|
41
41
|
// claude-swap's own per-slot verdicts, which have to be ASKED for rather than
|
|
42
|
-
// read off the store. The roster puts them on each row
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
42
|
+
// read off the store. The roster puts them on each row, and a switch asks for a
|
|
43
|
+
// collection through the same queue before it reads its target (#2184);
|
|
44
|
+
// verdictNow and verdictsNow are re-exported because cswap-admin.mjs and
|
|
45
|
+
// lan-deck.mjs reach them through this module.
|
|
46
|
+
import { verdictFor, verdictsAskedAt, verdictsNow } from "./claude-verdicts.mjs";
|
|
46
47
|
export { verdictNow, verdictsNow } from "./claude-verdicts.mjs";
|
|
47
48
|
// Asking claude-swap to collect, which every roster read does when something is
|
|
48
49
|
// due, and when each account will next be read.
|
|
@@ -51,6 +52,9 @@ import { nextReadAt, nudgeCollector } from "./claude-collector.mjs";
|
|
|
51
52
|
// (#1893). Pure, and keyed the way LAN sync keys accounts.
|
|
52
53
|
import { hasRecovered, reauthFor } from "./account-origins.mjs";
|
|
53
54
|
import { accountKey } from "./lan-copies.mjs";
|
|
55
|
+
// Whether a switch target's stored login is known dead, and whether a
|
|
56
|
+
// collection would read it first (#2184). Pure, and imports nothing.
|
|
57
|
+
import { collectionWouldRead, deadLoginOf } from "./login-guard.mjs";
|
|
54
58
|
import { dirname, join } from "node:path";
|
|
55
59
|
import { existsSync } from "node:fs";
|
|
56
60
|
import { homedir, platform } from "node:os";
|
|
@@ -628,8 +632,8 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
|
|
|
628
632
|
* It says nothing about whether the user is signed in. Measured on the machine
|
|
629
633
|
* that reported this, at the same instant:
|
|
630
634
|
*
|
|
631
|
-
* claude auth status --json -> loggedIn: true,
|
|
632
|
-
* cswap list --json ->
|
|
635
|
+
* claude auth status --json -> loggedIn: true, carol@example.net
|
|
636
|
+
* cswap list --json -> carol@example.net: relogin_required
|
|
633
637
|
* GET /api/quota -> source: cli, 5h 33%, 7d 37%
|
|
634
638
|
*
|
|
635
639
|
* The user had signed in again in a terminal, which refreshes the LIVE
|
|
@@ -796,6 +800,82 @@ export function slotNumber(accountNum) {
|
|
|
796
800
|
return Number.isInteger(num) && num >= 1 && num <= 999 ? num : null;
|
|
797
801
|
}
|
|
798
802
|
|
|
803
|
+
// ── before a switch: is the target's login known dead? (#2184) ──────────────
|
|
804
|
+
|
|
805
|
+
/** How long a switch waits for claude-swap to read its target first. A cold
|
|
806
|
+
* collection can take a minute; a switch that waited that long would read as
|
|
807
|
+
* a hang, and what it could not learn in time it does not hold against the
|
|
808
|
+
* target. */
|
|
809
|
+
const SWITCH_READ_WAIT_MS = 15_000;
|
|
810
|
+
|
|
811
|
+
/** Slot `num` as the store holds it — the account, its usage row and whether
|
|
812
|
+
* that row is the account's own — or null when there is no such slot. */
|
|
813
|
+
async function switchTarget(num) {
|
|
814
|
+
const root = backupRoot();
|
|
815
|
+
const seq = await readSequence(root);
|
|
816
|
+
const key = String(num);
|
|
817
|
+
const acct = seq?.accounts?.[key];
|
|
818
|
+
if (!acct) return null;
|
|
819
|
+
const row = (await readUsageRows(root))[key];
|
|
820
|
+
return { acct, row, matches: rowIsFor(row, acct), active: activeSlot(seq) === key };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Ask claude-swap to read slot `num` before a switch onto it, when a
|
|
825
|
+
* collection would read it at all — see login-guard.mjs for why that is the
|
|
826
|
+
* only check 0.26.0 offers, and the incident it would have caught.
|
|
827
|
+
*
|
|
828
|
+
* Shares the verdict queue's `cswap list --json`, so it never starts a second
|
|
829
|
+
* collection beside one already running, and waits for it no longer than
|
|
830
|
+
* SWITCH_READ_WAIT_MS. Outside the store lock: a collection takes none of the
|
|
831
|
+
* deck's, and a mutation queued behind this one must not wait out somebody
|
|
832
|
+
* else's network. Never throws — a check that cannot be made is no answer.
|
|
833
|
+
*/
|
|
834
|
+
export async function readBeforeSwitch(num, { now = Date.now(), collect = () => verdictsNow(), waitMs = SWITCH_READ_WAIT_MS } = {}) {
|
|
835
|
+
let timer = null;
|
|
836
|
+
try {
|
|
837
|
+
const t = await switchTarget(num);
|
|
838
|
+
if (!t || t.active || deadLoginOf(t.row, { matches: t.matches })) return false;
|
|
839
|
+
if (!collectionWouldRead(t.row, { matches: t.matches, now })) return false;
|
|
840
|
+
const waited = new Promise(resolve => { timer = setTimeout(resolve, waitMs); timer.unref?.(); });
|
|
841
|
+
await Promise.race([Promise.resolve().then(collect).catch(() => null), waited]);
|
|
842
|
+
return true;
|
|
843
|
+
} catch {
|
|
844
|
+
return false;
|
|
845
|
+
} finally {
|
|
846
|
+
if (timer) clearTimeout(timer);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* The refusal for a switch onto slot `num`, when claude-swap's store says its
|
|
852
|
+
* stored login is dead — or null, which is every case it cannot say so,
|
|
853
|
+
* including a store it could not read.
|
|
854
|
+
*
|
|
855
|
+
* `{ ok: false, reason: "login_expired", account: { num, email, alias }, error,
|
|
856
|
+
* since }`, so the panel can name the account and offer the sign-in that fixes
|
|
857
|
+
* it. A switch to the slot that is already active is not this question: it
|
|
858
|
+
* writes nothing live.
|
|
859
|
+
*/
|
|
860
|
+
export async function expiredSwitchTarget(num) {
|
|
861
|
+
try {
|
|
862
|
+
const t = await switchTarget(num);
|
|
863
|
+
if (!t || t.active) return null;
|
|
864
|
+
const signedIn = originsNow()[accountKey(t.acct.email, t.acct.organizationUuid)]?.signedInAt;
|
|
865
|
+
const dead = deadLoginOf(t.row, { matches: t.matches, signedInAt: signedIn > 0 ? signedIn : null });
|
|
866
|
+
if (!dead) return null;
|
|
867
|
+
return {
|
|
868
|
+
ok: false,
|
|
869
|
+
reason: "login_expired",
|
|
870
|
+
account: { num, email: t.acct.email ?? null, alias: t.acct.alias ?? null },
|
|
871
|
+
error: dead.error,
|
|
872
|
+
since: dead.since,
|
|
873
|
+
};
|
|
874
|
+
} catch {
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
799
879
|
/**
|
|
800
880
|
* Switch the active Claude account by delegating to `cswap`.
|
|
801
881
|
*
|
|
@@ -817,8 +897,25 @@ export function slotNumber(accountNum) {
|
|
|
817
897
|
* The validation stays OUTSIDE the lock. A slot number that is not one is not a
|
|
818
898
|
* mutation and must not wait behind somebody else's sixty-second `cswap add`
|
|
819
899
|
* to be told so.
|
|
900
|
+
*
|
|
901
|
+
* AND IT DOES NOT SWITCH ONTO A LOGIN KNOWN TO BE DEAD (#2184). Every session
|
|
902
|
+
* on the machine runs on the login this puts live, so a dead one stops all of
|
|
903
|
+
* them at once. The target is read first when a collection would read it
|
|
904
|
+
* (readBeforeSwitch), and judged inside the lock, on the store as it stands
|
|
905
|
+
* when `cswap switch` would run (expiredSwitchTarget). Anything that cannot be
|
|
906
|
+
* judged lets the switch go ahead.
|
|
907
|
+
*
|
|
908
|
+
* `before`, when given, is asked first thing inside the lock: a switch nobody
|
|
909
|
+
* pressed — the auto-switch failover — may have been turned off while it
|
|
910
|
+
* queued, and answers `{ skipped: <why> }` instead of moving the account.
|
|
911
|
+
*
|
|
912
|
+
* `force` is `cswap switch --force`: activate the target without backing up
|
|
913
|
+
* the current login first. Only for a current login already known to be dead.
|
|
914
|
+
* Claude Code clears a login whose refresh was refused, and a plain switch then
|
|
915
|
+
* refuses in turn ("Current account credential is empty …; refusing to
|
|
916
|
+
* overwrite its backup"), which would leave the user exactly where they were.
|
|
820
917
|
*/
|
|
821
|
-
export function switchClaudeAccount(accountNum) {
|
|
918
|
+
export function switchClaudeAccount(accountNum, { before, force = false } = {}) {
|
|
822
919
|
// Straight into an exec argument, so nothing but a slot number gets through.
|
|
823
920
|
const num = slotNumber(accountNum);
|
|
824
921
|
if (num == null) {
|
|
@@ -827,13 +924,17 @@ export function switchClaudeAccount(accountNum) {
|
|
|
827
924
|
|
|
828
925
|
// Argument vector, never a shell — and resolved through exec.mjs so the
|
|
829
926
|
// Windows `.exe`/`.cmd` shim is found too.
|
|
830
|
-
return
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
})
|
|
927
|
+
return readBeforeSwitch(num).then(() => withStoreLock(async () => {
|
|
928
|
+
const skipped = before ? await before() : null;
|
|
929
|
+
if (skipped) return { ok: false, skipped };
|
|
930
|
+
const expired = await expiredSwitchTarget(num);
|
|
931
|
+
if (expired) return expired;
|
|
932
|
+
const argv = force ? ["switch", String(num), "--force"] : ["switch", String(num)];
|
|
933
|
+
const r = await run(await cswapBin(), argv, { timeout: 30_000 });
|
|
934
|
+
if (r.ok) return { ok: true, output: r.stdout.trim() };
|
|
935
|
+
const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
|
|
936
|
+
return { ok: false, reason, output: failureDetail(r, 500) };
|
|
937
|
+
}));
|
|
837
938
|
}
|
|
838
939
|
|
|
839
940
|
/**
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// to "where does the `claude` command live", and the deck now asks that question
|
|
20
20
|
// at boot, before any quota exists, to decide whether this is a Claude machine
|
|
21
21
|
// at all. One list, one caller-visible name, no second spelling to drift.
|
|
22
|
-
import { existsSync } from "node:fs";
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { join, resolve, posix as posixPath, win32 as winPath } from "node:path";
|
|
25
25
|
|
|
@@ -131,15 +131,26 @@ function claudeCliOnDisk({
|
|
|
131
131
|
home = homedir(),
|
|
132
132
|
exists = existsSync,
|
|
133
133
|
} = {}) {
|
|
134
|
+
for (const _ of claudeCliFound({ platform, env, home, exists })) return true;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Every `claude` the walk above finds, in the order it finds them — the known
|
|
140
|
+
* install paths first, then each PATH entry. claudeCliOnDisk stops at the first;
|
|
141
|
+
* claudeCodeVersions below reads them all, because an old CLI anywhere on the
|
|
142
|
+
* machine still reads the settings file a new one does.
|
|
143
|
+
*/
|
|
144
|
+
function* claudeCliFound({ platform, env, home, exists }) {
|
|
134
145
|
const path = platform === "win32" ? winPath : posixPath;
|
|
135
146
|
const bare = [];
|
|
136
147
|
for (const candidate of claudeCliCandidates(platform, env, home)) {
|
|
137
148
|
// A full path is worth a single stat; a bare name means "ask PATH", which
|
|
138
149
|
// is the walk below. Same split quotaClaudeBin makes, for the same reason.
|
|
139
|
-
if (candidate.includes(path.sep)) { if (exists(candidate))
|
|
150
|
+
if (candidate.includes(path.sep)) { if (exists(candidate)) yield candidate; }
|
|
140
151
|
else bare.push(candidate);
|
|
141
152
|
}
|
|
142
|
-
if (bare.length === 0) return
|
|
153
|
+
if (bare.length === 0) return;
|
|
143
154
|
|
|
144
155
|
// process.env is case-insensitive on Windows, but an injected plain object in
|
|
145
156
|
// a test is not, and %Path% is how the variable is actually spelled there.
|
|
@@ -156,10 +167,136 @@ function claudeCliOnDisk({
|
|
|
156
167
|
const dir = entry.trim().replace(/^"+|"+$/g, "");
|
|
157
168
|
if (dir === "") continue;
|
|
158
169
|
for (const name of bare) {
|
|
159
|
-
for (const ext of exts)
|
|
170
|
+
for (const ext of exts) {
|
|
171
|
+
const found = path.join(dir, name + ext);
|
|
172
|
+
if (exists(found)) yield found;
|
|
173
|
+
}
|
|
160
174
|
}
|
|
161
175
|
}
|
|
162
|
-
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A Claude Code version as its installers spell it: three numbers, and a
|
|
179
|
+
* pre-release or build suffix that does not change which release it is. */
|
|
180
|
+
const CC_VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.+-]*)?$/;
|
|
181
|
+
|
|
182
|
+
/** Whether `version` is `min` or newer, by the three numbers. */
|
|
183
|
+
export function claudeVersionAtLeast(version, min) {
|
|
184
|
+
const a = CC_VERSION_RE.exec(String(version ?? ""));
|
|
185
|
+
const b = CC_VERSION_RE.exec(String(min ?? ""));
|
|
186
|
+
if (!a || !b) return false;
|
|
187
|
+
for (let i = 1; i <= 3; i++) {
|
|
188
|
+
const d = Number(a[i]) - Number(b[i]);
|
|
189
|
+
if (d !== 0) return d > 0;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The npm package every npm install of Claude Code is. */
|
|
195
|
+
const CC_PACKAGE = "@anthropic-ai/claude-code";
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The version of the one install `bin` belongs to, read off disk, or
|
|
199
|
+
* undefined when its layout does not say.
|
|
200
|
+
*
|
|
201
|
+
* npm the package's own package.json, found by walking up from the
|
|
202
|
+
* file the bin resolves to — or, for the `claude.cmd` shim npm
|
|
203
|
+
* writes on Windows, in the node_modules beside the shim
|
|
204
|
+
* native `~/.local/bin/claude` links to `…/claude/versions/<version>`;
|
|
205
|
+
* on Windows it is a copy, and the newest of those versions is the
|
|
206
|
+
* one the installer put there
|
|
207
|
+
* Homebrew the cask's binary lives at `…/Caskroom/claude-code/<version>/claude`
|
|
208
|
+
*
|
|
209
|
+
* The package.json comes first, and a version-shaped directory is believed only
|
|
210
|
+
* where those two layouts put one: a node manager keeps the NODE version in its
|
|
211
|
+
* paths (`…/installs/node/22.14.0/…`), and reading that as Claude Code's would
|
|
212
|
+
* let a machine with an old CLI through the gate.
|
|
213
|
+
*/
|
|
214
|
+
function installVersion(bin, { platform, home, exists, realpath, readText, list }) {
|
|
215
|
+
const path = platform === "win32" ? winPath : posixPath;
|
|
216
|
+
let real = bin;
|
|
217
|
+
try { real = realpath(bin); } catch { /* a dangling link reads as itself */ }
|
|
218
|
+
const fromPackage = file => {
|
|
219
|
+
try {
|
|
220
|
+
const pkg = JSON.parse(readText(file));
|
|
221
|
+
return pkg?.name === CC_PACKAGE && CC_VERSION_RE.test(pkg.version) ? pkg.version : undefined;
|
|
222
|
+
} catch { return undefined; }
|
|
223
|
+
};
|
|
224
|
+
const beside = path.join(path.dirname(bin), "node_modules", ...CC_PACKAGE.split("/"), "package.json");
|
|
225
|
+
if (exists(beside)) {
|
|
226
|
+
const v = fromPackage(beside);
|
|
227
|
+
if (v) return v;
|
|
228
|
+
}
|
|
229
|
+
let dir = path.dirname(real);
|
|
230
|
+
for (let i = 0; i < 5; i++) {
|
|
231
|
+
const file = path.join(dir, "package.json");
|
|
232
|
+
if (exists(file)) {
|
|
233
|
+
const v = fromPackage(file);
|
|
234
|
+
if (v) return v;
|
|
235
|
+
}
|
|
236
|
+
const up = path.dirname(dir);
|
|
237
|
+
if (up === dir) break;
|
|
238
|
+
dir = up;
|
|
239
|
+
}
|
|
240
|
+
const name = path.basename(real);
|
|
241
|
+
const parent = path.basename(path.dirname(real));
|
|
242
|
+
if (CC_VERSION_RE.test(name) && parent === "versions") return name;
|
|
243
|
+
if (CC_VERSION_RE.test(parent) && path.basename(path.dirname(path.dirname(real))) === "claude-code") return parent;
|
|
244
|
+
const nativeBin = path.join(home, ".local", "bin");
|
|
245
|
+
if (path.dirname(real) === nativeBin) {
|
|
246
|
+
try {
|
|
247
|
+
const versions = list(path.join(home, ".local", "share", "claude", "versions"))
|
|
248
|
+
.filter(v => CC_VERSION_RE.test(v));
|
|
249
|
+
let newest;
|
|
250
|
+
for (const v of versions) if (!newest || claudeVersionAtLeast(v, newest)) newest = v;
|
|
251
|
+
if (newest) return newest;
|
|
252
|
+
} catch { /* no native install beside it */ }
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The versions of every Claude Code the deck can find on this machine, read
|
|
259
|
+
* off disk without running any of them — the same places hasClaudeInstalled
|
|
260
|
+
* looks, each resolved to the install it belongs to. Distinct, and only the
|
|
261
|
+
* ones whose layout names a version: an install that does not say is left out
|
|
262
|
+
* rather than guessed at.
|
|
263
|
+
*
|
|
264
|
+
* WITHOUT A CHILD PROCESS, for the reason claudeCliOnDisk gives: the installer
|
|
265
|
+
* asks this at boot, and a `claude --version` is about three seconds there.
|
|
266
|
+
* Every dependency is injected so the suite can lay out an npm, a native and a
|
|
267
|
+
* Homebrew install for any platform without one on the machine.
|
|
268
|
+
*/
|
|
269
|
+
export function claudeCodeVersions(deps = {}) {
|
|
270
|
+
const out = new Set();
|
|
271
|
+
for (const { version } of claudeCodeInstalls(deps)) if (version) out.add(version);
|
|
272
|
+
return [...out];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Every `claude` the deck can find, each with the version its install layout
|
|
277
|
+
* names, or `version: undefined` where the layout does not say. The same walk
|
|
278
|
+
* and the same reading as claudeCodeVersions, which keeps only the versions;
|
|
279
|
+
* this keeps the installs that did not answer too, for a gate that has to
|
|
280
|
+
* treat "a Claude Code whose version is unknown" as a reason to refuse rather
|
|
281
|
+
* than as one fewer Claude Code (skill-hook.mjs).
|
|
282
|
+
*/
|
|
283
|
+
export function claudeCodeInstalls({
|
|
284
|
+
platform = process.platform,
|
|
285
|
+
env = process.env,
|
|
286
|
+
home = homedir(),
|
|
287
|
+
exists = existsSync,
|
|
288
|
+
realpath = p => realpathSync.native(p),
|
|
289
|
+
readText = p => readFileSync(p, "utf8"),
|
|
290
|
+
list = p => readdirSync(p),
|
|
291
|
+
} = {}) {
|
|
292
|
+
const out = [];
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
for (const bin of claudeCliFound({ platform, env, home, exists })) {
|
|
295
|
+
if (seen.has(bin)) continue;
|
|
296
|
+
seen.add(bin);
|
|
297
|
+
out.push({ bin, version: installVersion(bin, { platform, home, exists, realpath, readText, list }) });
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
163
300
|
}
|
|
164
301
|
|
|
165
302
|
/**
|
|
@@ -120,3 +120,26 @@ export async function currentIdentity() {
|
|
|
120
120
|
return null;
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether the CLI says anybody is signed in — the answer currentIdentity folds
|
|
126
|
+
* away: `{ loggedIn: true, email }`, `{ loggedIn: false }`, or null when there
|
|
127
|
+
* is no answer to read.
|
|
128
|
+
*
|
|
129
|
+
* `claude auth status --json` exits 1 when nobody is signed in and still prints
|
|
130
|
+
* `{"loggedIn": false, …}` (Claude Code 2.1.302), so `r.ok` cannot tell
|
|
131
|
+
* "signed out" from "could not ask". currentIdentity returns null for both,
|
|
132
|
+
* which is right for its callers. The auto-switch failover may only act on a
|
|
133
|
+
* CLI that has said the login is gone, so it reads the printed answer whatever
|
|
134
|
+
* the exit code, and a run that timed out or printed nothing is no answer.
|
|
135
|
+
*/
|
|
136
|
+
export async function cliSignIn() {
|
|
137
|
+
const r = await run(adminClaudeBin(), ["auth", "status", "--json"], { timeout: 20_000 });
|
|
138
|
+
if (r.timedOut) return null;
|
|
139
|
+
try {
|
|
140
|
+
const j = JSON.parse(r.stdout);
|
|
141
|
+
if (j?.loggedIn === true) return { loggedIn: true, email: j.email ?? "" };
|
|
142
|
+
if (j?.loggedIn === false) return { loggedIn: false };
|
|
143
|
+
} catch { /* nothing printed that could be read */ }
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// What `ccdeck accounts`, `ccdeck usage` and `ccdeck sessions` print (#2183).
|
|
2
|
+
//
|
|
3
|
+
// Pure — rows, a palette, glyphs and a width in; lines out — so the layout can
|
|
4
|
+
// be pinned at 40 columns and at 120 without a terminal, and so the same rows a
|
|
5
|
+
// running deck serves and the rows read in this process draw identically. The
|
|
6
|
+
// palette and glyphs are the boot's own (term.mjs), which is what makes these
|
|
7
|
+
// lines look like the product rather than like a script beside it: the same
|
|
8
|
+
// colour tier, the same ASCII fallback on a console that cannot draw a block.
|
|
9
|
+
import { sinceLabel, stripAnsi } from "./term.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Text that came from somewhere else — a session's title and folder, the
|
|
13
|
+
* question Claude Code passed on, an alias somebody typed — on one line and
|
|
14
|
+
* without the bytes a terminal acts on. A newline would break the row it is
|
|
15
|
+
* in, and an ESC would reach the terminal as a command rather than as text.
|
|
16
|
+
*/
|
|
17
|
+
export function oneLine(s) {
|
|
18
|
+
return String(s ?? "").replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The columns a character takes: two for the wide ones (CJK, Hangul, the
|
|
22
|
+
// fullwidth forms, the pictographic emoji), none for combining marks, joiners
|
|
23
|
+
// and variation selectors, one otherwise. Enough of wcwidth that a title in
|
|
24
|
+
// Japanese, or with an emoji in it, does not wrap the row it was clipped to.
|
|
25
|
+
const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩\u{1f300}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{1f900}-\u{1f9ff}\u{20000}-\u{3fffd}]/u;
|
|
26
|
+
const ZERO = /[\p{Mn}\p{Me}-︀-️]/u;
|
|
27
|
+
const cellsOf = (ch) => (ZERO.test(ch) ? 0 : WIDE.test(ch) ? 2 : 1);
|
|
28
|
+
|
|
29
|
+
/** How many columns `s` takes on a terminal, its colour codes not counted. */
|
|
30
|
+
export function textWidth(s) {
|
|
31
|
+
let w = 0;
|
|
32
|
+
for (const ch of stripAnsi(String(s))) w += cellsOf(ch);
|
|
33
|
+
return w;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The longest start of `s` that fits in `n` columns. */
|
|
37
|
+
function takeCells(s, n) {
|
|
38
|
+
let out = "";
|
|
39
|
+
let w = 0;
|
|
40
|
+
for (const ch of s) {
|
|
41
|
+
const c = cellsOf(ch);
|
|
42
|
+
if (w + c > n) break;
|
|
43
|
+
out += ch;
|
|
44
|
+
w += c;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A ten-cell meter, or `cells` of them. Blocks where the terminal can draw
|
|
50
|
+
* them, `#` and `.` where it cannot. */
|
|
51
|
+
export function meter(pct, { cells = 10, unicode = true } = {}) {
|
|
52
|
+
const p = Math.min(100, Math.max(0, Number(pct) || 0));
|
|
53
|
+
const full = Math.round((p / 100) * cells);
|
|
54
|
+
const [on, off] = unicode ? ["█", "░"] : ["#", "."];
|
|
55
|
+
return on.repeat(full) + off.repeat(cells - full);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The tone a percentage is drawn in: the panel's thresholds, near the limit
|
|
59
|
+
* in warn and at it in err. */
|
|
60
|
+
export function pctTone(pct, tone) {
|
|
61
|
+
return pct >= 90 ? tone.err : pct >= 75 ? tone.warn : tone.ok;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** "1h 12m" until a reset given in epoch SECONDS, or "" when there is none or
|
|
65
|
+
* it has passed — a reset in the past is a reading that is out of date, and
|
|
66
|
+
* "resets in 0s" would claim otherwise. */
|
|
67
|
+
export function untilReset(resetAt, now = Date.now()) {
|
|
68
|
+
if (typeof resetAt !== "number" || !Number.isFinite(resetAt)) return "";
|
|
69
|
+
const ms = resetAt * 1000 - now;
|
|
70
|
+
return ms > 0 ? sinceLabel(ms) : "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const padEnd = (s, n) => s + " ".repeat(Math.max(0, n - textWidth(s)));
|
|
74
|
+
const padStart = (s, n) => " ".repeat(Math.max(0, n - textWidth(s))) + s;
|
|
75
|
+
// Cut to `n` columns with the glyph tier's own ellipsis, which is three
|
|
76
|
+
// characters on a console that cannot draw the one-character one.
|
|
77
|
+
export const clip = (s, n, ellipsis = "…") => (textWidth(s) <= n ? s
|
|
78
|
+
: n <= textWidth(ellipsis) ? takeCells(s, n) : `${takeCells(s, n - textWidth(ellipsis))}${ellipsis}`);
|
|
79
|
+
|
|
80
|
+
/** What is wrong with an account, in words, or null when nothing is. */
|
|
81
|
+
export function accountTrouble(a) {
|
|
82
|
+
if (a.disabled) return { word: "disabled", tone: "muted" };
|
|
83
|
+
if (a.error && !a.staleCopy) return { word: "sign-in needed", tone: "err" };
|
|
84
|
+
if (a.stopped) return { word: "not collecting usage", tone: "warn" };
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function lanesText(a, { tone, g, unicode, now, wide }) {
|
|
89
|
+
const lanes = (a.lanes ?? []).filter(l => l.id === "five_hour" || l.id === "seven_day");
|
|
90
|
+
if (!lanes.length) {
|
|
91
|
+
return a.fetchedAt == null ? `${tone.muted}not read yet${tone.reset}` : `${tone.muted}no usage reading${tone.reset}`;
|
|
92
|
+
}
|
|
93
|
+
return lanes.map(l => {
|
|
94
|
+
const pct = `${Math.round(l.pct)}%`;
|
|
95
|
+
const until = untilReset(l.resetAt, now);
|
|
96
|
+
const bar = wide ? ` ${pctTone(l.pct, tone)}${meter(l.pct, { unicode })}${tone.reset}` : "";
|
|
97
|
+
const reset = wide && until ? ` ${tone.muted}${g.restart} ${padEnd(until, 7)}${tone.reset}` : wide ? " " : "";
|
|
98
|
+
return `${tone.muted}${l.label}${tone.reset}${bar} ${pctTone(l.pct, tone)}${padStart(pct, 4)}${tone.reset}${reset}`;
|
|
99
|
+
}).join(" ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The roster, one account per line, the active one marked.
|
|
104
|
+
*
|
|
105
|
+
* Three widths. From 104 columns each lane has its meter and its reset; from 64
|
|
106
|
+
* the percentages stand alone; below that the alias goes and the email is
|
|
107
|
+
* clipped. Nothing wraps: a row that wrapped would put one account's usage
|
|
108
|
+
* under another's name.
|
|
109
|
+
*/
|
|
110
|
+
export function accountLines(accounts, { tone, g, unicode = true, columns = 80, now = Date.now() } = {}) {
|
|
111
|
+
const rows = Array.isArray(accounts) ? accounts : [];
|
|
112
|
+
if (!rows.length) return [` ${tone.muted}${g.dash} no Claude accounts yet${tone.reset}`];
|
|
113
|
+
const wide = columns >= 104;
|
|
114
|
+
const aliasOf = (a) => oneLine(a.alias);
|
|
115
|
+
const emailOf = (a) => oneLine(a.email) || "(no email)";
|
|
116
|
+
const showAlias = columns >= 64 && rows.some(a => aliasOf(a));
|
|
117
|
+
const numW = Math.max(...rows.map(a => String(a.num).length));
|
|
118
|
+
const aliasW = showAlias ? Math.min(14, Math.max(...rows.map(a => textWidth(aliasOf(a))))) : 0;
|
|
119
|
+
// What is left for the email once the mark, the slot, the alias and the
|
|
120
|
+
// lanes have their room; never less than 12, which still shows whose it is.
|
|
121
|
+
const lanesW = wide ? 66 : 20;
|
|
122
|
+
const emailRoom = Math.max(12, columns - 2 - 2 - numW - 2 - (showAlias ? aliasW + 2 : 0) - 2 - lanesW);
|
|
123
|
+
const emailW = Math.min(emailRoom, Math.max(...rows.map(a => textWidth(emailOf(a)))));
|
|
124
|
+
return rows.map(a => {
|
|
125
|
+
const mark = a.active ? `${tone.ok}${g.pulse}${tone.reset}` : " ";
|
|
126
|
+
const num = `${a.active ? tone.bold : ""}${padStart(String(a.num), numW)}${tone.reset}`;
|
|
127
|
+
const alias = showAlias ? `${tone.accent}${padEnd(clip(aliasOf(a), aliasW, g.ellipsis), aliasW)}${tone.reset} ` : "";
|
|
128
|
+
const email = padEnd(clip(emailOf(a), emailW, g.ellipsis), emailW);
|
|
129
|
+
const trouble = accountTrouble(a);
|
|
130
|
+
const status = trouble
|
|
131
|
+
? `${tone[trouble.tone]}${trouble.word}${tone.reset}`
|
|
132
|
+
: lanesText(a, { tone, g, unicode, now, wide });
|
|
133
|
+
// The last lane's reset is padded for the column after it, which the last
|
|
134
|
+
// lane does not have: the padding goes, and the colour reset behind it stays.
|
|
135
|
+
const line = ` ${mark} ${num} ${alias}${email} ${status}`.replace(/ +((?:\x1b\[[0-9;]*m)*)$/, "$1");
|
|
136
|
+
// How old the reading is, where it fits: the one part of a row that can be
|
|
137
|
+
// left off without the row saying something else.
|
|
138
|
+
const age = !trouble && a.stale && a.fetchedAt != null
|
|
139
|
+
? ` ${tone.muted}read ${sinceLabel(now - a.fetchedAt)} ago${tone.reset}`
|
|
140
|
+
: "";
|
|
141
|
+
return textWidth(line) + textWidth(age) <= columns ? line + age : line;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One account's lanes, at length, for `ccdeck usage`: every lane the roster
|
|
146
|
+
* holds, the model-scoped ones included, each with a twenty-cell meter. */
|
|
147
|
+
export function usageLines(a, { tone, g, unicode = true, columns = 80, now = Date.now() } = {}) {
|
|
148
|
+
const alias = oneLine(a.alias);
|
|
149
|
+
const head = ` ${tone.ok}${g.pulse}${tone.reset} ${tone.bold}#${a.num} ${oneLine(a.email) || "(no email)"}${tone.reset}`
|
|
150
|
+
+ `${alias ? ` ${tone.accent}(${alias})${tone.reset}` : ""}`;
|
|
151
|
+
const out = [head, ""];
|
|
152
|
+
const trouble = accountTrouble(a);
|
|
153
|
+
if (trouble) out.push(` ${tone[trouble.tone]}${trouble.word}${tone.reset}`);
|
|
154
|
+
const lanes = a.lanes ?? [];
|
|
155
|
+
const labelW = Math.max(2, ...lanes.map(l => String(l.label).length));
|
|
156
|
+
const cells = columns >= 70 ? 20 : 10;
|
|
157
|
+
for (const l of lanes) {
|
|
158
|
+
const until = untilReset(l.resetAt, now);
|
|
159
|
+
out.push(` ${tone.muted}${padEnd(String(l.label), labelW)}${tone.reset} ${pctTone(l.pct, tone)}${meter(l.pct, { cells, unicode })}`
|
|
160
|
+
+ `${padStart(`${Math.round(l.pct)}%`, 5)}${tone.reset}${until ? ` ${tone.muted}resets in ${until}${tone.reset}` : ""}`);
|
|
161
|
+
}
|
|
162
|
+
if (!lanes.length) out.push(` ${tone.muted}${a.fetchedAt == null ? "not read yet" : "no usage reading"}${tone.reset}`);
|
|
163
|
+
if (a.fetchedAt != null) out.push("", ` ${tone.muted}read ${sinceLabel(now - a.fetchedAt)} ago${tone.reset}`);
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** "opus-4-1" for "claude-opus-4-1-20250805": the part a person says. */
|
|
168
|
+
export function shortModel(model) {
|
|
169
|
+
if (typeof model !== "string" || !model) return "";
|
|
170
|
+
return oneLine(model).replace(/^claude-/, "").replace(/-\d{8}$/, "").replace(/\[.*\]$/, "");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const STATE_WORDS = { waiting: "waiting", working: "working", idle: "your turn", ended: "ended" };
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The sessions, one per line, the ones waiting on a human first and their
|
|
177
|
+
* question underneath in their own words.
|
|
178
|
+
*/
|
|
179
|
+
export function sessionLines(sessions, { tone, g, columns = 80, now = Date.now() } = {}) {
|
|
180
|
+
const rows = Array.isArray(sessions) ? sessions : [];
|
|
181
|
+
if (!rows.length) return [` ${tone.muted}${g.dash} no sessions right now${tone.reset}`];
|
|
182
|
+
const label = (s) => oneLine(s.name) || oneLine(s.project) || oneLine(s.id).slice(0, 8);
|
|
183
|
+
const agentOf = (s) => (s.provider === "codex" ? "Codex" : "Claude");
|
|
184
|
+
// The mark, the state word and the age take 23 columns; the label gets what
|
|
185
|
+
// the agent column leaves, and on a narrow terminal the agent column gives
|
|
186
|
+
// way first — the model, then the agent — so the label, which is how a person
|
|
187
|
+
// tells two sessions apart, keeps at least eight.
|
|
188
|
+
const LEAD = 23;
|
|
189
|
+
const tails = [
|
|
190
|
+
(s) => `${agentOf(s)}${shortModel(s.model) ? ` ${g.bullet} ${shortModel(s.model)}` : ""}`,
|
|
191
|
+
(s) => agentOf(s),
|
|
192
|
+
() => "",
|
|
193
|
+
];
|
|
194
|
+
const longestLabel = Math.max(...rows.map(s => textWidth(label(s))));
|
|
195
|
+
const tailOf = tails.find(t => columns - LEAD - 2 - Math.max(...rows.map(s => textWidth(t(s)))) >= 8) ?? tails[2];
|
|
196
|
+
const tailW = Math.max(...rows.map(s => textWidth(tailOf(s))));
|
|
197
|
+
const labelW = Math.max(1, Math.min(longestLabel, columns - LEAD - (tailW ? tailW + 2 : 0)));
|
|
198
|
+
const out = [];
|
|
199
|
+
for (const s of rows) {
|
|
200
|
+
const icon = s.state === "waiting" ? `${tone.warn}${g.warn}${tone.reset}`
|
|
201
|
+
: s.state === "working" ? `${tone.ok}${g.pulse}${tone.reset}`
|
|
202
|
+
: s.state === "idle" ? `${tone.accent}${g.bullet}${tone.reset}`
|
|
203
|
+
: `${tone.muted}${g.dash}${tone.reset}`;
|
|
204
|
+
const word = STATE_WORDS[s.state] ?? s.state;
|
|
205
|
+
const wordTone = s.state === "waiting" ? tone.warn : s.state === "ended" ? tone.muted : "";
|
|
206
|
+
const age = sinceLabel(now - (s.since ?? s.lastEventAt ?? now));
|
|
207
|
+
const tail = tailOf(s);
|
|
208
|
+
const named = padEnd(clip(label(s), labelW, g.ellipsis), labelW);
|
|
209
|
+
out.push((` ${icon} ${wordTone}${padEnd(word, 9)}${tone.reset} ${tone.muted}${padStart(age, 7)}${tone.reset} `
|
|
210
|
+
+ `${tail ? `${named} ${tone.muted}${tail}${tone.reset}` : named}`).replace(/ +((?:\x1b\[[0-9;]*m)*)$/, "$1"));
|
|
211
|
+
if (s.state === "waiting" && s.waiting) {
|
|
212
|
+
const what = oneLine(s.waiting.message) || (s.waiting.kind === "asked" ? "asked a question" : "needs your permission");
|
|
213
|
+
// The tool the prompt is most likely about, unless Claude Code's own
|
|
214
|
+
// sentence already names it — "permission to use Bash (Bash)" says
|
|
215
|
+
// nothing twice over.
|
|
216
|
+
const tool0 = oneLine(s.waiting.tool);
|
|
217
|
+
const toolText = tool0 && !what.includes(tool0) ? ` (${tool0})` : "";
|
|
218
|
+
const room = Math.max(8, columns - 6 - textWidth(toolText));
|
|
219
|
+
const tool = toolText ? `${tone.muted}${toolText}${tone.reset}` : "";
|
|
220
|
+
out.push(` ${tone.warn}${clip(what, room, g.ellipsis)}${tone.reset}${tool}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { FREE_AUTO_REVIEW_TIER, isFreeAutoReview } from "./codex-review-policy.mjs";
|
|
2
|
+
// Price only observed increases under recorded settings, never a whole session
|
|
3
|
+
// at its latest tier. These are API-equivalent estimates, not billing evidence.
|
|
4
|
+
const FIELDS = ["input_tokens", "output_tokens", "cached_input_tokens", "cache_write_input_tokens"];
|
|
5
|
+
const TIERS = new Map([["default", "standard"], ["standard", "standard"], ["priority", "fast"], ["fast", "fast"], ["flex", "flex"]]);
|
|
6
|
+
const count = n => typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : 0;
|
|
7
|
+
const empty = () => Object.fromEntries(FIELDS.map(k => [k, 0]));
|
|
8
|
+
export function codexCostState() {
|
|
9
|
+
return { model: null, tier: null, previous: null, models: new Map() };
|
|
10
|
+
}
|
|
11
|
+
export function codexCostGap(state) {
|
|
12
|
+
state.gapPrevious = state.previous;
|
|
13
|
+
state.previous = null;
|
|
14
|
+
state.tier = null;
|
|
15
|
+
state.model = null;
|
|
16
|
+
state.lastUsageAt = undefined;
|
|
17
|
+
}
|
|
18
|
+
export function foldCodexCost(state, obj) {
|
|
19
|
+
const pl = obj?.payload;
|
|
20
|
+
if (obj?.type === "session_meta") {
|
|
21
|
+
state.creator = pl?.creator_account_id;
|
|
22
|
+
state.startedAt = obj.timestamp;
|
|
23
|
+
if (state.previous === null) state.previous = empty();
|
|
24
|
+
}
|
|
25
|
+
if (obj?.type === "session_meta" && typeof pl?.model === "string") state.model = pl.model;
|
|
26
|
+
if ((obj?.type === "turn_context" || obj?.type === "response_item") && typeof pl?.model === "string") state.model = pl.model;
|
|
27
|
+
if (obj?.type !== "event_msg") return;
|
|
28
|
+
if (pl?.type === "thread_settings_applied" && Object.hasOwn(pl.thread_settings ?? {}, "service_tier")) {
|
|
29
|
+
const tier = pl.thread_settings.service_tier;
|
|
30
|
+
state.tier = TIERS.get(tier) ?? "unknown";
|
|
31
|
+
}
|
|
32
|
+
const raw = pl?.type === "token_count" ? pl.info?.total_token_usage : null;
|
|
33
|
+
if (!raw || typeof raw !== "object") return;
|
|
34
|
+
const next = Object.fromEntries(FIELDS.map(k => [k, count(raw[k])]));
|
|
35
|
+
const priorUsageAt = state.lastUsageAt;
|
|
36
|
+
state.lastUsageAt = obj.timestamp;
|
|
37
|
+
const prior = state.previous;
|
|
38
|
+
const baseline = prior ?? state.gapPrevious;
|
|
39
|
+
state.gapPrevious = null;
|
|
40
|
+
state.previous = next;
|
|
41
|
+
// The first total could precede the tier setting; the first read after a gap
|
|
42
|
+
// certainly does. Leave that part at base rates instead of guessing its tier.
|
|
43
|
+
if (baseline && FIELDS.some(k => next[k] < baseline[k])) {
|
|
44
|
+
state.models.clear();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!prior || !state.model) return;
|
|
48
|
+
const delta = Object.fromEntries(FIELDS.map(k => [k, next[k] - prior[k]]));
|
|
49
|
+
const bucket = state.models.get(state.model) ?? { ...empty(), speeds: new Map() };
|
|
50
|
+
for (const k of FIELDS) bucket[k] += delta[k];
|
|
51
|
+
const free = isFreeAutoReview(state.model, state.creator, obj.timestamp)
|
|
52
|
+
&& (isFreeAutoReview(state.model, state.creator, priorUsageAt)
|
|
53
|
+
|| isFreeAutoReview(state.model, state.creator, state.startedAt));
|
|
54
|
+
const billedTier = free ? FREE_AUTO_REVIEW_TIER : state.tier;
|
|
55
|
+
if (billedTier && billedTier !== "standard") {
|
|
56
|
+
const share = bucket.speeds.get(billedTier) ?? empty();
|
|
57
|
+
for (const k of FIELDS) share[k] += delta[k];
|
|
58
|
+
bucket.speeds.set(billedTier, share);
|
|
59
|
+
}
|
|
60
|
+
state.models.set(state.model, bucket);
|
|
61
|
+
}
|
|
62
|
+
export function codexCostBuckets(state) {
|
|
63
|
+
if (state.models.size === 0) return undefined;
|
|
64
|
+
return Object.fromEntries([...state.models].map(([model, { speeds, ...usage }]) => [model, {
|
|
65
|
+
...usage, ...(speeds.size ? { speeds: Object.fromEntries(speeds) } : {}),
|
|
66
|
+
}]));
|
|
67
|
+
}
|