ccdeck 3.43.4 → 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.
Files changed (75) hide show
  1. package/README.md +69 -17
  2. package/THIRD_PARTY_NOTICES.md +43 -7
  3. package/bin/cli/commands.js +613 -0
  4. package/bin/cli/help.js +26 -3
  5. package/bin/cli/one-shot.js +2 -2
  6. package/bin/cli/picker.js +86 -0
  7. package/bin/cli/startup.js +13 -0
  8. package/bin/deck.js +16 -1
  9. package/dist/web/assets/{BrowserWatchModal-nOxhGOO4.js → BrowserWatchModal-DMIUygWz.js} +1 -1
  10. package/dist/web/assets/{RadioPanel-BLfpQ1Iw.js → RadioPanel-DNB_AHSs.js} +1 -1
  11. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
  12. package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
  13. package/dist/web/assets/app-CQIstkMF.js +318 -0
  14. package/dist/web/assets/app-Ch-0CYlK.css +1 -0
  15. package/dist/web/assets/{index-Ch4ks76J.js → index-BQbOfxbL.js} +1 -1
  16. package/dist/web/index.html +3 -3
  17. package/package.json +2 -1
  18. package/release-notes.json +34 -0
  19. package/src/server/account-notify.mjs +206 -9
  20. package/src/server/account-pick.mjs +160 -0
  21. package/src/server/account-projects.mjs +55 -15
  22. package/src/server/account-routes.mjs +47 -4
  23. package/src/server/account-watch.mjs +48 -4
  24. package/src/server/activity.mjs +3 -2
  25. package/src/server/args.mjs +112 -2
  26. package/src/server/autoswitch-failover.mjs +138 -0
  27. package/src/server/claude-accounts.mjs +115 -14
  28. package/src/server/claude-dir.mjs +142 -5
  29. package/src/server/claude-identity.mjs +23 -0
  30. package/src/server/cli-lines.mjs +224 -0
  31. package/src/server/codex-cost-usage.mjs +67 -0
  32. package/src/server/codex-enrichment.mjs +53 -9
  33. package/src/server/codex-review-policy.d.mts +4 -0
  34. package/src/server/codex-review-policy.mjs +11 -0
  35. package/src/server/codex-skills.mjs +146 -0
  36. package/src/server/codex-watch.mjs +5 -2
  37. package/src/server/cswap-auto-choice.mjs +53 -0
  38. package/src/server/cswap-auto-loop.mjs +100 -9
  39. package/src/server/deck-client.mjs +112 -0
  40. package/src/server/deck-prefs.mjs +16 -5
  41. package/src/server/event-pipeline.mjs +19 -3
  42. package/src/server/event-routes.mjs +15 -0
  43. package/src/server/exec.mjs +6 -2
  44. package/src/server/index.mjs +17 -1
  45. package/src/server/installer.mjs +149 -8
  46. package/src/server/lan-engine.mjs +2 -2
  47. package/src/server/lan-sync.mjs +3 -3
  48. package/src/server/lan-wire.mjs +3 -3
  49. package/src/server/login-guard.mjs +162 -0
  50. package/src/server/login-service.mjs +3 -0
  51. package/src/server/pinned-build.mjs +5 -0
  52. package/src/server/prefs-routes.mjs +4 -0
  53. package/src/server/request-gates.mjs +7 -0
  54. package/src/server/session-read-gate.mjs +5 -3
  55. package/src/server/session-summary.mjs +281 -0
  56. package/src/server/skill-hook.mjs +162 -0
  57. package/src/server/stop-continue-gate.d.mts +8 -0
  58. package/src/server/stop-continue-gate.mjs +77 -0
  59. package/src/server/stop-continue.mjs +544 -0
  60. package/src/server/stop-failure.d.mts +10 -0
  61. package/src/server/stop-failure.mjs +97 -0
  62. package/src/server/swap-log.mjs +19 -10
  63. package/src/server/tool-audit-report.mjs +5 -3
  64. package/src/server/tool-audit-routes.mjs +5 -3
  65. package/src/server/tool-audit-skills.mjs +82 -0
  66. package/src/server/tool-audit.mjs +53 -11
  67. package/src/server/traffic-radar-capture.mjs +10 -1
  68. package/src/server/traffic-radar-monitor.mjs +4 -2
  69. package/src/server/traffic-radar-routes.mjs +13 -0
  70. package/src/server/traffic-radar-service.mjs +133 -0
  71. package/src/server/usage-day.mjs +1 -1
  72. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  73. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  74. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  75. package/dist/web/assets/app-DBsNB-2y.js +0 -312
@@ -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; verdictNow and
43
- // verdictsNow are re-exported because cswap-admin.mjs and lan-deck.mjs reach
44
- // them through this module.
45
- import { verdictFor, verdictsAskedAt } from "./claude-verdicts.mjs";
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, claude3@sapec.md
632
- * cswap list --json -> claude3@sapec.md: relogin_required
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 withStoreLock(() => cswapBin()
831
- .then(bin => run(bin, ["switch", String(num)], { timeout: 30_000 }))
832
- .then(r => {
833
- if (r.ok) return { ok: true, output: r.stdout.trim() };
834
- const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
835
- return { ok: false, reason, output: failureDetail(r, 500) };
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)) return true; }
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 false;
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) if (exists(path.join(dir, name + ext))) return true;
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
- return false;
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
+ }