ccdeck 3.43.4 → 3.45.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 (97) hide show
  1. package/README.md +79 -18
  2. package/THIRD_PARTY_NOTICES.md +70 -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-Cbzf3TWa.js +1 -0
  10. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
  11. package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
  12. package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
  13. package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
  14. package/dist/web/assets/app-BW3NYUYB.js +318 -0
  15. package/dist/web/assets/app-C5RY_wZ4.css +1 -0
  16. package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/package.json +2 -1
  19. package/release-notes.json +52 -0
  20. package/src/server/account-notify.mjs +268 -9
  21. package/src/server/account-pick.mjs +160 -0
  22. package/src/server/account-projects.mjs +55 -15
  23. package/src/server/account-routes.mjs +47 -4
  24. package/src/server/account-watch.mjs +107 -4
  25. package/src/server/activity.mjs +3 -2
  26. package/src/server/args.mjs +112 -2
  27. package/src/server/autoswitch-failover.mjs +138 -0
  28. package/src/server/claude-accounts.mjs +127 -16
  29. package/src/server/claude-dir.mjs +142 -5
  30. package/src/server/claude-identity.mjs +23 -0
  31. package/src/server/cli-lines.mjs +224 -0
  32. package/src/server/codex-cost-usage.mjs +67 -0
  33. package/src/server/codex-enrichment.mjs +53 -9
  34. package/src/server/codex-review-policy.d.mts +4 -0
  35. package/src/server/codex-review-policy.mjs +11 -0
  36. package/src/server/codex-skills.mjs +146 -0
  37. package/src/server/codex-watch.mjs +5 -2
  38. package/src/server/cswap-auto-choice.mjs +53 -0
  39. package/src/server/cswap-auto-loop.mjs +100 -9
  40. package/src/server/deck-client.mjs +112 -0
  41. package/src/server/deck-prefs.mjs +26 -5
  42. package/src/server/event-pipeline.mjs +19 -3
  43. package/src/server/event-routes.mjs +15 -0
  44. package/src/server/exec.mjs +6 -2
  45. package/src/server/feature-use.mjs +11 -1
  46. package/src/server/forge-http.mjs +514 -0
  47. package/src/server/forge-public-fetch.mjs +228 -0
  48. package/src/server/forge-routes.mjs +154 -0
  49. package/src/server/github-api.mjs +1369 -0
  50. package/src/server/gitlab-api.mjs +1271 -0
  51. package/src/server/index.mjs +23 -3
  52. package/src/server/installer.mjs +149 -8
  53. package/src/server/integrations/cache.mjs +42 -0
  54. package/src/server/integrations/errors.mjs +167 -0
  55. package/src/server/integrations/forge.mjs +76 -0
  56. package/src/server/integrations/jira.mjs +1026 -0
  57. package/src/server/integrations/providers.mjs +11 -0
  58. package/src/server/integrations/proxy.mjs +314 -0
  59. package/src/server/integrations/registry.mjs +142 -0
  60. package/src/server/integrations/routes.mjs +498 -0
  61. package/src/server/integrations/store.mjs +226 -0
  62. package/src/server/lan-deck.mjs +7 -0
  63. package/src/server/lan-elsewhere.d.mts +30 -0
  64. package/src/server/lan-elsewhere.mjs +137 -0
  65. package/src/server/lan-engine.mjs +2 -2
  66. package/src/server/lan-sync.mjs +3 -3
  67. package/src/server/lan-wire.mjs +3 -3
  68. package/src/server/login-guard.mjs +162 -0
  69. package/src/server/login-service.mjs +3 -0
  70. package/src/server/org-telemetry.mjs +173 -0
  71. package/src/server/pinned-build.mjs +8 -0
  72. package/src/server/prefs-routes.mjs +4 -0
  73. package/src/server/request-gates.mjs +26 -0
  74. package/src/server/session-read-gate.mjs +5 -3
  75. package/src/server/session-summary.mjs +281 -0
  76. package/src/server/skill-hook.mjs +162 -0
  77. package/src/server/stop-continue-gate.d.mts +8 -0
  78. package/src/server/stop-continue-gate.mjs +77 -0
  79. package/src/server/stop-continue.mjs +544 -0
  80. package/src/server/stop-failure.d.mts +10 -0
  81. package/src/server/stop-failure.mjs +97 -0
  82. package/src/server/swap-log.mjs +19 -10
  83. package/src/server/tool-audit-report.mjs +5 -3
  84. package/src/server/tool-audit-routes.mjs +5 -3
  85. package/src/server/tool-audit-skills.mjs +82 -0
  86. package/src/server/tool-audit.mjs +53 -11
  87. package/src/server/traffic-radar-capture.mjs +10 -1
  88. package/src/server/traffic-radar-monitor.mjs +4 -2
  89. package/src/server/traffic-radar-routes.mjs +13 -0
  90. package/src/server/traffic-radar-service.mjs +133 -0
  91. package/src/server/usage-day.mjs +3 -1
  92. package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
  93. package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
  94. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  95. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  96. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  97. package/dist/web/assets/app-DBsNB-2y.js +0 -312
