taskchef 9.6.0 → 9.7.1

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.1",
4
4
  "description": "Native Codex dashboard with lightweight project delegation.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/docs/mcp-app.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # TaskChef sidebar
2
2
 
3
- The public app name is **TaskChef**. “TaskChef Next” remains the internal project name.
3
+ The released app is **TaskChef**. The development app is **TaskChef Dev**, with plugin and MCP server ID `taskchef-dev`.
4
4
 
5
5
  The native dashboard requires Node.js 22.18+, 23.2+, or 24+ for local Codex SQLite access.
6
6
 
@@ -196,7 +196,7 @@ Use this order for every development change:
196
196
  For a full Dev update, give the staging package a unique development version
197
197
  in both `package.json` and `.codex-plugin/plugin.json` (and package-lock metadata
198
198
  if present), such as `9.0.0-dev.1`. Set only the staging manifest's plugin name
199
- to `taskchef-next`; keep the source release manifest named `taskchef`.
199
+ to `taskchef-dev`; keep the source release manifest named `taskchef`.
200
200
  Before installing the staging package through the local development marketplace,
201
201
  run:
202
202
 
@@ -205,12 +205,21 @@ node scripts/prepare-mcp-app-preview.js /path/to/staging/plugin
205
205
  ```
206
206
 
207
207
  The script sets the copied plugin’s display name to **TaskChef Dev** and
208
- normalizes its MCP server key to `taskchef-next`. It refuses release manifests. The sidebar tool reads its
208
+ normalizes its MCP server key to `taskchef-dev`. It refuses release manifests. The sidebar tool reads its
209
209
  name from that manifest. The release checkout and release package keep
210
210
  **TaskChef**. Run this step again whenever rebuilding the development package.
211
211
  Do not register a standalone preview MCP server alongside these two plugins.
212
212
  Restart Codex after changes to plugin identity or backend code.
213
213
 
214
+ When migrating an existing Dev installation, prepare a new staging package with
215
+ plugin ID `taskchef-dev` and a unique Dev version. Set its local marketplace
216
+ name and plugin entry to `taskchef-dev`, then add the marketplace and install
217
+ `taskchef-dev@taskchef-dev`. Verify the new installation before removing
218
+ `taskchef-next@taskchef-next-preview` and the old marketplace registration.
219
+ Both editions continue to share `~/.agents/taskchef`; preserve that state and
220
+ the legacy GitHub credential key. After restarting Codex, the Dev skill is
221
+ `$taskchef-dev:delegate` and its MCP namespace is `taskchef_dev`.
222
+
214
223
  ## Local UI review without restarting Codex
215
224
 
216
225
  For UI-only changes, update the HTML in the installation that the running MCP
@@ -222,10 +231,10 @@ folder. Its next resource read then fails with `ENOENT`, and the sidebar shows
222
231
  From the source checkout, run:
223
232
 
224
233
  ```sh
