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.
- package/README.md +79 -18
- package/THIRD_PARTY_NOTICES.md +70 -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 +16 -1
- package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
- package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
- package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
- package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
- package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
- package/dist/web/assets/app-BW3NYUYB.js +318 -0
- package/dist/web/assets/app-C5RY_wZ4.css +1 -0
- package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +52 -0
- package/src/server/account-notify.mjs +268 -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 +107 -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 +127 -16
- 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 +26 -5
- 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/feature-use.mjs +11 -1
- package/src/server/forge-http.mjs +514 -0
- package/src/server/forge-public-fetch.mjs +228 -0
- package/src/server/forge-routes.mjs +154 -0
- package/src/server/github-api.mjs +1369 -0
- package/src/server/gitlab-api.mjs +1271 -0
- package/src/server/index.mjs +23 -3
- package/src/server/installer.mjs +149 -8
- package/src/server/integrations/cache.mjs +42 -0
- package/src/server/integrations/errors.mjs +167 -0
- package/src/server/integrations/forge.mjs +76 -0
- package/src/server/integrations/jira.mjs +1026 -0
- package/src/server/integrations/providers.mjs +11 -0
- package/src/server/integrations/proxy.mjs +314 -0
- package/src/server/integrations/registry.mjs +142 -0
- package/src/server/integrations/routes.mjs +498 -0
- package/src/server/integrations/store.mjs +226 -0
- package/src/server/lan-deck.mjs +7 -0
- package/src/server/lan-elsewhere.d.mts +30 -0
- package/src/server/lan-elsewhere.mjs +137 -0
- package/src/server/lan-engine.mjs +2 -2
- package/src/server/lan-sync.mjs +3 -3
- 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/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +8 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/request-gates.mjs +26 -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/usage-day.mjs +3 -1
- package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
- package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
- package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
- package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
- package/dist/web/assets/app-BiJ8TfUl.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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 });
|