@@ -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
+ }
@@ -10,6 +10,7 @@
10
10
  // (event-pipeline.mjs) hands every live event to maybeResolveCodex, and
11
11
  // session-tracking.mjs forgets a session through forgetCodexSession. The
12
12
  // bodies are unchanged.
13
+ import { codexCostState, codexCostGap, foldCodexCost, codexCostBuckets } from "./codex-cost-usage.mjs";
13
14
  import { stat } from "node:fs/promises";
14
15
  import { join } from "node:path";
15
16
  import { STOP, rolloutNameId, walkRolloutDays } from "./codex-dir.mjs";
@@ -48,6 +49,7 @@ import { sessionReadGate } from "./session-read-gate.mjs";
48
49
  const codexRolloutPathBySid = new Map();
49
50
  const CODEX_READ_THROTTLE_MS = 2500;
50
51
  const codexUsageReads = sessionReadGate(CODEX_READ_THROTTLE_MS);
52
+ const codexUsageRequests = new Map();
51
53
  // How many of the newest day directories a lookup reads when it is not owed
52
54
  // the whole tree: the bound listRecentCodexRollouts keeps for the watcher.
53
55
  const CODEX_RECENT_DAY_DIRS = 2;
@@ -211,6 +213,7 @@ export async function readCodexRollout(path) {
211
213
  const s = await stat(path);
212
214
  if (s.size === 0) return null;
213
215
  let text;
216
+ const pricing = codexCostState();
214
217
  if (s.size <= CODEX_HEAD_BYTES + CODEX_TAIL_BYTES) {
215
218
  text = await readByteRange(path, 0, s.size);
216
219
  } else {
@@ -221,7 +224,7 @@ export async function readCodexRollout(path) {
221
224
  // the loop below skips it.
222
225
  const head = await readByteRange(path, 0, CODEX_HEAD_BYTES);
223
226
  const tail = await readByteRange(path, s.size - CODEX_TAIL_BYTES, s.size);
224
- text = `${head}\n${tail}`;
227
+ text = `${head}\n__ccdeck_gap__\n${tail}`;
225
228
  }
226
229
  let lastUsage = null;
227
230
  let model = null;
@@ -230,9 +233,11 @@ export async function readCodexRollout(path) {
230
233
  // Undefined until a turn_context is read: "no turn yet" is not "no effort".
231
234
  let effort;
232
235
  for (const line of text.split("\n")) {
236
+ if (line === "__ccdeck_gap__") { codexCostGap(pricing); continue; }
233
237
  if (!line) continue;
234
238
  let obj;
235
239
  try { obj = JSON.parse(line); } catch { continue; }
240
+ foldCodexCost(pricing, obj);
236
241
  // One record is one of these at most: each asks the record's own type.
237
242
  const meta = sessionMeta(obj);
238
243
  if (meta) {
@@ -251,28 +256,47 @@ export async function readCodexRollout(path) {
251
256
  if (obj?.type === "turn_context") effort = turnContextEffort(obj.payload);
252
257
  }
253
258
  if (!lastUsage && !model && !contextWindow) return null;
254
- return { usage: lastUsage, model, contextWindow, cwd, effort };
259
+ const usageByModel = codexCostBuckets(pricing);
260
+ return { usage: lastUsage, model, contextWindow, cwd, effort,
261
+ ...(usageByModel ? { usageByModel } : {}) };
255
262
  } catch {
256
263
  return null;
257
264
  }
258
265
  }
259
266
 
260
- function maybeResolveCodex(payload) {
267
+ function maybeResolveCodex(payload, opts = {}) {
261
268
  if (!payload || typeof payload !== "object") return;
262
269
  if (payload.provider !== "codex") return;
263
270
  const sid = payload.session_id;
264
271
  if (!sid) return;
265
- codexUsageReads.run(sid, async () => {
272
+ let entry = codexUsageRequests.get(sid);
273
+ if (!entry) {
274
+ entry = { revision: 0, opts: {}, timer: null };
275
+ codexUsageRequests.set(sid, entry);
276
+ }
277
+ entry.revision++;
278
+ entry.opts = { persist: opts.persist };
279
+ runCodexUsageRead(sid, entry);
280
+ }
281
+
282
+ function runCodexUsageRead(sid, entry) {
283
+ if (codexUsageRequests.get(sid) !== entry) return;
284
+ const revision = entry.revision;
285
+ const emitOpts = entry.opts;
286
+ const started = codexUsageReads.run(sid, async () => {
266
287
  const path = await findCodexRolloutPath(sid);
267
- if (!path) return;
288
+ if (!path || codexUsageRequests.get(sid) !== entry || entry.revision !== revision) return;
268
289
  const r = await readCodexRollout(path);
269
- if (!r) return;
290
+ // Watcher activity, eviction or shutdown can supersede either await.
291
+ // Never let a delayed snapshot overwrite newer live observations.
292
+ if (!r || codexUsageRequests.get(sid) !== entry || entry.revision !== revision) return;
270
293
  if (r.usage) {
271
294
  pushEvent({
272
295
  hook_event_name: "UsageObserved",
273
296
  session_id: sid,
274
297
  usage: r.usage,
275
- }, "internal");
298
+ usageByModel: r.usageByModel ?? null,
299
+ }, "internal", emitOpts);
276
300
  }
277
301
  if (r.model) {
278
302
  pushEvent({
@@ -282,7 +306,7 @@ function maybeResolveCodex(payload) {
282
306
  // A hook-delivered session has no watcher to spread it, so it rides
283
307
  // here, beside the model it qualifies.
284
308
  ...(r.effort !== undefined ? { reasoning_effort: r.effort } : {}),
285
- }, "internal");
309
+ }, "internal", emitOpts);
286
310
  }
287
311
  if (r.contextWindow) {
288
312
  // Piggy-back on the model event with the window — reducer reads
@@ -292,9 +316,21 @@ function maybeResolveCodex(payload) {
292
316
  session_id: sid,
293
317
  model: r.model ?? undefined,
294
318
  model_context_window: r.contextWindow,
295
- }, "internal");
319
+ }, "internal", emitOpts);
296
320
  }
297
321
  });
322
+ if (started) {
323
+ clearTimeout(entry.timer);
324
+ entry.timer = null;
325
+ } else if (!entry.timer) {
326
+ // Keep demand until a read actually starts, including while another read
327
+ // is pending. At most one unref'd retry timer exists for this session.
328
+ entry.timer = setTimeout(() => {
329
+ entry.timer = null;
330
+ runCodexUsageRead(sid, entry);
331
+ }, CODEX_READ_THROTTLE_MS + 1);
332
+ entry.timer.unref?.();
333
+ }
298
334
  }
