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
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
1
|
+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
2
2
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
3
3
|
import { UsageView, type UsageData } from './UsageView';
|
|
4
4
|
beforeEach(() => window.localStorage.clear());
|
|
5
5
|
afterEach(cleanup);
|
|
6
|
-
const data:UsageData={start:'2026-10-01',end:'2026-10-15',timeZone:'Asia/Singapore',samples:2,incomplete:false,totals:{tokens:200,costUsd:3,inputTokens:160,cachedInputTokens:40,outputTokens:40},days:[{date:'2026-10-15',tokens:200,costUsd:3,projects:{a:2,b:1}}],projects:[{id:'a',name:'Alpha',tokens:100,costUsd:2},{id:'b',name:'Beta',tokens:100,costUsd:1}]};
|
|
6
|
+
const data:UsageData={start:'2026-10-01',end:'2026-10-15',timeZone:'Asia/Singapore',samples:2,incomplete:false,totals:{tokens:200,costUsd:3,inputTokens:160,cachedInputTokens:40,outputTokens:40},days:[{date:'2026-10-15',tokens:200,costUsd:3,projects:{a:2,b:1},projectTokens:{a:120,b:80}}],projects:[{id:'a',name:'Alpha',tokens:100,costUsd:2},{id:'b',name:'Beta',tokens:100,costUsd:1}]};
|
|
7
7
|
test('defaults to 30 days, changes range, isolates projects, and exposes exact day values',async()=>{
|
|
8
8
|
const request=vi.fn().mockResolvedValue(data);render(<UsageView request={request}/>);
|
|
9
9
|
await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
|
|
@@ -122,3 +122,68 @@ test('invalid saved Usage ranges fall back to 30 days', async () => {
|
|
|
122
122
|
await screen.findByRole('table');
|
|
123
123
|
expect(request).toHaveBeenCalledWith({range:'30d',timeZone:expect.any(String)});
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
test('project tokens share selection and highlight with cost and expose exact values',async()=>{
|
|
127
|
+
const request=vi.fn().mockResolvedValue(data);const view=render(<UsageView request={request}/>);await screen.findByRole('table');
|
|
128
|
+
expect(screen.getByRole('heading',{name:'Tokens and cost by project'})).toBeVisible();expect(screen.queryByText(/over time/)).toBeNull();
|
|
129
|
+
fireEvent.focus(screen.getByRole('button',{name:'Oct 15 project usage'}));expect(screen.getByText(/Alpha: 120 tokens, \$2.00 · Beta: 80 tokens, \$1.00/)).toBeVisible();
|
|
130
|
+
fireEvent.mouseEnter(screen.getByRole('button',{name:'Alpha'}));
|
|
131
|
+
expect(screen.getByRole('img',{name:'Daily project token stack: all projects'}).querySelector('[data-project="b"]')).toHaveAttribute('opacity','0.2');
|
|
132
|
+
fireEvent.click(screen.getByRole('button',{name:'Alpha'}));expect(screen.getByRole('img',{name:'Daily project token stack: Alpha'}).querySelectorAll('polygon')).toHaveLength(1);
|
|
133
|
+
expect(view.container.querySelectorAll('h3')).not.toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
test('weekly summary connects gaps and resets, hides model buckets and shows current allowance in the view timezone',async()=>{
|
|
136
|
+
vi.useFakeTimers({toFake:['Date']});vi.setSystemTime(new Date('2026-10-17T00:00:00Z'));
|
|
137
|
+
try {
|
|
138
|
+
const days=Array.from({length:5},(_,i)=>({...data.days[0],date:`2026-10-${11+i}`}));
|
|
139
|
+
const reading=(date:string,remainingPercent:number,resetsAt:number,id='codex')=>({id,name:null,date,time:Date.parse(`${date}T00:00:00Z`),remainingPercent,resetsAt});
|
|
140
|
+
const reset=Date.parse('2026-10-13T00:00:00Z'), nextReset=Date.parse('2026-10-20T01:00:00Z');
|
|
141
|
+
const current=reading('2026-10-17',89,nextReset);
|
|
142
|
+
const request=vi.fn().mockResolvedValue({...data,samples:0,days,currentWeeklyLimit:current,weeklyLimits:[reading('2026-10-11',80,reset),reading('2026-10-12',70,reset),reading('2026-10-13',100,nextReset),reading('2026-10-15',90,nextReset),reading('2026-10-15',50,nextReset,'gpt-5.3')]});
|
|
143
|
+
render(<UsageView request={request}/>);const chart=await screen.findByRole('img',{name:'Recorded weekly allowance remaining by day'});
|
|
144
|
+
expect(chart.querySelectorAll('circle')).toHaveLength(4);
|
|
145
|
+
expect(chart.querySelector('polyline')?.getAttribute('points')?.split(' ')).toHaveLength(6);
|
|
146
|
+
expect(screen.getByRole('heading',{name:'Weekly limits · 89% left'})).toBeVisible();
|
|
147
|
+
const expected=new Date(nextReset).toLocaleString(undefined,{timeZone:data.timeZone,month:'short',day:'numeric',hour:'numeric',minute:'2-digit',timeZoneName:'short'});
|
|
148
|
+
expect(screen.getByText(`Resets ${expected}`)).toBeVisible();
|
|
149
|
+
expect(screen.queryByText('codex')).toBeNull();expect(screen.queryByText(/GPT-5.3/i)).toBeNull();expect(screen.queryByText(/Last observation/)).toBeNull();expect(screen.queryByRole('table')).toBeNull();
|
|
150
|
+
expect(screen.getByRole('heading',{name:'Recorded tokens'}).closest('.usage-totals')).toContainElement(chart);
|
|
151
|
+
} finally {vi.useRealTimers();}
|
|
152
|
+
});
|
|
153
|
+
test('project charts expose ten distinct projects and only put lower ranks in Other',async()=>{
|
|
154
|
+
const projects=Array.from({length:12},(_,i)=>({id:String(i),name:`Project ${i+1}`,tokens:12-i,costUsd:12-i}));
|
|
155
|
+
const day={...data.days[0],projects:Object.fromEntries(projects.map(p=>[p.id,p.costUsd])),projectTokens:Object.fromEntries(projects.map(p=>[p.id,p.tokens]))};
|
|
156
|
+
render(<UsageView request={vi.fn().mockResolvedValue({...data,projects,days:[day]})}/>);await screen.findByRole('table');
|
|
157
|
+
const chart=screen.getByRole('img',{name:'Daily project token stack: all projects'});expect(chart.querySelectorAll('polygon')).toHaveLength(11);
|
|
158
|
+
expect(new Set([...chart.querySelectorAll('polygon')].map(p=>p.getAttribute('fill'))).size).toBe(11);
|
|
159
|
+
fireEvent.focus(screen.getByRole('button',{name:'Oct 15 project usage'}));expect(screen.getByText(/Other: 3 tokens, \$3.00/)).toBeVisible();
|
|
160
|
+
fireEvent.click(screen.getByRole('button',{name:'Project 10'}));expect(screen.getByRole('img',{name:'Daily project token stack: Project 10'}).querySelectorAll('polygon')).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
test('expired current allowance is unavailable while history remains visible',async()=>{
|
|
163
|
+
const sample={id:'codex',name:null,time:1,resetsAt:2,remainingPercent:80};
|
|
164
|
+
render(<UsageView request={vi.fn().mockResolvedValue({...data,currentWeeklyLimit:sample})}/>);
|
|
165
|
+
expect(await screen.findByText('Current allowance unavailable')).toBeVisible();expect(screen.queryByText(/80% left/)).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a failed background refresh keeps saved charts visible and Retry forces a fresh read',async()=>{
|
|
169
|
+
const request=vi.fn().mockResolvedValueOnce({...data,refreshing:true}).mockResolvedValueOnce({...data,refreshError:'Saved usage is shown. Background refresh failed.'}).mockResolvedValue(data);
|
|
170
|
+
render(<UsageView request={request}/>);
|
|
171
|
+
await screen.findByRole('table');expect(screen.getByText(/Updating saved usage/)).toBeVisible();
|
|
172
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Saved usage is shown');expect(screen.getByRole('table')).toBeVisible();
|
|
173
|
+
fireEvent.click(screen.getByRole('button',{name:'Retry'}));
|
|
174
|
+
await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'30d',timeZone:expect.any(String),force:true}));
|
|
175
|
+
await waitFor(()=>expect(screen.queryByRole('alert')).toBeNull());expect(screen.getByRole('table')).toBeVisible();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('an idle weekly card expires at its reset without another request',async()=>{
|
|
179
|
+
vi.useFakeTimers();vi.setSystemTime(new Date('2026-10-17T00:00:00Z'));
|
|
180
|
+
try {
|
|
181
|
+
const currentWeeklyLimit={id:'codex',name:null,time:Date.now()-1000,resetsAt:Date.now()+2000,remainingPercent:89};
|
|
182
|
+
const request=vi.fn().mockResolvedValue({...data,currentWeeklyLimit});
|
|
183
|
+
await act(async()=>{render(<UsageView request={request}/>);});
|
|
184
|
+
expect(screen.getByRole('heading',{name:'Weekly limits · 89% left'})).toBeVisible();
|
|
185
|
+
await act(async()=>{vi.advanceTimersByTime(2000);});
|
|
186
|
+
expect(screen.getByText('Current allowance unavailable')).toBeVisible();
|
|
187
|
+
expect(screen.queryByRole('heading',{name:/89% left/})).toBeNull();expect(request).toHaveBeenCalledTimes(1);
|
|
188
|
+
}finally{vi.useRealTimers();}
|
|
189
|
+
});
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { usePreference } from "./preferences";
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
|
|
4
|
+
interface WeeklyLimit { id: string; name: string | null; date?: string; time: number; remainingPercent: number; resetsAt: number }
|
|
4
5
|
export interface UsageData {
|
|
5
|
-
updatedAt?: string; refreshing?: boolean; progress?: ScanProgress;
|
|
6
|
+
updatedAt?: string; refreshing?: boolean; refreshError?: string; progress?: ScanProgress;
|
|
6
7
|
start: string; end: string; timeZone: string; samples: number; incomplete: boolean;
|
|
7
8
|
totals: { tokens: number; costUsd: number; inputTokens: number; cachedInputTokens: number; outputTokens: number };
|
|
8
|
-
days: { date: string; tokens: number; costUsd: number; projects: Record<string, number> }[];
|
|
9
|
+
days: { date: string; tokens: number; costUsd: number; projects: Record<string, number>; projectTokens: Record<string, number> }[];
|
|
10
|
+
weeklyLimits?: WeeklyLimit[];
|
|
11
|
+
currentWeeklyLimit?: WeeklyLimit | null;
|
|
9
12
|
projects: { id: string; name: string; costUsd: number; tokens: number }[];
|
|
10
13
|
}
|
|
11
14
|
interface ScanProgress { completed: number; total: number; }
|
|
@@ -15,7 +18,7 @@ export type UsageResponse = UsageData | { loading: true; progress?: ScanProgress
|
|
|
15
18
|
const money = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: n > 0 && n < .01 ? 4 : 2 }).format(n);
|
|
16
19
|
const count = (n: number) => new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
|
17
20
|
const dateLabel = (value: string) => new Date(`${value}T12:00:00Z`).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
|
|
18
|
-
const colors = ["#6e91ad", "#80ad99", "#b8a0ca", "#c2a06e", "#c78585", "#92979c"];
|
|
21
|
+
const colors = ["#6e91ad", "#80ad99", "#b8a0ca", "#c2a06e", "#c78585", "#7caeb5", "#a9af78", "#bb8caa", "#b28b6f", "#8e97c5", "#92979c"];
|
|
19
22
|
const left = 64, right = 794, top = 16, bottom = 166;
|
|
20
23
|
|
|
21
24
|
export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnType<typeof usageRequest>) => Promise<UsageResponse>; refreshKey?: number }) {
|
|
@@ -28,6 +31,7 @@ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnT
|
|
|
28
31
|
const lastRequest = useRef({ refreshKey, retry: 0, rescan: 0 });
|
|
29
32
|
const [selected, setSelected] = useState<string | null>(null);
|
|
30
33
|
const [activeDay, setActiveDay] = useState<number | null>(null);
|
|
34
|
+
const [activeProjectDay, setActiveProjectDay] = useState<number | null>(null);
|
|
31
35
|
const [retry, setRetry] = useState(0);
|
|
32
36
|
const [rescan, setRescan] = useState(0);
|
|
33
37
|
useEffect(() => {
|
|
@@ -35,7 +39,7 @@ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnT
|
|
|
35
39
|
const force = refreshKey !== lastRequest.current.refreshKey || retry !== lastRequest.current.retry;
|
|
36
40
|
const rebuild = rescan !== lastRequest.current.rescan;
|
|
37
41
|
lastRequest.current = { refreshKey, retry, rescan };
|
|
38
|
-
setLoading(true); setError(null); setProgress(null); setActiveDay(null); setHighlighted(null);
|
|
42
|
+
setLoading(true); setError(null); setProgress(null); setActiveDay(null); setActiveProjectDay(null); setHighlighted(null);
|
|
39
43
|
if (!force && !rebuild) setData(null);
|
|
40
44
|
let timer: ReturnType<typeof setTimeout>;
|
|
41
45
|
async function load(force: boolean, rebuild = false) {
|
|
@@ -58,15 +62,18 @@ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnT
|
|
|
58
62
|
const unfinished = (date: string) => date === today;
|
|
59
63
|
const barStyle = (date: string, color: string) => unfinished(date) ? { fillOpacity: .35, stroke: color, strokeWidth: 1.5, strokeDasharray: "4 3" } : {};
|
|
60
64
|
const project = data?.projects.find(item => item.id === selected);
|
|
61
|
-
const series = data ? (project ? [{ ...project, color: colors[data.projects.indexOf(project) <
|
|
62
|
-
: [...data.projects.slice(0,
|
|
63
|
-
...(data.projects.length >
|
|
65
|
+
const series = data ? (project ? [{ ...project, color: colors[data.projects.indexOf(project) < 10 ? data.projects.indexOf(project) : 10] }]
|
|
66
|
+
: [...data.projects.slice(0, 10).map((item, index) => ({ ...item, color: colors[index] })),
|
|
67
|
+
...(data.projects.length > 10 ? [{ id: "__other", name: "Other", costUsd: 0, tokens: 0, color: colors[10] }] : [])]) : [];
|
|
64
68
|
const activeHighlight = series.some(item => item.id === highlighted) ? highlighted : null;
|
|
65
69
|
const dayWidth = data ? (right - left) / data.days.length : 1;
|
|
66
70
|
const maxCost = Math.max(...(data?.days.map(day => day.costUsd) ?? []), .01);
|
|
67
71
|
const maxTokens = Math.max(...(data?.days.map(day => day.tokens) ?? []), 1);
|
|
68
72
|
const projectCost = (day: UsageData["days"][number], id: string) => id === "__other"
|
|
69
|
-
? data!.projects.slice(
|
|
73
|
+
? data!.projects.slice(10).reduce((sum, item) => sum + (day.projects[item.id] ?? 0), 0) : day.projects[id] ?? 0;
|
|
74
|
+
const projectTokens = (day: UsageData["days"][number], id: string) => id === "__other"
|
|
75
|
+
? data!.projects.slice(10).reduce((sum, item) => sum + (day.projectTokens?.[item.id] ?? 0), 0) : day.projectTokens?.[id] ?? 0;
|
|
76
|
+
const maxProjectTokens = Math.max(...(data?.days.map(day => series.reduce((sum, item) => sum + projectTokens(day, item.id), 0)) ?? []), 1);
|
|
70
77
|
const maxProjectCost = Math.max(...(data?.days.map(day => series.reduce((sum, item) => sum + projectCost(day, item.id), 0)) ?? []), .01);
|
|
71
78
|
const x = (index: number) => left + (index + .5) * dayWidth;
|
|
72
79
|
const y = (value: number, max: number) => bottom - value / max * (bottom - top);
|
|
@@ -91,13 +98,15 @@ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnT
|
|
|
91
98
|
</div>}
|
|
92
99
|
{error && <div role="alert"><p>Usage could not be loaded: {error}</p><button onClick={() => setRetry(value => value + 1)}>Retry</button></div>}
|
|
93
100
|
{data && <>
|
|
101
|
+
{data.refreshError && <p role="alert">{data.refreshError} <button onClick={() => setRetry(value => value + 1)}>Retry</button></p>}
|
|
94
102
|
<p className="usage-period">{dateLabel(data.start)} – {dateLabel(data.end)} · {data.timeZone} · Daily · Includes archived chats and subagent usage{data.updatedAt && <> · Updated {new Date(data.updatedAt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</>}</p>
|
|
95
|
-
|
|
96
|
-
<div className="usage-totals">
|
|
103
|
+
<div className="usage-totals">
|
|
97
104
|
<article><h3>Recorded tokens</h3><strong>{count(data.totals.tokens)}</strong><p>Input {count(data.totals.inputTokens)} · Cached input {count(data.totals.cachedInputTokens)} · Output {count(data.totals.outputTokens)}</p></article>
|
|
98
105
|
<article><h3>Estimated cost</h3><strong>{money(data.totals.costUsd)}</strong><p>API-equivalent prices · USD</p></article>
|
|
106
|
+
<WeeklyLimits data={data} />
|
|
99
107
|
</div>
|
|
100
|
-
|
|
108
|
+
{!data.samples ? <p role="status">No timestamped usage is available for this period.</p> : <>
|
|
109
|
+
<article className="usage-panel" aria-label="Tokens and cost"><h3>Tokens and cost</h3>
|
|
101
110
|
<div className="usage-legend"><span className="usage-token-key">━ Tokens</span><span><i style={{ background: colors[0] }} /> Estimated cost</span></div>
|
|
102
111
|
<div className="usage-chart-scroll"><svg viewBox="0 0 860 210" role="img" aria-label="Daily estimated cost bars and token line" className="usage-chart">
|
|
103
112
|
{grid(maxCost)}
|
|
@@ -108,22 +117,85 @@ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnT
|
|
|
108
117
|
{data.days.map((day, index) => <g key={day.date} tabIndex={0} role="button" aria-label={`${dateLabel(day.date)}: ${money(day.costUsd)}, ${day.tokens.toLocaleString()} tokens${unfinished(day.date) ? " (in progress)" : ""}`} onMouseEnter={() => setActiveDay(index)} onFocus={() => setActiveDay(index)} onClick={() => setActiveDay(index)} onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setActiveDay(index); } }}><rect x={left + index * dayWidth} y={top} width={dayWidth} height={bottom - top} fill="transparent" /><circle cx={x(index)} cy={y(day.tokens, maxTokens)} r={data.days.length > 30 ? 0 : 2.5} className="usage-token-dot" /></g>)}
|
|
109
118
|
{dates()}
|
|
110
119
|
</svg></div>
|
|
111
|
-
<p className="usage-chart-detail" aria-live="polite">{activeDay === null ? "
|
|
120
|
+
<p className="usage-chart-detail" aria-live="polite">{activeDay === null ? "" : `${dateLabel(data.days[activeDay].date)} · ${data.days[activeDay].tokens.toLocaleString()} tokens · ${money(data.days[activeDay].costUsd)}`}</p>
|
|
112
121
|
</article>
|
|
113
|
-
<article className="usage-panel" aria-label="
|
|
114
|
-
<
|
|
115
|
-
<
|
|
122
|
+
<article className="usage-panel" aria-label="Tokens and cost by project"><h3>Tokens and cost by project</h3>
|
|
123
|
+
<div className="usage-legend"><button aria-pressed={!project} onMouseEnter={() => setHighlighted(null)} onFocus={() => setHighlighted(null)} onClick={() => setSelected(null)}>All projects</button>{data.projects.slice(0, 10).map((item, index) => <button key={item.id} aria-pressed={project?.id === item.id} onMouseEnter={() => setHighlighted(item.id)} onMouseLeave={() => setHighlighted(null)} onFocus={() => setHighlighted(item.id)} onBlur={() => setHighlighted(null)} onClick={() => setSelected(project?.id === item.id ? null : item.id)}><i style={{ background: colors[index] }} />{item.name}</button>)}{data.projects.length > 10 && <span tabIndex={0} onMouseEnter={() => setHighlighted("__other")} onMouseLeave={() => setHighlighted(null)} onFocus={() => setHighlighted("__other")} onBlur={() => setHighlighted(null)}><i style={{ background: colors[10] }} />Other</span>}</div>
|
|
124
|
+
<h4>Estimated cost</h4>
|
|
116
125
|
<div className="usage-chart-scroll"><svg viewBox="0 0 860 210" role="img" aria-label={`Daily project cost bars: ${project?.name ?? "all projects"}`} className="usage-chart">
|
|
117
126
|
{grid(maxProjectCost)}
|
|
118
127
|
{data.days.map((day, index) => { let accumulated = 0; return <g key={day.date}>{series.map(item => { const cost = projectCost(day, item.id); const start = accumulated; accumulated += cost; return <rect key={item.id} x={x(index) - dayWidth * .3} y={y(accumulated, maxProjectCost)} width={dayWidth * .6} height={(cost / maxProjectCost) * (bottom - top)} fill={item.color} {...barStyle(day.date, item.color)} data-project={item.id} opacity={activeHighlight && activeHighlight !== item.id ? .2 : 1}><title>{dateLabel(day.date)}{unfinished(day.date) ? " (in progress)" : ""} · {item.name}: {money(accumulated - start)}</title></rect>; })}</g>; })}
|
|
119
128
|
{dates()}
|
|
120
129
|
</svg></div>
|
|
130
|
+
<h4>Tokens</h4>
|
|
131
|
+
<div className="usage-chart-scroll"><svg viewBox="0 0 860 210" role="img" aria-label={`Daily project token stack: ${project?.name ?? "all projects"}`} className="usage-chart">
|
|
132
|
+
{grid(maxProjectTokens, true)}
|
|
133
|
+
{series.map((item, seriesIndex) => {
|
|
134
|
+
const lower = data.days.map(day => series.slice(0, seriesIndex).reduce((sum, preceding) => sum + projectTokens(day, preceding.id), 0));
|
|
135
|
+
const upper = data.days.map((day, index) => lower[index] + projectTokens(day, item.id));
|
|
136
|
+
const finished = data.days.map((day, index) => ({ day, index })).filter(({ day }) => !unfinished(day.date));
|
|
137
|
+
return <g key={item.id} data-project={item.id} opacity={activeHighlight && activeHighlight !== item.id ? .2 : 1}>
|
|
138
|
+
<polygon fill={item.color} fillOpacity=".3" points={[...data.days.map((_, index) => `${x(index)},${y(upper[index], maxProjectTokens)}`), ...data.days.map((_, index) => `${x(index)},${y(lower[index], maxProjectTokens)}`).reverse()].join(" ")} />
|
|
139
|
+
<polyline fill="none" stroke={item.color} strokeWidth="2" points={finished.map(({ index }) => `${x(index)},${y(upper[index], maxProjectTokens)}`).join(" ")} />
|
|
140
|
+
{data.days.map((day, index) => unfinished(day.date) && index > 0 ? <polyline key={day.date} fill="none" stroke={item.color} strokeWidth="2" strokeDasharray="5 4" points={`${x(index - 1)},${y(upper[index - 1], maxProjectTokens)} ${x(index)},${y(upper[index], maxProjectTokens)}`} /> : null)}
|
|
141
|
+
{data.days.map((day, index) => <circle key={day.date} cx={x(index)} cy={y(upper[index], maxProjectTokens)} r="2.5" fill={item.color}><title>{dateLabel(day.date)} · {item.name}: {projectTokens(day, item.id).toLocaleString()} tokens</title></circle>)}
|
|
142
|
+
</g>;
|
|
143
|
+
})}
|
|
144
|
+
{data.days.map((day, index) => <g key={day.date} tabIndex={0} role="button" aria-label={`${dateLabel(day.date)} project usage`} onMouseEnter={() => setActiveProjectDay(index)} onFocus={() => setActiveProjectDay(index)} onClick={() => setActiveProjectDay(index)} onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setActiveProjectDay(index); } }}><rect x={left + index * dayWidth} y={top} width={dayWidth} height={bottom - top} fill="transparent" /></g>)}
|
|
145
|
+
{dates()}
|
|
146
|
+
</svg></div>
|
|
147
|
+
<p className="usage-chart-detail" aria-live="polite">{activeProjectDay === null ? "" : `${dateLabel(data.days[activeProjectDay].date)} · ${series.map(item => `${item.name}: ${projectTokens(data.days[activeProjectDay], item.id).toLocaleString()} tokens, ${money(projectCost(data.days[activeProjectDay], item.id))}`).join(" · ")}`}</p>
|
|
121
148
|
</article>
|
|
122
149
|
<article className="usage-panel" aria-label="Top 10 projects"><h3>Top 10 projects</h3>
|
|
123
|
-
<div className="usage-ranking"><table><thead><tr><th scope="col">Project</th><th scope="col">Estimated cost</th><th scope="col">Share</th></tr></thead><tbody>{data.projects.slice(0, 10).map((item, index) => <tr key={item.id}><th scope="row"><button aria-pressed={project?.id === item.id} onClick={() => setSelected(project?.id === item.id ? null : item.id)}><span>{index + 1}. {item.name}</span><span className="usage-rank-track" aria-hidden><span style={{ width: `${data.projects[0].costUsd ? item.costUsd / data.projects[0].costUsd * 100 : 0}%`, background: colors[index <
|
|
150
|
+
<div className="usage-ranking"><table><thead><tr><th scope="col">Project</th><th scope="col">Estimated cost</th><th scope="col">Share</th></tr></thead><tbody>{data.projects.slice(0, 10).map((item, index) => <tr key={item.id}><th scope="row"><button aria-pressed={project?.id === item.id} onClick={() => setSelected(project?.id === item.id ? null : item.id)}><span>{index + 1}. {item.name}</span><span className="usage-rank-track" aria-hidden><span style={{ width: `${data.projects[0].costUsd ? item.costUsd / data.projects[0].costUsd * 100 : 0}%`, background: colors[index < 10 ? index : 10] }} /></span></button></th><td>{money(item.costUsd)}</td><td>{data.totals.costUsd ? (item.costUsd / data.totals.costUsd * 100).toFixed(1) : "0.0"}%</td></tr>)}</tbody></table></div>
|
|
124
151
|
</article>
|
|
125
152
|
</>}
|
|
126
153
|
<p className="usage-footnote">Cost is estimated at API-equivalent prices, not your subscription bill or remaining allowance.</p>
|
|
127
154
|
</>}
|
|
128
155
|
</section>;
|
|
129
156
|
}
|
|
157
|
+
|
|
158
|
+
function WeeklyLimits({ data }: { data: UsageData }) {
|
|
159
|
+
const [now, setNow] = useState(Date.now);
|
|
160
|
+
const resetsAt = data.currentWeeklyLimit?.resetsAt;
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
163
|
+
function update() {
|
|
164
|
+
const time = Date.now();
|
|
165
|
+
setNow(time);
|
|
166
|
+
if (resetsAt && resetsAt > time) timer = setTimeout(update, Math.min(resetsAt - time, 2_147_483_647));
|
|
167
|
+
}
|
|
168
|
+
update();
|
|
169
|
+
return () => clearTimeout(timer);
|
|
170
|
+
}, [resetsAt]);
|
|
171
|
+
const samples = (data.weeklyLimits ?? []).filter(sample => sample.id === "codex").sort((a, b) => a.time - b.time);
|
|
172
|
+
const current = data.currentWeeklyLimit && data.currentWeeklyLimit.resetsAt > now ? data.currentWeeklyLimit : null;
|
|
173
|
+
const dateTime = (time: number) => new Date(time).toLocaleString(undefined, { timeZone: data.timeZone, month: "short", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" });
|
|
174
|
+
const dayIndex = new Map(data.days.map((day, index) => [day.date, index]));
|
|
175
|
+
const position = (time: number) => {
|
|
176
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: data.timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(time).map(part => [part.type, part.value]));
|
|
177
|
+
const index = dayIndex.get(`${parts.year}-${parts.month}-${parts.day}`) ?? 0;
|
|
178
|
+
return 30 + (index + (Number(parts.hour) + Number(parts.minute) / 60) / 24) / data.days.length * 264;
|
|
179
|
+
};
|
|
180
|
+
const y = (value: number) => 72 - value / 100 * 60;
|
|
181
|
+
const points: string[] = [];
|
|
182
|
+
samples.forEach((sample, index) => {
|
|
183
|
+
const previous = samples[index - 1];
|
|
184
|
+
if (previous && previous.resetsAt <= sample.time && previous.resetsAt > previous.time) {
|
|
185
|
+
// Preserve the observed allowance until its known reset, then refill.
|
|
186
|
+
points.push(`${position(previous.resetsAt)},${y(previous.remainingPercent)}`, `${position(previous.resetsAt)},${y(100)}`);
|
|
187
|
+
}
|
|
188
|
+
points.push(`${position(sample.time)},${y(sample.remainingPercent)}`);
|
|
189
|
+
});
|
|
190
|
+
return <article className="usage-weekly" aria-label="Weekly limits">
|
|
191
|
+
<h3>Weekly limits{current && ` · ${current.remainingPercent.toFixed(0)}% left`}</h3>
|
|
192
|
+
<p className="usage-weekly-reset">{current ? `Resets ${dateTime(current.resetsAt)}` : "Current allowance unavailable"}</p>
|
|
193
|
+
{!samples.length ? <p>No weekly limit readings for this period.</p> : <svg viewBox="0 0 300 96" role="img" aria-label="Recorded weekly allowance remaining by day" className="usage-weekly-chart">
|
|
194
|
+
<title>Recorded allowance snapshots from Codex session logs; lines connect observations and show known resets.</title>
|
|
195
|
+
{[0, 50, 100].map(value => <g key={value}><line x1="30" x2="294" y1={y(value)} y2={y(value)} className="usage-grid" /><text x="24" y={y(value) + 3} textAnchor="end">{value}%</text></g>)}
|
|
196
|
+
<polyline fill="none" stroke={colors[0]} strokeWidth="2" points={points.join(" ")} />
|
|
197
|
+
{samples.map(sample => <circle key={sample.time} cx={position(sample.time)} cy={y(sample.remainingPercent)} r="2.5" fill={colors[0]}><title>{dateTime(sample.time)} · {sample.remainingPercent.toFixed(1)}% left · Resets {dateTime(sample.resetsAt)}</title></circle>)}
|
|
198
|
+
<text x="30" y="90">{dateLabel(data.start)}</text><text x="294" y="90" textAnchor="end">{dateLabel(data.end)}</text>
|
|
199
|
+
</svg>}
|
|
200
|
+
</article>;
|
|
201
|
+
}
|
|
@@ -230,16 +230,20 @@ body { background: var(--taskchef-page); }
|
|
|
230
230
|
.usage-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
|
231
231
|
.usage-view button:disabled { opacity: .5; cursor: default; }
|
|
232
232
|
.usage-view h2 { font-size: 24px; margin: 4px 0 8px; }
|
|
233
|
+
.usage-view h4 { font-size: 12px; font-weight: 500; margin: 16px 0 4px; color: var(--taskchef-muted); }
|
|
233
234
|
.usage-view h3 { font-size: 14px; margin: 0 0 12px; }
|
|
234
235
|
.usage-view p, .usage-view small { color: var(--taskchef-muted); font-size: 12px; line-height: 1.5; margin: 8px 0; }
|
|
235
236
|
.usage-ranges, .usage-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0; }
|
|
236
237
|
.usage-view button { font: inherit; font-size: 12px; color: var(--taskchef-text); background: transparent; border: 1px solid var(--taskchef-border); border-radius: 6px; padding: 6px 10px; cursor: pointer; }
|
|
237
238
|
.usage-view button:hover, .usage-view button[aria-pressed="true"] { background: var(--taskchef-hover); border-color: var(--taskchef-muted); }
|
|
238
239
|
.usage-view button:focus-visible, .usage-chart g:focus-visible { outline: 2px solid var(--taskchef-focus); outline-offset: 2px; }
|
|
239
|
-
.usage-totals { display: grid; grid-template-columns: repeat(
|
|
240
|
+
.usage-totals { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 16px 0; }
|
|
240
241
|
.usage-totals article, .usage-panel { padding: 18px; border: 1px solid var(--taskchef-border); border-radius: 10px; background: var(--taskchef-surface); min-width: 0; }
|
|
241
242
|
.usage-totals strong { font-size: 28px; font-weight: 600; }
|
|
242
243
|
.usage-panel { margin: 16px 0; }
|
|
244
|
+
.usage-weekly-chart { display: block; width: 100%; font: 9px var(--taskchef-font, sans-serif); fill: var(--taskchef-muted); }
|
|
245
|
+
.usage-weekly h3 { margin-bottom: 8px; }
|
|
246
|
+
.usage-weekly-reset { min-height: 18px; }
|
|
243
247
|
.usage-legend { font-size: 12px; gap: 12px; }
|
|
244
248
|
.usage-legend span, .usage-legend button { display: inline-flex; align-items: center; gap: 6px; }
|
|
245
249
|
.usage-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
|
@@ -262,7 +266,8 @@ body { background: var(--taskchef-page); }
|
|
|
262
266
|
.usage-rank-track { flex: 1; height: 8px; border-radius: 2px; background: var(--taskchef-border); overflow: hidden; }
|
|
263
267
|
.usage-rank-track > span { display: block; height: 100%; }
|
|
264
268
|
.usage-warning { padding: 10px; border: 1px solid var(--taskchef-border); border-radius: 6px; }
|
|
265
|
-
@media (max-width:
|
|
269
|
+
@media (max-width: 700px) { .usage-totals { grid-template-columns: 1fr; gap: 10px; } }
|
|
270
|
+
@media (max-width: 500px) { .usage-panel { padding: 12px; } .usage-rank-track { display: none; } .usage-ranking button > span:first-child { width: 100%; } }
|
|
266
271
|
|
|
267
272
|
.usage-progress { margin: 16px 0; }
|
|
268
273
|
.usage-progress progress { display: block; width: 100%; height: 6px; accent-color: var(--taskchef-accent); }
|
package/src/mcp-app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { taskChefStateDir } from "./taskchef-state.js";
|
|
2
|
+
import { readUsageHistory, writeUsageHistory } from "./usage-history-cache.js";
|
|
2
3
|
import { aggregateUsage } from "./usage-analytics.js";
|
|
3
4
|
import { mkdir, readFile } from "node:fs/promises";
|
|
4
5
|
import { homedir } from "node:os";
|
|
@@ -231,14 +232,25 @@ export function registerTaskChefApp(server, {
|
|
|
231
232
|
const settings = await readSettings();
|
|
232
233
|
// History is independent of chart range and timezone. Range changes query
|
|
233
234
|
// this shared snapshot; only expiry or explicit Refresh starts another read.
|
|
234
|
-
const key = JSON.stringify([settings.showCli, settings.showExec]);
|
|
235
|
+
const key = JSON.stringify([scanner.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"), settings.showCli, settings.showExec]);
|
|
236
|
+
const cacheDirectory = join(dirname(settingsPath), "cache", "usage-history");
|
|
235
237
|
let job = analyticsJobs.get(key);
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
pending: true, refreshAgain: false, rescan: !!rescan, progress: { completed: 0, total: 0 } };
|
|
238
|
+
if (!job) {
|
|
239
|
+
job = { families: null, error: null, finishedAt: 0, pending: false, needsRefresh: true,
|
|
240
|
+
progress: { completed: 0, total: 0 } };
|
|
240
241
|
analyticsJobs.set(key, job);
|
|
241
242
|
const current = job;
|
|
243
|
+
// Share hydration between concurrent views before starting one background scan.
|
|
244
|
+
job.hydrating = readUsageHistory(cacheDirectory, key).then(saved => {
|
|
245
|
+
if (saved) { current.families = saved.families; current.finishedAt = saved.savedAt; }
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
await job.hydrating;
|
|
249
|
+
if ((force || rescan) && job?.pending) { job.refreshAgain = true; job.rescan ||= rescan; }
|
|
250
|
+
if (!job.pending && (job.needsRefresh || force || rescan || (!job.error && Date.now() - job.finishedAt >= 60_000))) {
|
|
251
|
+
Object.assign(job, { error: null, needsRefresh: false,
|
|
252
|
+
pending: true, refreshAgain: false, rescan: !!rescan, progress: { completed: 0, total: 0 } });
|
|
253
|
+
const current = job;
|
|
242
254
|
Promise.resolve().then(async () => {
|
|
243
255
|
do {
|
|
244
256
|
current.refreshAgain = false;
|
|
@@ -247,12 +259,15 @@ export function registerTaskChefApp(server, {
|
|
|
247
259
|
const families = await scanner.usageHistory({ settings, rescan: rebuild, onProgress: progress => { current.progress = progress; } });
|
|
248
260
|
current.families = families;
|
|
249
261
|
current.finishedAt = Date.now();
|
|
262
|
+
// Cache failures never hide readable usage or replace a last good snapshot.
|
|
263
|
+
await writeUsageHistory(cacheDirectory, key, families, current.finishedAt).catch(() => {});
|
|
250
264
|
} while (current.refreshAgain);
|
|
251
265
|
}).catch(error => { current.error = error; }).finally(() => { current.pending = false; });
|
|
252
266
|
}
|
|
253
|
-
if (job.error) throw job.error;
|
|
267
|
+
if (job.error && !job.families) throw job.error;
|
|
254
268
|
const data = job.families ? { ...aggregateUsage(job.families, { range, timeZone }),
|
|
255
269
|
updatedAt: new Date(job.finishedAt).toISOString(),
|
|
270
|
+
...(job.error ? { refreshError: "Saved usage is shown. Background refresh failed." } : {}),
|
|
256
271
|
...(job.pending ? { refreshing: true, progress: job.progress } : {}) }
|
|
257
272
|
: { loading: true, progress: job.progress };
|
|
258
273
|
return { structuredContent: data, content: [] };
|
package/src/usage-analytics.js
CHANGED
|
@@ -16,13 +16,25 @@ export function aggregateUsage(families, { range, timeZone, now = Date.now() })
|
|
|
16
16
|
const start = startDate.toISOString().slice(0, 10);
|
|
17
17
|
const days = [];
|
|
18
18
|
for (let cursor = startDate; cursor.toISOString().slice(0, 10) <= end; cursor = new Date(cursor.getTime() + 86400000)) {
|
|
19
|
-
days.push({ date: cursor.toISOString().slice(0, 10), tokens: 0, costUsd: 0, projects: {} });
|
|
19
|
+
days.push({ date: cursor.toISOString().slice(0, 10), tokens: 0, costUsd: 0, projects: {}, projectTokens: {} });
|
|
20
20
|
}
|
|
21
21
|
const byDate = new Map(days.map(day => [day.date, day]));
|
|
22
22
|
const projects = new Map();
|
|
23
|
-
|
|
23
|
+
const limitDays = new Map();
|
|
24
|
+
let incomplete = false, samples = 0, latestWeeklyLimit = null;
|
|
24
25
|
const totals = { tokens: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
25
26
|
for (const { project, usage } of families) {
|
|
27
|
+
// Account allowance snapshots are observations, never summed across chats.
|
|
28
|
+
for (const sample of usage.weeklyLimits ?? []) {
|
|
29
|
+
if (!Number.isFinite(sample.time) || sample.time > now || !Number.isFinite(sample.remainingPercent)
|
|
30
|
+
|| sample.remainingPercent < 0 || sample.remainingPercent > 100 || !Number.isFinite(sample.resetsAt) || sample.resetsAt <= sample.time) continue;
|
|
31
|
+
if (sample.id !== "codex") continue;
|
|
32
|
+
if (!latestWeeklyLimit || sample.time > latestWeeklyLimit.time) latestWeeklyLimit = sample;
|
|
33
|
+
const date = dateKey(sample.time);
|
|
34
|
+
if (!byDate.has(date)) continue;
|
|
35
|
+
const key = JSON.stringify([sample.id, date]);
|
|
36
|
+
if (!limitDays.has(key) || sample.time > limitDays.get(key).time) limitDays.set(key, { ...sample, date });
|
|
37
|
+
}
|
|
26
38
|
// Coverage can be incomplete even when no timestamped records survive.
|
|
27
39
|
incomplete ||= usage.analyticsPartial || usage.partial || false;
|
|
28
40
|
const key = project.id === 'projectless' ? 'unassigned' : project.id || project.path || 'unassigned';
|
|
@@ -38,10 +50,13 @@ export function aggregateUsage(families, { range, timeZone, now = Date.now() })
|
|
|
38
50
|
totals.costUsd += sample.costUsd; samples += sample.samples;
|
|
39
51
|
day.tokens += tokens; day.costUsd += sample.costUsd;
|
|
40
52
|
day.projects[key] = (day.projects[key] ?? 0) + sample.costUsd;
|
|
53
|
+
day.projectTokens[key] = (day.projectTokens[key] ?? 0) + tokens;
|
|
41
54
|
item.tokens += tokens; item.costUsd += sample.costUsd;
|
|
42
55
|
incomplete ||= sample.partial || sample.costPartial;
|
|
43
56
|
}
|
|
44
57
|
}
|
|
45
58
|
return { start, end, timeZone, days, totals, samples, incomplete,
|
|
59
|
+
currentWeeklyLimit: latestWeeklyLimit?.resetsAt > now ? latestWeeklyLimit : null,
|
|
60
|
+
weeklyLimits: [...limitDays.values()].sort((a, b) => a.time - b.time || a.id.localeCompare(b.id)),
|
|
46
61
|
projects: [...projects.values()].sort((a, b) => b.costUsd - a.costUsd || a.name.localeCompare(b.name)) };
|
|
47
62
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { writeDurableAtomic } from "./state-store.js";
|
|
6
|
+
|
|
7
|
+
const nonnegative = z.number().finite().nonnegative();
|
|
8
|
+
const timestamp = nonnegative.max(8.64e15);
|
|
9
|
+
// Keep only fields needed by aggregateUsage, not message content or per-file records.
|
|
10
|
+
const family = z.object({
|
|
11
|
+
project: z.object({ id: z.string().nullable().optional(), name: z.string(), path: z.string().optional() }),
|
|
12
|
+
usage: z.object({
|
|
13
|
+
analyticsPartial: z.boolean().optional(), partial: z.boolean().optional(),
|
|
14
|
+
timeline: z.array(z.object({
|
|
15
|
+
time: timestamp, costUsd: nonnegative, samples: nonnegative.int(),
|
|
16
|
+
partial: z.boolean(), costPartial: z.boolean(),
|
|
17
|
+
tokens: z.object({ total_tokens: nonnegative.int(), input_tokens: nonnegative.int(), cached_input_tokens: nonnegative.int(), output_tokens: nonnegative.int() }),
|
|
18
|
+
})),
|
|
19
|
+
weeklyLimits: z.array(z.object({
|
|
20
|
+
id: z.string(), name: z.string().nullable(), time: timestamp,
|
|
21
|
+
remainingPercent: nonnegative.max(100), resetsAt: timestamp,
|
|
22
|
+
})).optional(),
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
const snapshot = z.object({ version: z.literal(1), scope: z.string(), savedAt: timestamp, families: z.array(family) });
|
|
26
|
+
const cachePath = (directory, scope) => join(directory, `${createHash("sha256").update(scope).digest("hex")}.json`);
|
|
27
|
+
|
|
28
|
+
export async function readUsageHistory(directory, scope) {
|
|
29
|
+
try {
|
|
30
|
+
const value = snapshot.parse(JSON.parse(await readFile(cachePath(directory, scope), "utf8")));
|
|
31
|
+
return value.scope === scope && value.savedAt <= Date.now() ? value : null;
|
|
32
|
+
} catch { return null; } // Missing, incompatible, or damaged caches rebuild from source.
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function writeUsageHistory(directory, scope, families, savedAt) {
|
|
36
|
+
const value = snapshot.parse({ version: 1, scope, savedAt, families });
|
|
37
|
+
await writeDurableAtomic(cachePath(directory, scope), `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
38
|
+
}
|