taskchef 9.6.0 → 9.7.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/.codex-plugin/plugin.json +1 -1
- package/docs/mcp-app.md +35 -10
- package/package.json +1 -1
- package/src/codex-session-usage.js +21 -8
- package/src/mcp-app/dist/index.html +75 -75
- package/src/mcp-app/react/UsageView.test.tsx +67 -2
- package/src/mcp-app/react/UsageView.tsx +88 -16
- package/src/mcp-app/react/styles.css +7 -2
- package/src/mcp-app.js +21 -6
- package/src/usage-analytics.js +17 -2
- package/src/usage-history-cache.js +38 -0
package/docs/mcp-app.md
CHANGED
|
@@ -367,7 +367,7 @@ a marketplace does not automatically move their installed plugin identity.
|
|
|
367
367
|
|
|
368
368
|
## Usage analytics
|
|
369
369
|
|
|
370
|
-

|
|
371
371
|
|
|
372
372
|
The Board/Usage selector sits beside the logo in the navbar, separated from
|
|
373
373
|
Board filters by a divider. It opens an on-demand historical usage view. Ranges are
|
|
@@ -376,7 +376,15 @@ Last month (the full previous calendar month). The default is 30 days; the
|
|
|
376
376
|
selected range is remembered locally when Usage is reopened.
|
|
377
377
|
There is no custom range. Dates use the view's local timezone, including DST.
|
|
378
378
|
Range changes and reopening Usage query one shared history snapshot without
|
|
379
|
-
rescanning logs. The snapshot is
|
|
379
|
+
rescanning logs. The combined snapshot is also saved atomically with private file
|
|
380
|
+
permissions under `~/.agents/taskchef/cache/usage-history/`, scoped by Codex home
|
|
381
|
+
and CLI/exec visibility. It contains the fields needed for charts, including
|
|
382
|
+
weekly readings, and no message content or per-file parsing records. After an
|
|
383
|
+
MCP server restart, the first Usage request loads a compatible disk snapshot
|
|
384
|
+
and returns saved charts before refreshing history in the background. Range and
|
|
385
|
+
timezone changes share that refresh. A missing, damaged, or incompatible snapshot
|
|
386
|
+
falls back to a normal scan. A failed background refresh retains the last good
|
|
387
|
+
snapshot, shows a Retry action, and never overwrites it with a failed result. The snapshot is scoped by CLI/exec visibility, independently of
|
|
380
388
|
range or timezone. After one minute it refreshes in the background while cached
|
|
381
389
|
charts remain visible. Refresh explicitly rebuilds it, reusing existing per-file
|
|
382
390
|
metadata and incremental parsing caches. Unchanged files retain parsed records
|
|
@@ -387,14 +395,31 @@ metadata changes; transient I/O failures retry while retaining healthy sources.
|
|
|
387
395
|
Rescan explicitly reparses available files and refreshes the inventory, while
|
|
388
396
|
retaining known history for missing sources. Board polling never starts a history scan.
|
|
389
397
|
|
|
390
|
-
The combined chart uses cost bars on the left axis and a token
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
+
The combined Tokens and cost chart uses cost bars on the left axis and a token
|
|
399
|
+
line on the right. Bars emphasize daily spending; the line shows activity changes.
|
|
400
|
+
Today's cost bars have a lighter fill and dashed borders; token segments leading
|
|
401
|
+
into today are dashed too. Finished days stay solid.
|
|
402
|
+
Tokens and cost by project shows stacked cost bars followed by a stacked token
|
|
403
|
+
area chart. Both use the period's top ten projects by estimated cost plus Other,
|
|
404
|
+
with matching colors. Select a legend entry or a Top 10 row to isolate that
|
|
405
|
+
project in both charts; All projects restores the stacks. Hovering or focusing
|
|
406
|
+
a legend entry highlights that project in both charts. Hover or focus a day in
|
|
407
|
+
the token chart to inspect exact project tokens and cost. Totals, the combined
|
|
408
|
+
chart, and rankings always describe the full selected period.
|
|
409
|
+
|
|
410
|
+
Weekly limits occupies one third of the summary row beside Recorded tokens and
|
|
411
|
+
Estimated cost. Narrow views stack these cards. Its heading shows the latest
|
|
412
|
+
recorded Codex percentage left and the next reset in the viewer's timezone.
|
|
413
|
+
The latest reading is independent of the chart's selected historical period;
|
|
414
|
+
a reading whose reset has passed is unavailable until a new observation arrives.
|
|
415
|
+
Only the main Codex bucket appears; model-specific limits are excluded. The
|
|
416
|
+
compact chart uses the last observation per local day and connects observations
|
|
417
|
+
across missing days. Known resets add an explicit refill jump. Connecting lines
|
|
418
|
+
interpolate between recorded points; they are not additional measurements.
|
|
419
|
+
Codex writes service-provided seven-day primary or secondary allowance windows
|
|
420
|
+
into local rollout token-count events, including events without token counters.
|
|
421
|
+
TaskChef reads these logs; it does not poll the service for current allowance.
|
|
422
|
+
Root-chat CLI/exec visibility controls which logs supply observations.
|
|
398
423
|
|
|
399
424
|
Archived chats and descendant usage are included. CLI/exec visibility settings
|
|
400
425
|
still apply to root chats. Descendants are counted once under their root chat's
|
package/package.json
CHANGED
|
@@ -19,9 +19,10 @@ const bucket = () => ({ tokens: zero(), costUsd: 0, partial: false, costPartial:
|
|
|
19
19
|
// Cumulative totals and call counters are independent. A missing boundary can
|
|
20
20
|
// leave one turn partial without preventing later calls from being allocated.
|
|
21
21
|
export function summarizeRecords(records, latestTurnId, models, child = false) {
|
|
22
|
-
const total = bucket(), latest = bucket(), durations = new Map(), seen = new Set(), timeline = [];
|
|
22
|
+
const total = bucket(), latest = bucket(), durations = new Map(), seen = new Set(), timeline = [], weeklyLimits = [];
|
|
23
23
|
let previous = zero();
|
|
24
24
|
for (const record of [...records].sort((a, b) => a.time - b.time || a.order - b.order)) {
|
|
25
|
+
if (record.kind === "weekly-limit") { weeklyLimits.push(record); continue; }
|
|
25
26
|
const key = JSON.stringify([record.time, record.kind, record.turn, record.root, record.model, record.total, record.last, record.duration]);
|
|
26
27
|
if (seen.has(key)) continue;
|
|
27
28
|
seen.add(key);
|
|
@@ -71,7 +72,7 @@ export function summarizeRecords(records, latestTurnId, models, child = false) {
|
|
|
71
72
|
latest.partial = latest.costPartial = true;
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
|
-
return { total, latest, durations, timeline };
|
|
75
|
+
return { total, latest, durations, timeline, weeklyLimits };
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
async function inventory(root, paths = []) {
|
|
@@ -138,7 +139,7 @@ export class CodexSessionUsage {
|
|
|
138
139
|
if (!saved && cachePath) {
|
|
139
140
|
try {
|
|
140
141
|
const value = JSON.parse(await readFile(cachePath, "utf8"));
|
|
141
|
-
if ([1, 2, 3].includes(value.version) && validResult(value.result) && (value.version === 1 || (Array.isArray(value.result.timeline) && value.result.timeline.every(sample => Number.isFinite(sample.time) && usage(sample.tokens) && Number.isFinite(sample.costUsd) && sample.costUsd >= 0 && sample.samples === 1 && typeof sample.partial === "boolean" && typeof sample.costPartial === "boolean")))) saved = value;
|
|
142
|
+
if ([1, 2, 3, 4].includes(value.version) && validResult(value.result) && (value.version === 1 || (Array.isArray(value.result.timeline) && value.result.timeline.every(sample => Number.isFinite(sample.time) && usage(sample.tokens) && Number.isFinite(sample.costUsd) && sample.costUsd >= 0 && sample.samples === 1 && typeof sample.partial === "boolean" && typeof sample.costPartial === "boolean")))) saved = value;
|
|
142
143
|
} catch { /* An absent or damaged cache is rebuilt from source records. */ }
|
|
143
144
|
}
|
|
144
145
|
const sourceKeys = (items) => items.flatMap(([sessionId, versions]) => versions
|
|
@@ -164,7 +165,7 @@ export class CodexSessionUsage {
|
|
|
164
165
|
})) knownSources = [];
|
|
165
166
|
} catch { knownSources = []; /* No prior valid source coverage is available. */ }
|
|
166
167
|
const missingSources = knownSources.filter(key => !currentSources.has(key));
|
|
167
|
-
if (!rescan && !saved?.retryRead && saved?.version ===
|
|
168
|
+
if (!rescan && !saved?.retryRead && saved?.version === 4 && typeof saved.result.analyticsPartial === "boolean" && saved.recordSources && saved.sourceSignature === sourceSignature) { this.results.set(resultKey, saved); return saved.result; }
|
|
168
169
|
const availablePaths = new Set(sources.flatMap(([, versions]) => versions.filter(version => version.length > 2).map(version => version[0])));
|
|
169
170
|
let retryRead = false;
|
|
170
171
|
const parsed = [];
|
|
@@ -183,7 +184,7 @@ export class CodexSessionUsage {
|
|
|
183
184
|
if (!availablePaths.has(path)) throw new Error("Rollout is missing.");
|
|
184
185
|
const previous = saved?.sourceFiles?.[sourceKey];
|
|
185
186
|
let file;
|
|
186
|
-
if (!rescan && previous && JSON.stringify(previous.stamp) === JSON.stringify(stamp) && Array.isArray(saved.recordSources?.[sourceKey])) {
|
|
187
|
+
if (!rescan && saved?.version === 4 && previous && JSON.stringify(previous.stamp) === JSON.stringify(stamp) && Array.isArray(saved.recordSources?.[sourceKey])) {
|
|
187
188
|
file = { records: saved.recordSources[sourceKey], partial: previous.partial };
|
|
188
189
|
} else {
|
|
189
190
|
if (rescan) this.files.delete(path);
|
|
@@ -208,10 +209,10 @@ export class CodexSessionUsage {
|
|
|
208
209
|
const retainedSources = saved?.recordSources && typeof saved.recordSources === "object" ? saved.recordSources : {};
|
|
209
210
|
for (const key of missingSources) {
|
|
210
211
|
const rows = retainedSources[key];
|
|
211
|
-
if (Array.isArray(rows) && rows.every(row => row && Number.isFinite(row.time) && ["usage", "duration", "issue"].includes(row.kind))) recordSources[key] = rows;
|
|
212
|
+
if (Array.isArray(rows) && rows.every(row => row && Number.isFinite(row.time) && ["usage", "duration", "issue", "weekly-limit"].includes(row.kind))) recordSources[key] = rows;
|
|
212
213
|
}
|
|
213
214
|
const result = { latest: bucket(), total: bucket(), subagents: sessions.length - 1, pricingDate: pricing.date,
|
|
214
|
-
durationMs: 0, durationTurns: 0, timeline: [], analyticsPartial: inventoryFailed, partial: inventoryFailed };
|
|
215
|
+
durationMs: 0, durationTurns: 0, timeline: [], weeklyLimits: [], analyticsPartial: inventoryFailed, partial: inventoryFailed };
|
|
215
216
|
for (const { session, records, failed, latestFailed, timePartial } of parsed) {
|
|
216
217
|
const summary = summarizeRecords(records, latestTurnId, pricing.models, session.id !== id);
|
|
217
218
|
const retained = missingSources.filter(key => JSON.parse(key)[0] === session.id).flatMap(key => recordSources[key] ?? []);
|
|
@@ -226,9 +227,11 @@ export class CodexSessionUsage {
|
|
|
226
227
|
summary.latest.costPartial ||= missing.latest.samples > 0 || missing.latest.costPartial;
|
|
227
228
|
summary.durations = combined.durations;
|
|
228
229
|
summary.timeline = combined.timeline;
|
|
230
|
+
summary.weeklyLimits = combined.weeklyLimits;
|
|
229
231
|
}
|
|
230
232
|
result.analyticsPartial ||= timePartial || summary.total.tokens.total_tokens !== summary.timeline.reduce((sum, sample) => sum + sample.tokens.total_tokens, 0);
|
|
231
233
|
result.timeline.push(...summary.timeline);
|
|
234
|
+
result.weeklyLimits.push(...summary.weeklyLimits);
|
|
232
235
|
for (const key of ["total", "latest"]) {
|
|
233
236
|
add(result[key].tokens, summary[key].tokens);
|
|
234
237
|
result[key].costUsd += summary[key].costUsd;
|
|
@@ -267,7 +270,7 @@ export class CodexSessionUsage {
|
|
|
267
270
|
}
|
|
268
271
|
if (missingSources.some(key => JSON.parse(key)[0] === id)) result.partial = true;
|
|
269
272
|
}
|
|
270
|
-
const entry = { version:
|
|
273
|
+
const entry = { version: 4, sourceSignature, result, recordSources, sourceFiles, retryRead, knownSources: [...new Set([...knownSources, ...currentSources])] };
|
|
271
274
|
// Keep successful sources even if another source failed. Retry failures on
|
|
272
275
|
// the next read without reparsing all healthy historical files.
|
|
273
276
|
this.results.set(resultKey, entry);
|
|
@@ -341,6 +344,16 @@ export class CodexSessionUsage {
|
|
|
341
344
|
if (p.type === "task_started") { file.turn = p.turn_id; file.root = p.root_turn_id ?? file.root; }
|
|
342
345
|
const time = Date.parse(row.timestamp);
|
|
343
346
|
if (!Number.isFinite(time)) { file.partial = true; return; }
|
|
347
|
+
if (p.type === "token_count" && p.rate_limits) {
|
|
348
|
+
const limits = p.rate_limits;
|
|
349
|
+
for (const window of [limits.primary, limits.secondary]) {
|
|
350
|
+
if (window?.window_minutes !== 10080 || !Number.isFinite(window.used_percent)
|
|
351
|
+
|| window.used_percent < 0 || window.used_percent > 100 || !Number.isSafeInteger(window.resets_at) || window.resets_at * 1000 <= time) continue;
|
|
352
|
+
file.records.push({ kind: "weekly-limit", time, id: typeof limits.limit_id === "string" ? limits.limit_id : "codex",
|
|
353
|
+
name: typeof limits.limit_name === "string" ? limits.limit_name : null,
|
|
354
|
+
remainingPercent: 100 - window.used_percent, resetsAt: window.resets_at * 1000 });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
344
357
|
if (p.type === "token_count" && p.info) file.records.push({ kind: "usage", time, order: file.offset,
|
|
345
358
|
turn: file.turn, root: file.root, model: file.model, total: p.info.total_token_usage, last: p.info.last_token_usage });
|
|
346
359
|
if (["task_complete", "turn_aborted"].includes(p.type)) {
|