225
- npm run update:mcp-app-preview -- /path/to/the/running/taskchef-next/version
234
+ npm run update:mcp-app-preview -- /path/to/the/running/taskchef-dev/version
226
235
  ```
227
236
 
228
- The helper builds the UI, checks the development plugin ID is `taskchef-next`, and replaces only
237
+ The helper builds the UI, checks the development plugin ID is `taskchef-dev`, and replaces only
229
238
  `src/mcp-app/dist/index.html` atomically. It preserves the plugin version, server
230
239
  code, dependencies, and local data. It refuses missing or redirected HTML paths.
231
240
  Then right-click **TaskChef Dev** in the Codex sidebar and choose **Refresh**.
@@ -351,13 +360,13 @@ uses this repository's GitHub token; no shared-marketplace deploy key is needed.
351
360
  If the push loses a race with another main update, rerun the Release workflow
352
361
  on main to retry after checking the current published version. Do not force push.
353
362
 
354
- Keep TaskChef Dev in a separate local marketplace named `taskchef-next-preview`,
355
- with plugin ID `taskchef-next` and display name **TaskChef Dev**. Store that
363
+ Keep TaskChef Dev in a separate local marketplace named `taskchef-dev`,
364
+ with plugin ID `taskchef-dev` and display name **TaskChef Dev**. Store that
356
365
  marketplace and its staging packages in a persistent local folder, outside
357
366
  this public checkout. Its catalog uses `source.source: local` with a path to
358
367
  the prepared staging package. Add that folder with
359
368
  `codex plugin marketplace add /path/to/local-dev-marketplace`, then install
360
- `codex plugin add taskchef-next@taskchef-next-preview`. This can coexist with
369
+ `codex plugin add taskchef-dev@taskchef-dev`. This can coexist with
361
370
  `taskchef@taskchef`. Never rename the source release manifest for Dev.
362
371
 
363
372
  Retire the old shared catalog entry only after this repository's catalog is
@@ -367,7 +376,7 @@ a marketplace does not automatically move their installed plugin identity.
367
376
 
368
377
  ## Usage analytics
369
378
 
370
- ![Usage analytics with sample data](images/usage-analytics.jpg)
379
+ ![Usage analytics with sample data](images/usage-project-tokens.png)
371
380
 
372
381
  The Board/Usage selector sits beside the logo in the navbar, separated from
373
382
  Board filters by a divider. It opens an on-demand historical usage view. Ranges are
@@ -376,8 +385,16 @@ Last month (the full previous calendar month). The default is 30 days; the
376
385
  selected range is remembered locally when Usage is reopened.
377
386
  There is no custom range. Dates use the view's local timezone, including DST.
378
387
  Range changes and reopening Usage query one shared history snapshot without
379
- rescanning logs. The snapshot is scoped by CLI/exec visibility, independently of
380
- range or timezone. After one minute it refreshes in the background while cached
388
+ rescanning logs. The combined snapshot is also saved atomically with private file
389
+ permissions under `~/.agents/taskchef/cache/usage-history/`, scoped by Codex home
390
+ and the all-sources accounting policy. It contains the fields needed for charts, including
391
+ weekly readings, and no message content or per-file parsing records. After an
392
+ MCP server restart, the first Usage request loads a compatible disk snapshot
393
+ and returns saved charts before refreshing history in the background. Range and
394
+ timezone changes share that refresh. A missing, damaged, or incompatible snapshot
395
+ falls back to a normal scan. A failed background refresh retains the last good
396
+ snapshot, shows a Retry action, and never overwrites it with a failed result. The snapshot is independent of Board visibility, range, and timezone. Older
397
+ visibility-filtered snapshots are not reused. After one minute it refreshes in the background while cached
381
398
  charts remain visible. Refresh explicitly rebuilds it, reusing existing per-file
382
399
  metadata and incremental parsing caches. Unchanged files retain parsed records
383
400
  on disk across restarts and turn or price changes. A session filename index
@@ -387,17 +404,35 @@ metadata changes; transient I/O failures retry while retaining healthy sources.
387
404
  Rescan explicitly reparses available files and refreshes the inventory, while
388
405
  retaining known history for missing sources. Board polling never starts a history scan.
389
406
 
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
-
399
- Archived chats and descendant usage are included. CLI/exec visibility settings
400
- still apply to root chats. Descendants are counted once under their root chat's
407
+ The combined Tokens and cost chart uses cost bars on the left axis and a token
408
+ line on the right. Bars emphasize daily spending; the line shows activity changes.
409
+ Today's cost bars have a lighter fill and dashed borders; token segments leading
410
+ into today are dashed too. Finished days stay solid.
411
+ Tokens and cost by project shows stacked cost bars followed by a stacked token
412
+ area chart. Both use the period's top ten projects by estimated cost plus Other,
413
+ with matching colors. Select a legend entry or a Top 10 row to isolate that
414
+ project in both charts; All projects restores the stacks. Hovering or focusing
415
+ a legend entry highlights that project in both charts. Hover or focus a day in
416
+ the token chart to inspect exact project tokens and cost. Totals, the combined
417
+ chart, and rankings always describe the full selected period.
418
+
419
+ Weekly limits occupies one third of the summary row beside Recorded tokens and
420
+ Estimated cost. Narrow views stack these cards. Its heading shows the latest
421
+ recorded Codex percentage left and the next reset in the viewer's timezone.
422
+ The latest reading is independent of the chart's selected historical period;
423
+ a reading whose reset has passed is unavailable until a new observation arrives.
424
+ Only the main Codex bucket appears; model-specific limits are excluded. The
425
+ compact chart uses the last observation per local day and connects observations
426
+ across missing days. Known resets add an explicit refill jump. Connecting lines
427
+ interpolate between recorded points; they are not additional measurements.
428
+ Codex writes service-provided seven-day primary or secondary allowance windows
429
+ into local rollout token-count events, including events without token counters.
430
+ TaskChef reads these logs; it does not poll the service for current allowance.
431
+ Weekly observations include all root-chat sources, regardless of Board visibility.
432
+
433
+ Desktop, CLI, exec, archived chats, and descendant usage are included. Board
434
+ visibility settings do not narrow Usage or change its cached history.
435
+ Descendants are counted once under their root chat's
401
436
  current project. Unassigned chats share one Unassigned project. Daily usage
402
437
  comes from recorded call timestamps, not chat creation or last-update dates.
403
438
  Unexplained cumulative counter gaps stay in lifetime totals but are excluded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "9.6.0",
3
+ "version": "9.7.1",
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",
@@ -28,7 +28,7 @@ export async function assertNoStateWriters(paths, runCommand = run) {
28
28
  if (!identities.has(root)) {
29
29
  let name;
30
30
  try { name = JSON.parse(await readFile(join(root, ".codex-plugin/plugin.json"), "utf8")).name; } catch { /* Unrelated process. */ }
31
- identities.set(root, ["taskchef", "taskchef-next"].includes(name));
31
+ identities.set(root, ["taskchef", "taskchef-dev", "taskchef-next"].includes(name));
32
32
  }
33
33
  return identities.get(root);
34
34
  }
@@ -674,10 +674,10 @@ export class CodexSessionScanner {
674
674
  }
675
675
  return { month, timeZone, chats };
676
676
  }
677
- async usageAnalytics({ range, timeZone, settings }) {
678
- return aggregateUsage(await this.usageHistory({ settings }), { range, timeZone, now: this.now() });
677
+ async usageAnalytics({ range, timeZone }) {
678
+ return aggregateUsage(await this.usageHistory(), { range, timeZone, now: this.now() });
679
679
  }
680
- async usageHistory({ settings, onProgress = () => {}, rescan = false }) {
680
+ async usageHistory({ onProgress = () => {}, rescan = false } = {}) {
681
681
  if (rescan) this.sessionUsage.inventoryAt = -Infinity;
682
682
  const snapshot = await this.refresh();
683
683
  if (!snapshot.healthy) throw new Error(snapshot.scan.error);
@@ -690,8 +690,8 @@ export class CodexSessionScanner {
690
690
  })), records.projects);
691
691
  const families = [];
692
692
  const seen = new Set();
693
- // History includes archived chats. CLI/exec preferences still apply.
694
- const tasks = grouped.tasks.filter(task => (settings.showCli || task.observed?.source !== "cli") && (settings.showExec || task.observed?.source !== "exec"));
693
+ // Accounting includes every root, independent of Board visibility settings.
694
+ const tasks = grouped.tasks;
695
695
  let completed = 0;
696
696
  onProgress({ completed, total: tasks.length });
697
697
  for (const task of tasks) {
@@ -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)) {