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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "9.6.0",
3
+ "version": "9.7.0",
4
4
  "description": "Native Codex dashboard with lightweight project delegation.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
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
- ![Usage analytics with sample data](images/usage-analytics.jpg)
370
+ ![Usage analytics with sample data](images/usage-project-tokens.png)
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 scoped by CLI/exec visibility, independently of
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 line on the
391
- right. Today's cost bars have a lighter fill and dashed borders in both charts;
392
- the token segment leading into today is dashed too. Finished days, including
393
- the last day of Last month, stay solid. The project chart uses stacked cost bars for the period's top five
394
- projects plus Other. Select a legend entry or a Top 10 row to isolate that
395
- project in the project chart; All projects restores the stack. Hovering or
396
- focusing a legend entry highlights its bars and dims other project colors. Totals, the
397
- combined chart, and rankings always describe the full selected period.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "9.6.0",
3
+ "version": "9.7.0",
4
4
  "description": "A native Codex dashboard for chats, usage, pull requests, and schedules.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Favo Yang",
@@ -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 === 3 && typeof saved.result.analyticsPartial === "boolean" && saved.recordSources && saved.sourceSignature === sourceSignature) { this.results.set(resultKey, saved); return saved.result; }
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: 3, sourceSignature, result, recordSources, sourceFiles, retryRead, knownSources: [...new Set([...knownSources, ...currentSources])] };
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)) {