299
335
 
300
336
  /**
@@ -305,6 +341,8 @@ function maybeResolveCodex(payload) {
305
341
  function forgetCodexSession(sid) {
306
342
  codexRolloutPathBySid.delete(sid);
307
343
  codexUsageReads.forget(sid);
344
+ clearTimeout(codexUsageRequests.get(sid)?.timer);
345
+ codexUsageRequests.delete(sid);
308
346
  codexSessionModel.delete(sid);
309
347
  codexSessionApproval.delete(sid);
310
348
  codexSessionEffort.delete(sid);
@@ -316,3 +354,9 @@ function forgetCodexSession(sid) {
316
354
  // at each declaration, so the declarations read as they did where they came
317
355
  // from.
318
356
  export { findCodexRolloutPath, forgetCodexSession, maybeResolveCodex };
357
+
358
+ export function stopCodexEnrichment() {
359
+ for (const entry of codexUsageRequests.values()) clearTimeout(entry.timer);
360
+ codexUsageRequests.clear();
361
+ codexUsageReads.forgetAll();
362
+ }
@@ -0,0 +1,4 @@
1
+ export const AUTO_REVIEW_MODEL: string;
2
+ export const FREE_AUTO_REVIEW_TIER: string;
3
+ export const FREE_AUTO_REVIEW_FROM: number;
4
+ export function isFreeAutoReview(model: unknown, creator: unknown, at: unknown): boolean;
@@ -0,0 +1,11 @@
1
+ // OpenAI confirms ChatGPT auto-review checks are free:
2
+ // https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan
3
+ // The announcement was on Oct 6. Use the following UTC day conservatively;
4
+ // do not infer the exact historical billing cutover from the current policy.
5
+ export const AUTO_REVIEW_MODEL = "codex-auto-review";
6
+ export const FREE_AUTO_REVIEW_TIER = "chatgpt-auto-review-free";
7
+ export const FREE_AUTO_REVIEW_FROM = Date.parse("2026-10-07T00:00:00Z");
8
+ export function isFreeAutoReview(model, creator, at) {
9
+ return model === AUTO_REVIEW_MODEL && typeof creator === "string" && creator.trim().length > 0
10
+ && typeof at === "string" && Date.parse(at) >= FREE_AUTO_REVIEW_FROM;
11
+ }
@@ -0,0 +1,146 @@
1
+ // Which Codex skill a rollout line shows in use, for Skill activity (#2181).
2
+ //
3
+ // Codex writes no record of its own that a skill ran. Two things in a rollout
4
+ // say so anyway:
5
+ //
6
+ // - the `<skill>` message Codex injects when a person picks one with `$name`
7
+ // or /skills, which it writes only once the skill has loaded (explicit);
8
+ // - the model reading a skill's SKILL.md, or running one of its scripts, which
9
+ // is Codex's own rule for a skill it chose itself (detected).
10
+ //
11
+ // A detected use counts only for a skill in the catalog Codex gave that session
12
+ // (its `<skills_instructions>` message) and only when the command succeeded, so
13
+ // searching for SKILL.md files or reading a Claude skill is not a use. One use
14
+ // per turn and skill: the turn id is the row's key, which also lands a forked
15
+ // rollout's copy of its parent's turns on the rows the parent already wrote.
16
+ import { posix, win32 } from "node:path";
17
+ import { noteSkillUse } from "./tool-audit.mjs";
18
+ import { readByteRange } from "./jsonl-chunks.mjs";
19
+
20
+ // The catalog sits in the first lines of a rollout, a few tens of KB in.
21
+ const CATALOG_HEAD_BYTES = 512 * 1024;
22
+ const slashes = path => path.replace(/\\/g, "/");
23
+
24
+ /** The skills a `<skills_instructions>` message lists: SKILL.md path → name,
25
+ * and each skill's folder for its scripts. Null when the text has none. */
26
+ export function parseSkillCatalog(text) {
27
+ const start = typeof text === "string" ? text.indexOf("<skills_instructions>") : -1;
28
+ if (start < 0) return null;
29
+ const roots = new Map(), files = new Map(), dirs = [];
30
+ const end = text.indexOf("</skills_instructions>", start);
31
+ for (const line of text.slice(start, end < 0 ? undefined : end).split("\n")) {
32
+ const root = /^- `(r\d+)` = `(.+)`\s*$/.exec(line);
33
+ if (root) { roots.set(root[1], slashes(root[2]).replace(/\/+$/, "")); continue; }
34
+ const entry = /^- (\S+): .*\(file: (r\d+)\/([^()]*\/SKILL\.md)\)\s*$/.exec(line);
35
+ if (!entry || !roots.has(entry[2])) continue;
36
+ const file = `${roots.get(entry[2])}/${slashes(entry[3])}`;
37
+ files.set(file, entry[1]);
38
+ dirs.push([file.slice(0, -"SKILL.md".length), entry[1]]);
39
+ }
40
+ return files.size ? { files, dirs } : null;
41
+ }
42
+
43
+ /** The catalog a rollout began with, for a session this deck joined late. */
44
+ export async function readCodexSkillCatalog(path) {
45
+ try {
46
+ const head = await readByteRange(path, 0, CATALOG_HEAD_BYTES);
47
+ for (const line of head.split("\n")) {
48
+ if (!line.includes("<skills_instructions>")) continue;
49
+ return parseSkillCatalog(messageText(JSON.parse(line)?.payload));
50
+ }
51
+ } catch {}
52
+ return null;
53
+ }
54
+
55
+ function messageText(payload) {
56
+ if (payload?.type !== "message" || !Array.isArray(payload.content)) return "";
57
+ return payload.content.map(part => typeof part?.text === "string" ? part.text : "").join("\n");
58
+ }
59
+
60
+ // A rollout's paths are its own machine's, so their shape picks the rules, not
61
+ // the host reading them: `fileURLToPath` refuses a drive-less file URL on
62
+ // Windows, and the host's `resolve` would put its own drive letter in front.
63
+ const windowsPath = path => /^(?:[A-Za-z]:[\\/]|\\\\|\/\/)/.test(path);
64
+ const localPath = value => {
65
+ if (typeof value !== "string" || !value) return null;
66
+ if (!value.startsWith("file://")) return value;
67
+ try {
68
+ const { host, pathname } = new URL(value);
69
+ const path = decodeURIComponent(pathname);
70
+ return host ? `//${host}${path}` : /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
71
+ } catch { return null; }
72
+ };
73
+ const resolveIn = (cwd, path) => (windowsPath(cwd) ? win32 : posix).resolve(cwd, path);
74
+
75
+ /** A finished command that may have touched a skill: worth reading the catalog for. */
76
+ const commandItem = obj => {
77
+ const item = obj?.type === "event_msg" && obj.payload?.type === "item_completed" ? obj.payload.item : null;
78
+ return item?.type === "CommandExecution" && item.exit_code === 0 && Array.isArray(item.parsed_cmd) ? item : null;
79
+ };
80
+
81
+ /** Per-rollout state: the catalog (undefined until known, null for none), the
82
+ * turn the last `task_started` opened, and the turn-and-skill keys counted. */
83
+ export const codexSkillState = () => ({ catalog: undefined, turn: null, seen: new Set(), waiting: [], loading: false });
84
+
85
+ /**
86
+ * The skill uses one rollout line shows, as `{ key, name, origin, at }`.
87
+ * `skills` is the rollout's codexSkillState. A command names its own turn; the
88
+ * injected `<skill>` message names none and belongs to the turn just opened.
89
+ */
90
+ export function codexSkillUses(obj, skills) {
91
+ const pl = obj?.payload;
92
+ const at = typeof obj?.timestamp === "string" ? Date.parse(obj.timestamp) : undefined;
93
+ if (obj?.type === "event_msg" && pl?.type === "task_started" && typeof pl.turn_id === "string") { skills.turn = pl.turn_id; return []; }
94
+ const turn = (typeof pl?.turn_id === "string" ? pl.turn_id : skills.turn) ?? `at:${obj?.timestamp ?? ""}`;
95
+ const use = (name, origin) => {
96
+ const key = `${turn}|${name}`;
97
+ if (skills.seen.has(key)) return [];
98
+ if (skills.seen.size >= 256) skills.seen.clear();
99
+ skills.seen.add(key);
100
+ return [{ key, name, origin, at }];
101
+ };
102
+ if (obj?.type === "response_item" && pl?.type === "message") {
103
+ const text = messageText(pl);
104
+ if (pl.role === "developer" && text.includes("<skills_instructions>")) { skills.catalog = parseSkillCatalog(text); return []; }
105
+ if (pl.role !== "user") return [];
106
+ return [...text.matchAll(/<skill>\s*<name>([^<]+)<\/name>/g)].flatMap(m => use(m[1].trim(), "typed"));
107
+ }
108
+ const item = commandItem(obj);
109
+ if (!item || !skills.catalog) return [];
110
+ const cwd = localPath(item.cwd);
111
+ const found = [];
112
+ for (const parsed of item.parsed_cmd) {
113
+ if (parsed?.type === "read" && typeof parsed.path === "string") {
114
+ const name = skills.catalog.files.get(slashes(cwd ? resolveIn(cwd, parsed.path) : parsed.path));
115
+ if (name) found.push(name);
116
+ } else if (parsed?.type === "unknown" && typeof parsed.cmd === "string") {
117
+ const cmd = slashes(parsed.cmd);
118
+ for (const [dir, name] of skills.catalog.dirs) if (cmd.includes(`${dir}scripts/`)) found.push(name);
119
+ }
120
+ }
121
+ return [...new Set(found)].flatMap(name => use(name, "detected"));
122
+ }
123
+
124
+ /**
125
+ * Count the skills one rollout line shows. `state` is the watcher's cursor for
126
+ * the rollout: its session, cwd, subagent link and path. A command that may
127
+ * read a skill, in a session joined after its catalog went by, waits for the
128
+ * catalog to be read off the head of the file.
129
+ */
130
+ export function noteCodexSkills(obj, state, note = noteSkillUse) {
131
+ const skills = state.skills ??= codexSkillState();
132
+ const session = state.link?.parent_session_id ?? state.sid, cwd = state.cwd, subagent = !!state.link;
133
+ const send = uses => { for (const use of uses) note({ ...use, session, cwd, subagent }); };
134
+ if (skills.catalog === undefined && commandItem(obj) && state.path) {
135
+ skills.waiting.push(obj);
136
+ if (!skills.loading) {
137
+ skills.loading = true;
138
+ void readCodexSkillCatalog(state.path).then(catalog => {
139
+ if (skills.catalog === undefined) skills.catalog = catalog;
140
+ for (const line of skills.waiting.splice(0)) send(codexSkillUses(line, skills));
141
+ });
142
+ }
143
+ return;
144
+ }
145
+ send(codexSkillUses(obj, skills));
146
+ }
@@ -16,6 +16,7 @@ import { STOP, rolloutNameId, sidFromRolloutName, walkRolloutDays } from "./code
16
16
  // record that says whose rollout it is — see codex-translate.mjs. The watcher
17
17
  // below still decides which lines are read and where each one goes.
18
18
  import { codexObjToPayload, codexSessionModel, sessionMeta } from "./codex-translate.mjs";
19
+ import { noteCodexSkills } from "./codex-skills.mjs";
19
20
  import { codexCwdInWorkspace, writesCodexLog } from "./log-election.mjs";
20
21
  // The one spelling of a rollout's cwd — see canonical-path.mjs.
21
22
  import { canonicalCwd } from "./canonical-path.mjs";
@@ -351,7 +352,7 @@ async function openCodexCursor(path, st, now, firstRun, recover = false) {
351
352
  // UsageObserved in the file to every tab, and re-appending the lot to
352
353
  // the shared events.jsonl if this deck is the elected writer (#981).
353
354
  const fresh = !codexSeenEver.has(path);
354
- const state = { offset: fresh ? 0 : st.size, sid: header.sid, cwd: header.cwd, link: header.link, skip: false, sawBeginning: fresh, rootOpened: false, seenAt: now };
355
+ const state = { offset: fresh ? 0 : st.size, sid: header.sid, cwd: header.cwd, link: header.link, skip: false, sawBeginning: fresh, rootOpened: false, seenAt: now, path };
355
356
  codexFileState.set(path, state);
356
357
  rememberCodexPath(path);
357
358
  if (firstRun) {
@@ -409,6 +410,8 @@ function emitCodexLines(state, consume, persist) {
409
410
  if (!line) continue;
410
411
  let obj;
411
412
  try { obj = JSON.parse(line); } catch { continue; }
413
+ // Skill activity reads the line itself: what says a skill ran is not an event.
414
+ noteCodexSkills(obj, state);
412
415
  const prevModel = codexSessionModel.get(state.sid);
413
416
  const payload = codexObjToPayload(obj, state.sid, state.cwd);
414
417
  const link = codexThreadLink(obj, state.sid, state.cwd);
@@ -486,7 +489,7 @@ async function openGrownOlderRollouts(now) {
486
489
  // Never `sawBeginning`, whatever this process read of the file before: the
487
490
  // lines before `was` are ones this deck has either drawn already or joined
488
491
  // too late for, and either way a SessionStart now would be a false one.
489
- const state = { offset: skip ? st.size : was, sid: header.sid, cwd: header.cwd, link: header.link, skip, sawBeginning: false, rootOpened: false, seenAt: now, mtimeMs: st.mtimeMs };
492
+ const state = { offset: skip ? st.size : was, sid: header.sid, cwd: header.cwd, link: header.link, skip, sawBeginning: false, rootOpened: false, seenAt: now, mtimeMs: st.mtimeMs, path };
490
493
  codexFileState.set(path, state);
491
494
  rememberCodexPath(path);
492
495
  opened.push({ path, st, state });