@adecore/agents 0.38.0 → 0.40.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/dist/async.d.ts +5 -0
- package/dist/async.js +13 -5
- package/dist/chat/acp-protocol.js +3 -5
- package/dist/chat/chat-core.d.ts +1 -0
- package/dist/chat/chat-core.js +9 -2
- package/dist/chat/chat-process.d.ts +1 -0
- package/dist/chat/chat-process.js +1 -1
- package/dist/chat/chat-session.d.ts +2 -0
- package/dist/chat/chat-session.js +9 -10
- package/dist/chat/claude-protocol.js +1 -6
- package/dist/chat/claude-transcript.js +1 -3
- package/dist/chat/codex-protocol.js +1 -6
- package/dist/chat/projector.js +1 -3
- package/dist/chat/request-summary.js +1 -3
- package/dist/coded-error.js +1 -5
- package/dist/context/argv.js +1 -2
- package/dist/context/verb.js +1 -3
- package/dist/error-text.d.ts +1 -0
- package/dist/error-text.js +4 -0
- package/dist/fields.d.ts +3 -0
- package/dist/fields.js +12 -0
- package/dist/fs.d.ts +1 -0
- package/dist/fs.js +8 -4
- package/dist/lineage.js +11 -5
- package/dist/messages/notice-store.js +3 -4
- package/dist/providers/accounts/acp-account.js +3 -20
- package/dist/providers/accounts/service.d.ts +1 -2
- package/dist/providers/accounts/service.js +5 -9
- package/dist/providers/accounts/status.js +1 -3
- package/dist/providers/acp-agent-state.js +1 -3
- package/dist/providers/claude.js +0 -1
- package/dist/providers/codex.js +0 -1
- package/dist/providers/detect.js +1 -1
- package/dist/providers/shell-commands.js +1 -2
- package/dist/run-process.js +1 -3
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +5 -8
- package/dist/tasks/background-limit.js +3 -2
- package/dist/tasks/task-coordinator.js +11 -17
- package/dist/testing/watch.js +0 -1
- package/dist/usage/limits/monitor.js +1 -2
- package/dist/usage/readers/claude.js +3 -10
- package/dist/usage/readers/codex.js +2 -9
- package/dist/usage/readers/grok.js +3 -10
- package/dist/usage/readers/host-log.js +3 -10
- package/dist/usage/record.d.ts +2 -0
- package/dist/usage/record.js +13 -0
- package/package.json +6 -6
- package/src/async.ts +23 -5
- package/src/chat/acp-backend.ts +121 -0
- package/src/chat/acp-protocol.ts +172 -6
- package/src/chat/attachment-store.ts +21 -0
- package/src/chat/backend.ts +0 -2
- package/src/chat/bookmark-store.ts +23 -0
- package/src/chat/chat-core.ts +218 -7
- package/src/chat/chat-log.ts +19 -0
- package/src/chat/chat-process.ts +52 -1
- package/src/chat/chat-session.ts +414 -13
- package/src/chat/chat-store.ts +50 -0
- package/src/chat/chat-title.ts +9 -0
- package/src/chat/claude-backend.ts +50 -0
- package/src/chat/claude-protocol.ts +155 -8
- package/src/chat/claude-title.ts +10 -0
- package/src/chat/claude-transcript.ts +47 -4
- package/src/chat/codex-backend.ts +131 -0
- package/src/chat/codex-protocol.ts +180 -8
- package/src/chat/codex-thread.ts +23 -0
- package/src/chat/composer-preferences.ts +6 -0
- package/src/chat/context-breakdown.ts +11 -0
- package/src/chat/delta-coalescer.ts +8 -0
- package/src/chat/external-sessions.ts +28 -0
- package/src/chat/generated-image.ts +25 -0
- package/src/chat/index.ts +5 -0
- package/src/chat/input.ts +13 -0
- package/src/chat/json-rpc-transport.ts +28 -0
- package/src/chat/limit-resume.ts +13 -0
- package/src/chat/projector.ts +160 -4
- package/src/chat/readable-text.ts +3 -0
- package/src/chat/request-summary.ts +18 -4
- package/src/chat/subagent-projection.ts +3 -0
- package/src/chat/subagent-reader.ts +76 -0
- package/src/chat/subagent-settlement.ts +24 -0
- package/src/chat/thread-search.ts +13 -0
- package/src/chat/thread.ts +31 -0
- package/src/chat/ui-queries.ts +122 -0
- package/src/chat/ui-replies.ts +34 -0
- package/src/chat/visual-store.ts +49 -0
- package/src/chat/wake-chat.ts +3 -0
- package/src/client-sinks.ts +2 -0
- package/src/coded-error.ts +1 -5
- package/src/context/argv.ts +18 -2
- package/src/context/index.ts +1 -0
- package/src/context/refusal.ts +3 -0
- package/src/context/verb.ts +32 -4
- package/src/coordination/index.ts +10 -0
- package/src/error-text.ts +7 -0
- package/src/fields.ts +14 -0
- package/src/fs.ts +20 -4
- package/src/host/agent-host.ts +20 -0
- package/src/host/environment.ts +6 -0
- package/src/host/handlers.ts +18 -0
- package/src/host/memory-port.ts +4 -0
- package/src/host/wiring.ts +10 -0
- package/src/index.ts +1 -0
- package/src/lineage.ts +24 -5
- package/src/messages/deliver-message.ts +6 -0
- package/src/messages/deliver-notice.ts +13 -0
- package/src/messages/notice-store.ts +16 -4
- package/src/modes.ts +1 -0
- package/src/outbox/outbox-worker.ts +31 -0
- package/src/outbox/outbox.ts +5 -0
- package/src/providers/accounts/accounts.ts +36 -0
- package/src/providers/accounts/acp-account.ts +22 -27
- package/src/providers/accounts/launch.ts +4 -0
- package/src/providers/accounts/service.ts +110 -12
- package/src/providers/accounts/shadow-home.ts +10 -0
- package/src/providers/accounts/status.ts +13 -4
- package/src/providers/accounts/store.ts +25 -0
- package/src/providers/accounts/variables.ts +11 -1
- package/src/providers/acp-agent-state.ts +24 -3
- package/src/providers/acp-provider.ts +5 -0
- package/src/providers/acp-usage-log.ts +2 -0
- package/src/providers/catalog.ts +17 -0
- package/src/providers/claude.ts +10 -1
- package/src/providers/codex-rules.ts +3 -0
- package/src/providers/codex.ts +2 -1
- package/src/providers/detect.ts +6 -1
- package/src/providers/provider.ts +1 -1
- package/src/providers/registry.ts +18 -2
- package/src/providers/shell-commands.ts +37 -2
- package/src/record-directory.ts +19 -0
- package/src/run-process.ts +24 -3
- package/src/serializer.ts +3 -0
- package/src/skills.ts +73 -10
- package/src/tasks/background-limit.ts +9 -2
- package/src/tasks/end-children.ts +9 -0
- package/src/tasks/give-task.ts +8 -0
- package/src/tasks/task-coordinator.ts +81 -17
- package/src/tasks/task-store.ts +28 -0
- package/src/tasks/waiting-child.ts +21 -0
- package/src/tasks/wake-parent.ts +26 -0
- package/src/tasks/wiring.ts +22 -0
- package/src/testing/accounts.ts +2 -0
- package/src/testing/fake-acp-capture.ts +16 -0
- package/src/testing/fake-acp.ts +66 -0
- package/src/testing/fake-claude.ts +177 -0
- package/src/testing/fake-cli.ts +29 -0
- package/src/testing/fake-codex.ts +122 -0
- package/src/testing/manual-clock.ts +2 -0
- package/src/testing/watch.ts +7 -1
- package/src/title-file.ts +12 -0
- package/src/usage/accounts.ts +6 -0
- package/src/usage/aggregate.ts +19 -0
- package/src/usage/exchange.ts +12 -0
- package/src/usage/index-file.ts +27 -0
- package/src/usage/limits/monitor.ts +31 -3
- package/src/usage/limits/normalize.ts +30 -0
- package/src/usage/limits/probe.ts +17 -0
- package/src/usage/pricing.ts +35 -0
- package/src/usage/projects.ts +8 -0
- package/src/usage/readers/claude.ts +12 -9
- package/src/usage/readers/codex.ts +40 -8
- package/src/usage/readers/grok.ts +17 -9
- package/src/usage/readers/host-log.ts +9 -9
- package/src/usage/record.ts +22 -0
- package/src/usage/roots.ts +6 -0
- package/src/usage/scanner.ts +61 -0
- package/src/usage/usage-service.ts +14 -0
- package/src/watch-seam.ts +8 -0
- package/src/windows-command.ts +12 -0
package/src/usage/accounts.ts
CHANGED
|
@@ -20,8 +20,10 @@ export function machineAccounts(snapshot: ProviderAccounts, nameOf: (kind: Usage
|
|
|
20
20
|
if (!isUsageProvider(account.kind)) {
|
|
21
21
|
return [];
|
|
22
22
|
}
|
|
23
|
+
|
|
23
24
|
const isDefault = id === account.kind;
|
|
24
25
|
const state = snapshot.statuses.find((status) => status.id === id)?.state;
|
|
26
|
+
|
|
25
27
|
return [
|
|
26
28
|
{
|
|
27
29
|
id,
|
|
@@ -52,7 +54,9 @@ export function usageRootsOf(service: ProviderAccountsService): UsageRootPath[]
|
|
|
52
54
|
if (!isUsageProvider(kind)) {
|
|
53
55
|
return [];
|
|
54
56
|
}
|
|
57
|
+
|
|
55
58
|
const folder = service.transcriptFolder(kind, id);
|
|
59
|
+
|
|
56
60
|
return folder === null || folder === '' ? [] : [{ id, kind, folder }];
|
|
57
61
|
})
|
|
58
62
|
);
|
|
@@ -78,10 +82,12 @@ export function limitAccountsOf(
|
|
|
78
82
|
/* The account every CLI session a chat here ran was under, by `<provider>\0<sessionId>`, for the transcripts accounts share. */
|
|
79
83
|
export function chatSessionAccounts(chats: ReadonlyArray<Pick<ChatInfo, 'provider' | 'agentSessionId' | 'account'>>): Map<string, string> {
|
|
80
84
|
const known = new Map<string, string>();
|
|
85
|
+
|
|
81
86
|
for (const chat of chats) {
|
|
82
87
|
if (chat.agentSessionId !== null) {
|
|
83
88
|
known.set(`${chat.provider}\0${chat.agentSessionId}`, chat.account ?? chat.provider);
|
|
84
89
|
}
|
|
85
90
|
}
|
|
91
|
+
|
|
86
92
|
return known;
|
|
87
93
|
}
|
package/src/usage/aggregate.ts
CHANGED
|
@@ -30,6 +30,7 @@ function slotFormatter(timeZone: string, resolution: UsageResolution): Intl.Date
|
|
|
30
30
|
resolution === 'hour'
|
|
31
31
|
? { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hourCycle: 'h23' }
|
|
32
32
|
: { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' };
|
|
33
|
+
|
|
33
34
|
try {
|
|
34
35
|
return new Intl.DateTimeFormat('en-CA', options);
|
|
35
36
|
} catch {
|
|
@@ -41,8 +42,11 @@ function slotFormatter(timeZone: string, resolution: UsageResolution): Intl.Date
|
|
|
41
42
|
/* `2026-09-10` for a day, `2026-09-10T14` for an hour: the day is always the first ten characters. */
|
|
42
43
|
function slotOf(format: Intl.DateTimeFormat, timestampMs: number, resolution: UsageResolution): string {
|
|
43
44
|
const parts = format.formatToParts(new Date(timestampMs));
|
|
45
|
+
|
|
44
46
|
const at = (type: Intl.DateTimeFormatPartTypes): string => parts.find((part) => part.type === type)?.value ?? '';
|
|
47
|
+
|
|
45
48
|
const day = `${at('year')}-${at('month')}-${at('day')}`;
|
|
49
|
+
|
|
46
50
|
return resolution === 'hour' ? `${day}T${at('hour')}` : day;
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -96,6 +100,7 @@ function foldBucket(
|
|
|
96
100
|
): void {
|
|
97
101
|
const key = `${slot}\0${record.provider}\0${record.model}\0${account ?? ''}`;
|
|
98
102
|
let bucket = buckets.get(key);
|
|
103
|
+
|
|
99
104
|
if (bucket === undefined) {
|
|
100
105
|
bucket = {
|
|
101
106
|
slot,
|
|
@@ -108,12 +113,15 @@ function foldBucket(
|
|
|
108
113
|
cacheSavingsUsd: 0,
|
|
109
114
|
sessions: new Set()
|
|
110
115
|
};
|
|
116
|
+
|
|
111
117
|
buckets.set(key, bucket);
|
|
112
118
|
}
|
|
119
|
+
|
|
113
120
|
bucket.totals = addTotals(bucket.totals, record.totals);
|
|
114
121
|
bucket.costUsd += cost;
|
|
115
122
|
bucket.priced ||= price !== null || record.costUsd !== undefined;
|
|
116
123
|
bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
|
|
124
|
+
|
|
117
125
|
if (record.sessionId !== '') {
|
|
118
126
|
bucket.sessions.add(record.sessionId);
|
|
119
127
|
}
|
|
@@ -122,6 +130,7 @@ function foldBucket(
|
|
|
122
130
|
function foldModel(models: Map<string, ModelRow>, record: UsageRecord, account: string | null, lookup: PriceLookup, cost: number): void {
|
|
123
131
|
const key = `${record.provider}\0${record.model}\0${account ?? ''}`;
|
|
124
132
|
let model = models.get(key);
|
|
133
|
+
|
|
125
134
|
if (model === undefined) {
|
|
126
135
|
model = {
|
|
127
136
|
provider: record.provider,
|
|
@@ -134,8 +143,10 @@ function foldModel(models: Map<string, ModelRow>, record: UsageRecord, account:
|
|
|
134
143
|
basis: record.costUsd === undefined ? lookup.basis : 'exact',
|
|
135
144
|
pricedAs: record.costUsd === undefined ? lookup.pricedAs : null
|
|
136
145
|
};
|
|
146
|
+
|
|
137
147
|
models.set(key, model);
|
|
138
148
|
}
|
|
149
|
+
|
|
139
150
|
model.totals = addTotals(model.totals, record.totals);
|
|
140
151
|
model.costUsd += cost;
|
|
141
152
|
model.priced ||= lookup.price !== null || record.costUsd !== undefined;
|
|
@@ -178,31 +189,39 @@ export async function aggregate(
|
|
|
178
189
|
|
|
179
190
|
for (const record of records) {
|
|
180
191
|
const account = only === null ? null : accountOfRecord(record);
|
|
192
|
+
|
|
181
193
|
if (only !== null && !only.has(account!)) {
|
|
182
194
|
continue;
|
|
183
195
|
}
|
|
196
|
+
|
|
184
197
|
const slot = slotOf(format, record.timestampMs, payload.resolution);
|
|
185
198
|
const day = slot.slice(0, 10);
|
|
199
|
+
|
|
186
200
|
if (day < payload.from || day > payload.to) {
|
|
187
201
|
continue;
|
|
188
202
|
}
|
|
203
|
+
|
|
189
204
|
const lookup = prices.look(record.model, record.serviceTier, record.timestampMs);
|
|
190
205
|
const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
|
|
191
206
|
foldBucket(buckets, slot, record, account, lookup.price, cost);
|
|
192
207
|
foldModel(models, record, account, lookup, cost);
|
|
208
|
+
|
|
193
209
|
if (record.sessionId !== '') {
|
|
194
210
|
sessions.add(`${record.provider}\0${record.sessionId}`);
|
|
195
211
|
}
|
|
196
212
|
|
|
197
213
|
let folder = folders.get(record.cwd);
|
|
214
|
+
|
|
198
215
|
if (folder === undefined) {
|
|
199
216
|
const resolved = await resolver.resolve(record.cwd);
|
|
200
217
|
folder = resolved.folder;
|
|
201
218
|
folders.set(record.cwd, folder);
|
|
219
|
+
|
|
202
220
|
if (!projects.has(folder)) {
|
|
203
221
|
projects.set(folder, { folder, name: resolved.name, projectId: resolved.projectId, byProvider: new Map(), totals: emptyTotals(), costUsd: 0 });
|
|
204
222
|
}
|
|
205
223
|
}
|
|
224
|
+
|
|
206
225
|
foldProject(projects.get(folder)!, record, cost);
|
|
207
226
|
}
|
|
208
227
|
|
package/src/usage/exchange.ts
CHANGED
|
@@ -17,9 +17,11 @@ export function parseRate(document: unknown, currency: string): Omit<UsageRate,
|
|
|
17
17
|
const body = typeof document === 'object' && document !== null ? (document as Record<string, unknown>) : null;
|
|
18
18
|
const rates = body === null || typeof body.rates !== 'object' || body.rates === null ? null : (body.rates as Record<string, unknown>);
|
|
19
19
|
const rate = rates === null ? undefined : rates[currency];
|
|
20
|
+
|
|
20
21
|
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0 || typeof body?.date !== 'string') {
|
|
21
22
|
return null;
|
|
22
23
|
}
|
|
24
|
+
|
|
23
25
|
return { currency, rate, date: body.date };
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -47,6 +49,7 @@ export class ExchangeRates {
|
|
|
47
49
|
/* Reads the rate an earlier run stored, once; asking for a new one is left to `ensure`. */
|
|
48
50
|
load(): Promise<void> {
|
|
49
51
|
this.stored ??= this.readStored();
|
|
52
|
+
|
|
50
53
|
return this.stored;
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -57,6 +60,7 @@ export class ExchangeRates {
|
|
|
57
60
|
.finally(() => {
|
|
58
61
|
this.inFlight = null;
|
|
59
62
|
});
|
|
63
|
+
|
|
60
64
|
return this.inFlight;
|
|
61
65
|
}
|
|
62
66
|
|
|
@@ -67,6 +71,7 @@ export class ExchangeRates {
|
|
|
67
71
|
private async readStored(): Promise<void> {
|
|
68
72
|
try {
|
|
69
73
|
const stored = JSON.parse(await readFile(this.file, 'utf8')) as UsageRate;
|
|
74
|
+
|
|
70
75
|
if (stored?.currency === TARGET_CURRENCY && typeof stored.rate === 'number' && stored.rate > 0) {
|
|
71
76
|
this.rate = stored;
|
|
72
77
|
}
|
|
@@ -81,16 +86,22 @@ export class ExchangeRates {
|
|
|
81
86
|
if (!this.allowFetch || (this.rate !== null && Date.now() - this.rate.fetchedAt < TTL_MS)) {
|
|
82
87
|
return false;
|
|
83
88
|
}
|
|
89
|
+
|
|
84
90
|
let changed = false;
|
|
91
|
+
|
|
85
92
|
try {
|
|
86
93
|
const response = await fetch(`${FRANKFURTER_URL}?from=USD&to=${TARGET_CURRENCY}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
94
|
+
|
|
87
95
|
if (!response.ok) {
|
|
88
96
|
throw new Error(`frankfurter.app answered ${response.status}`);
|
|
89
97
|
}
|
|
98
|
+
|
|
90
99
|
const parsed = parseRate(await response.json(), TARGET_CURRENCY);
|
|
100
|
+
|
|
91
101
|
if (parsed === null) {
|
|
92
102
|
throw new Error(`The answer carried no ${TARGET_CURRENCY} rate`);
|
|
93
103
|
}
|
|
104
|
+
|
|
94
105
|
this.rate = { ...parsed, fetchedAt: Date.now() };
|
|
95
106
|
changed = true;
|
|
96
107
|
await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
|
|
@@ -99,6 +110,7 @@ export class ExchangeRates {
|
|
|
99
110
|
// Yesterday's rate still converts; a rate that could not be refreshed is not a failure.
|
|
100
111
|
console.warn('Could not refresh the exchange rate:', errorText(e));
|
|
101
112
|
}
|
|
113
|
+
|
|
102
114
|
return changed;
|
|
103
115
|
}
|
|
104
116
|
}
|
package/src/usage/index-file.ts
CHANGED
|
@@ -58,12 +58,15 @@ class Interner {
|
|
|
58
58
|
|
|
59
59
|
index(value: string): number {
|
|
60
60
|
const seen = this.at.get(value);
|
|
61
|
+
|
|
61
62
|
if (seen !== undefined) {
|
|
62
63
|
return seen;
|
|
63
64
|
}
|
|
65
|
+
|
|
64
66
|
const next = this.values.length;
|
|
65
67
|
this.values.push(value);
|
|
66
68
|
this.at.set(value, next);
|
|
69
|
+
|
|
67
70
|
return next;
|
|
68
71
|
}
|
|
69
72
|
}
|
|
@@ -74,6 +77,7 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
74
77
|
const folders = new Interner();
|
|
75
78
|
const accounts = new Interner();
|
|
76
79
|
const files: Record<string, FileRow> = {};
|
|
80
|
+
|
|
77
81
|
const row = (record: UsageRecord): RecordRow => {
|
|
78
82
|
const counted: RecordRow = [
|
|
79
83
|
record.timestampMs,
|
|
@@ -90,14 +94,18 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
90
94
|
record.dedupeKey ?? 0,
|
|
91
95
|
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
92
96
|
];
|
|
97
|
+
|
|
93
98
|
if (record.costUsd !== undefined || record.serviceTier !== undefined) {
|
|
94
99
|
counted[13] = record.costUsd;
|
|
95
100
|
}
|
|
101
|
+
|
|
96
102
|
if (record.serviceTier !== undefined) {
|
|
97
103
|
counted[14] = record.serviceTier;
|
|
98
104
|
}
|
|
105
|
+
|
|
99
106
|
return counted;
|
|
100
107
|
};
|
|
108
|
+
|
|
101
109
|
for (const [path, file] of index) {
|
|
102
110
|
files[path] = {
|
|
103
111
|
p: file.provider,
|
|
@@ -112,6 +120,7 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
112
120
|
...(file.compressed === undefined ? {} : { z: file.compressed })
|
|
113
121
|
};
|
|
114
122
|
}
|
|
123
|
+
|
|
115
124
|
const document: IndexFile = {
|
|
116
125
|
version: INDEX_VERSION,
|
|
117
126
|
models: models.values,
|
|
@@ -120,6 +129,7 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
120
129
|
accounts: accounts.values,
|
|
121
130
|
files
|
|
122
131
|
};
|
|
132
|
+
|
|
123
133
|
return JSON.stringify(document);
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -142,12 +152,15 @@ function isRow(row: unknown, version: number): row is RecordRow {
|
|
|
142
152
|
export function decodeIndex(text: string): UsageIndex {
|
|
143
153
|
const index: UsageIndex = new Map();
|
|
144
154
|
let document: IndexFile;
|
|
155
|
+
|
|
145
156
|
try {
|
|
146
157
|
document = JSON.parse(text) as IndexFile;
|
|
147
158
|
} catch {
|
|
148
159
|
return index;
|
|
149
160
|
}
|
|
161
|
+
|
|
150
162
|
const version = document?.version;
|
|
163
|
+
|
|
151
164
|
if (
|
|
152
165
|
(version !== INDEX_VERSION && version !== 2 && version !== ACCOUNTLESS_VERSION) ||
|
|
153
166
|
!Array.isArray(document.models) ||
|
|
@@ -156,19 +169,26 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
156
169
|
) {
|
|
157
170
|
return index;
|
|
158
171
|
}
|
|
172
|
+
|
|
159
173
|
const accounts = Array.isArray(document.accounts) ? document.accounts : [];
|
|
174
|
+
|
|
160
175
|
const name = (table: string[], at: number): string | null => (typeof table[at] === 'string' ? table[at] : null);
|
|
176
|
+
|
|
161
177
|
for (const [path, file] of Object.entries(document.files ?? {})) {
|
|
162
178
|
// Every provider the schema knows, so a new one is never dropped from the index it was saved in.
|
|
163
179
|
if (!UsageProviderSchema.safeParse(file.p).success) {
|
|
164
180
|
continue;
|
|
165
181
|
}
|
|
182
|
+
|
|
166
183
|
if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0 || !Array.isArray(file.r)) {
|
|
167
184
|
continue;
|
|
168
185
|
}
|
|
186
|
+
|
|
169
187
|
let broken = false;
|
|
188
|
+
|
|
170
189
|
const decode = (rows: RecordRow[]): UsageRecord[] => {
|
|
171
190
|
const records: UsageRecord[] = [];
|
|
191
|
+
|
|
172
192
|
for (const row of rows) {
|
|
173
193
|
const valid = isRow(row, version);
|
|
174
194
|
const model = valid ? name(document.models, row[1]) : null;
|
|
@@ -176,10 +196,13 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
176
196
|
const cwd = valid ? name(document.folders, row[3]) : null;
|
|
177
197
|
const accountAt = valid ? (row[12] ?? 0) : 0;
|
|
178
198
|
const account = accountAt === 0 ? undefined : name(accounts, accountAt - 1);
|
|
199
|
+
|
|
179
200
|
if (!valid || model === null || sessionId === null || cwd === null || account === null) {
|
|
180
201
|
broken = true;
|
|
202
|
+
|
|
181
203
|
return records;
|
|
182
204
|
}
|
|
205
|
+
|
|
183
206
|
records.push({
|
|
184
207
|
provider: file.p,
|
|
185
208
|
timestampMs: row[0],
|
|
@@ -193,10 +216,13 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
193
216
|
dedupeKey: row[11] === 0 ? null : row[11]
|
|
194
217
|
});
|
|
195
218
|
}
|
|
219
|
+
|
|
196
220
|
return records;
|
|
197
221
|
};
|
|
222
|
+
|
|
198
223
|
const records = decode(file.r);
|
|
199
224
|
const tail = decode(Array.isArray(file.t) ? file.t : []);
|
|
225
|
+
|
|
200
226
|
if (!broken) {
|
|
201
227
|
index.set(path, {
|
|
202
228
|
provider: file.p,
|
|
@@ -212,5 +238,6 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
212
238
|
});
|
|
213
239
|
}
|
|
214
240
|
}
|
|
241
|
+
|
|
215
242
|
return index;
|
|
216
243
|
}
|
|
@@ -62,7 +62,6 @@ export interface UsageMonitorOptions {
|
|
|
62
62
|
providers: ProviderRegistry;
|
|
63
63
|
/* The accounts to read; absent reads the default account of every CLI and names none. */
|
|
64
64
|
accounts?: LimitAccounts;
|
|
65
|
-
/* How to ask; a test answers without starting a CLI. */
|
|
66
65
|
probe?: (kind: UsageProvider, command: readonly string[], env: ProbeEnv) => Promise<ProbeResult>;
|
|
67
66
|
/*
|
|
68
67
|
* A plan the host reads itself, for a provider whose CLI cannot be asked (a subscription API the host calls,
|
|
@@ -100,6 +99,7 @@ export class UsageMonitor {
|
|
|
100
99
|
this.plans = options.plans ?? {};
|
|
101
100
|
this.probe = options.probe ?? ((kind, command, env) => (kind === 'claude' ? probeClaude(command, env) : probeCodex(command, env, options.client)));
|
|
102
101
|
this.now = options.now ?? Date.now;
|
|
102
|
+
|
|
103
103
|
for (const kind of PROVIDERS) {
|
|
104
104
|
this.stateOf(kind, kind);
|
|
105
105
|
}
|
|
@@ -113,8 +113,10 @@ export class UsageMonitor {
|
|
|
113
113
|
snapshot(): UsageLimitsSnapshot {
|
|
114
114
|
const providers = this.listed().map(({ id, kind, label, color }) => {
|
|
115
115
|
const published = this.stateOf(kind, id).published;
|
|
116
|
+
|
|
116
117
|
return this.accounts === null ? published : { ...published, account: { id, label, ...(color === undefined ? {} : { color }) } };
|
|
117
118
|
});
|
|
119
|
+
|
|
118
120
|
return { providers };
|
|
119
121
|
}
|
|
120
122
|
|
|
@@ -136,6 +138,7 @@ export class UsageMonitor {
|
|
|
136
138
|
this.inFlight ??= this.runAll(force).finally(() => {
|
|
137
139
|
this.inFlight = null;
|
|
138
140
|
});
|
|
141
|
+
|
|
139
142
|
return this.inFlight;
|
|
140
143
|
}
|
|
141
144
|
|
|
@@ -153,7 +156,9 @@ export class UsageMonitor {
|
|
|
153
156
|
if (update.windows.length === 0) {
|
|
154
157
|
return;
|
|
155
158
|
}
|
|
159
|
+
|
|
156
160
|
const state = this.stateOf(update.kind, update.account ?? update.kind);
|
|
161
|
+
|
|
157
162
|
state.published = {
|
|
158
163
|
...state.published,
|
|
159
164
|
plan: update.plan ?? state.published.plan,
|
|
@@ -162,27 +167,33 @@ export class UsageMonitor {
|
|
|
162
167
|
windows: mergeWindows(state.published.windows, update.windows),
|
|
163
168
|
unavailable: null
|
|
164
169
|
};
|
|
170
|
+
|
|
165
171
|
this.emit();
|
|
166
172
|
}
|
|
167
173
|
|
|
168
174
|
/* Every account of a CLI whose plan is read, the default one first. */
|
|
169
175
|
private listed(): LimitAccount[] {
|
|
170
176
|
const accounts = this.accounts?.list() ?? PROVIDERS.map((kind) => ({ id: kind, kind, label: kind, isDefault: true }));
|
|
177
|
+
|
|
171
178
|
return PROVIDERS.flatMap((kind) => {
|
|
172
179
|
if (!PROBED.includes(kind) && this.plans[kind] === undefined) {
|
|
173
180
|
return [];
|
|
174
181
|
}
|
|
182
|
+
|
|
175
183
|
const own = accounts.filter((account) => account.kind === kind);
|
|
184
|
+
|
|
176
185
|
return [...own.filter((account) => account.isDefault), ...own.filter((account) => !account.isDefault)];
|
|
177
186
|
});
|
|
178
187
|
}
|
|
179
188
|
|
|
180
189
|
private stateOf(kind: UsageProvider, id: string): ProviderState {
|
|
181
190
|
let state = this.states.get(id);
|
|
191
|
+
|
|
182
192
|
if (state === undefined || state.kind !== kind) {
|
|
183
193
|
state = { kind, published: blank(kind), backoffMs: PROBE_INTERVAL_MS, nextAt: 0 };
|
|
184
194
|
this.states.set(id, state);
|
|
185
195
|
}
|
|
196
|
+
|
|
186
197
|
return state;
|
|
187
198
|
}
|
|
188
199
|
|
|
@@ -191,48 +202,62 @@ export class UsageMonitor {
|
|
|
191
202
|
if (force) {
|
|
192
203
|
return true;
|
|
193
204
|
}
|
|
205
|
+
|
|
194
206
|
if (this.now() < state.nextAt) {
|
|
195
207
|
return false;
|
|
196
208
|
}
|
|
209
|
+
|
|
197
210
|
if (account.isDefault) {
|
|
198
211
|
return true;
|
|
199
212
|
}
|
|
213
|
+
|
|
200
214
|
const used = this.accounts?.lastUsedAt(account.id) ?? null;
|
|
215
|
+
|
|
201
216
|
return used !== null && this.now() - used < RECENT_USE_MS;
|
|
202
217
|
}
|
|
203
218
|
|
|
204
219
|
private async runAll(force: boolean): Promise<void> {
|
|
205
220
|
const installed = await this.registry.list();
|
|
206
221
|
let changed = false;
|
|
222
|
+
|
|
207
223
|
for (const account of this.listed()) {
|
|
208
224
|
const { kind } = account;
|
|
209
225
|
const state = this.stateOf(kind, account.id);
|
|
226
|
+
|
|
210
227
|
if (!this.due(account, state, force)) {
|
|
211
228
|
continue;
|
|
212
229
|
}
|
|
230
|
+
|
|
213
231
|
const info = installed.find((provider) => provider.kind === kind);
|
|
232
|
+
|
|
214
233
|
if (this.plans[kind] === undefined && (info === undefined || !info.installed)) {
|
|
215
234
|
state.published = { ...blank(kind), checkedAt: this.now(), unavailable: { reason: 'not-installed', message: null } };
|
|
216
235
|
state.nextAt = this.now() + PROBE_INTERVAL_MS;
|
|
217
236
|
changed = true;
|
|
237
|
+
|
|
218
238
|
continue;
|
|
219
239
|
}
|
|
240
|
+
|
|
220
241
|
// The next read is counted from when this one started, so a read that takes a few seconds does not push the tick of the clock past its time.
|
|
221
242
|
const startedAt = this.now();
|
|
222
243
|
const result = await this.read(account);
|
|
223
244
|
const failed = 'unavailable' in result && result.unavailable?.reason === 'failed';
|
|
224
245
|
state.backoffMs = failed ? Math.min(MAX_BACKOFF_MS, state.backoffMs * 2) : PROBE_INTERVAL_MS;
|
|
225
246
|
state.nextAt = startedAt + state.backoffMs;
|
|
247
|
+
|
|
226
248
|
if (failed && state.published.windows.length > 0) {
|
|
227
249
|
// A pass that could not reach the CLI leaves the last good numbers where they are.
|
|
228
250
|
continue;
|
|
229
251
|
}
|
|
252
|
+
|
|
230
253
|
state.published =
|
|
231
254
|
'unavailable' in result
|
|
232
255
|
? { ...blank(kind), checkedAt: this.now(), unavailable: result.unavailable }
|
|
233
256
|
: { kind, plan: result.plan, checkedAt: this.now(), source: 'probe', windows: result.windows, cost: result.cost, unavailable: null };
|
|
257
|
+
|
|
234
258
|
changed = true;
|
|
235
259
|
}
|
|
260
|
+
|
|
236
261
|
if (changed) {
|
|
237
262
|
this.emit();
|
|
238
263
|
}
|
|
@@ -240,6 +265,7 @@ export class UsageMonitor {
|
|
|
240
265
|
|
|
241
266
|
private async read(account: LimitAccount): Promise<ProbeResult> {
|
|
242
267
|
const plan = this.plans[account.kind];
|
|
268
|
+
|
|
243
269
|
if (plan !== undefined) {
|
|
244
270
|
try {
|
|
245
271
|
return await plan(account);
|
|
@@ -247,17 +273,19 @@ export class UsageMonitor {
|
|
|
247
273
|
return { unavailable: { reason: 'failed', message: errorText(e) } };
|
|
248
274
|
}
|
|
249
275
|
}
|
|
276
|
+
|
|
250
277
|
let env: ProbeEnv;
|
|
278
|
+
|
|
251
279
|
try {
|
|
252
280
|
env = this.accounts === null ? definedEnv(process.env) : this.accounts.envFor(account.kind, account.id);
|
|
253
281
|
} catch (e) {
|
|
254
282
|
return { unavailable: { reason: 'failed', message: errorText(e) } };
|
|
255
283
|
}
|
|
284
|
+
|
|
256
285
|
return this.probe(account.kind, this.registry.get(account.kind).command, env);
|
|
257
286
|
}
|
|
258
287
|
|
|
259
288
|
private emit(): void {
|
|
260
|
-
|
|
261
|
-
this.sinks.emit(event);
|
|
289
|
+
this.sinks.emit({ event: 'usage.limitsChanged', payload: this.snapshot() });
|
|
262
290
|
}
|
|
263
291
|
}
|
|
@@ -36,12 +36,15 @@ export function kindOfDuration(durationMs: number | null): UsageWindow['kind'] {
|
|
|
36
36
|
if (durationMs === null) {
|
|
37
37
|
return 'other';
|
|
38
38
|
}
|
|
39
|
+
|
|
39
40
|
if (durationMs >= MONTH_MS) {
|
|
40
41
|
return 'monthly';
|
|
41
42
|
}
|
|
43
|
+
|
|
42
44
|
if (durationMs >= WEEK_MS) {
|
|
43
45
|
return 'weekly';
|
|
44
46
|
}
|
|
47
|
+
|
|
45
48
|
return 'session';
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -68,33 +71,44 @@ export function readClaudeUsage(response: unknown): ProviderReading | { unavaila
|
|
|
68
71
|
if (!isRecord(response)) {
|
|
69
72
|
return { unavailable: { reason: 'failed', message: 'Claude Code returned no readable usage' } };
|
|
70
73
|
}
|
|
74
|
+
|
|
71
75
|
const plan = typeof response.subscription_type === 'string' ? response.subscription_type : null;
|
|
72
76
|
const limits = isRecord(response.rate_limits) ? response.rate_limits : null;
|
|
77
|
+
|
|
73
78
|
if (response.rate_limits_available === false || limits === null) {
|
|
74
79
|
// An API key, Bedrock or Vertex account has no plan windows at all, which is not a failure.
|
|
75
80
|
return { unavailable: { reason: 'no-subscription', message: null } };
|
|
76
81
|
}
|
|
82
|
+
|
|
77
83
|
const windows: UsageWindow[] = [];
|
|
84
|
+
|
|
78
85
|
const push = (id: string, label: string, kind: UsageWindow['kind'], durationMs: number, entry: unknown): void => {
|
|
79
86
|
const window = isRecord(entry) ? entry : null;
|
|
80
87
|
const utilization = window === null ? null : num(window.utilization);
|
|
88
|
+
|
|
81
89
|
if (utilization === null) {
|
|
82
90
|
return;
|
|
83
91
|
}
|
|
92
|
+
|
|
84
93
|
const resetsAt = typeof window?.resets_at === 'string' ? Date.parse(window.resets_at) : Number.NaN;
|
|
85
94
|
windows.push({ id, label, kind, used: fraction(utilization / 100), resetsAt: Number.isNaN(resetsAt) ? null : resetsAt, durationMs });
|
|
86
95
|
};
|
|
96
|
+
|
|
87
97
|
for (const known of CLAUDE_WINDOWS) {
|
|
88
98
|
push(known.id, known.label, known.kind, known.durationMs, limits[known.key]);
|
|
89
99
|
}
|
|
100
|
+
|
|
90
101
|
for (const entry of Array.isArray(limits.model_scoped) ? limits.model_scoped : []) {
|
|
91
102
|
const name = isRecord(entry) && typeof entry.display_name === 'string' ? entry.display_name : null;
|
|
103
|
+
|
|
92
104
|
if (name !== null) {
|
|
93
105
|
push(`seven_day_${slug(name)}`, `Weekly · ${name}`, 'weekly', WEEK_MS, entry);
|
|
94
106
|
}
|
|
95
107
|
}
|
|
108
|
+
|
|
96
109
|
const session = isRecord(response.session) ? response.session : null;
|
|
97
110
|
const sessionUsd = session === null ? null : num(session.total_cost_usd);
|
|
111
|
+
|
|
98
112
|
return { plan, windows, cost: sessionUsd === null ? null : { sessionUsd } };
|
|
99
113
|
}
|
|
100
114
|
|
|
@@ -103,11 +117,14 @@ export function readClaudeEvent(info: unknown): LimitsUpdate | null {
|
|
|
103
117
|
const event = isRecord(info) ? info : null;
|
|
104
118
|
const type = event !== null && typeof event.rateLimitType === 'string' ? event.rateLimitType : null;
|
|
105
119
|
const utilization = event === null ? null : num(event.utilization);
|
|
120
|
+
|
|
106
121
|
if (type === null || utilization === null) {
|
|
107
122
|
return null;
|
|
108
123
|
}
|
|
124
|
+
|
|
109
125
|
const known = CLAUDE_WINDOWS.find((window) => window.key === type);
|
|
110
126
|
const resetsAt = num(event?.resetsAt);
|
|
127
|
+
|
|
111
128
|
return {
|
|
112
129
|
kind: 'claude',
|
|
113
130
|
windows: [
|
|
@@ -127,24 +144,30 @@ export function readClaudeEvent(info: unknown): LimitsUpdate | null {
|
|
|
127
144
|
*/
|
|
128
145
|
export function readCodexLimits(snapshot: unknown): ProviderReading | null {
|
|
129
146
|
const limits = isRecord(snapshot) ? snapshot : null;
|
|
147
|
+
|
|
130
148
|
if (limits === null || (typeof limits.limitId === 'string' && limits.limitId !== 'codex')) {
|
|
131
149
|
return null;
|
|
132
150
|
}
|
|
151
|
+
|
|
133
152
|
const plan = typeof limits.planType === 'string' ? limits.planType : null;
|
|
134
153
|
const windows: UsageWindow[] = [];
|
|
154
|
+
|
|
135
155
|
for (const [position, entry] of [
|
|
136
156
|
['primary', limits.primary],
|
|
137
157
|
['secondary', limits.secondary]
|
|
138
158
|
] as const) {
|
|
139
159
|
const window = isRecord(entry) ? entry : null;
|
|
140
160
|
const usedPercent = window === null ? null : num(window.usedPercent);
|
|
161
|
+
|
|
141
162
|
if (usedPercent === null) {
|
|
142
163
|
continue;
|
|
143
164
|
}
|
|
165
|
+
|
|
144
166
|
const minutes = num(window?.windowDurationMins);
|
|
145
167
|
const durationMs = minutes === null ? null : Math.round(minutes * MINUTE_MS);
|
|
146
168
|
const kind = kindOfDuration(durationMs);
|
|
147
169
|
const resetsAt = num(window?.resetsAt);
|
|
170
|
+
|
|
148
171
|
windows.push({
|
|
149
172
|
id: position,
|
|
150
173
|
kind,
|
|
@@ -154,6 +177,7 @@ export function readCodexLimits(snapshot: unknown): ProviderReading | null {
|
|
|
154
177
|
durationMs
|
|
155
178
|
});
|
|
156
179
|
}
|
|
180
|
+
|
|
157
181
|
return { plan, windows, cost: null };
|
|
158
182
|
}
|
|
159
183
|
|
|
@@ -164,11 +188,14 @@ export function readCodexLimits(snapshot: unknown): ProviderReading | null {
|
|
|
164
188
|
*/
|
|
165
189
|
export function mergeWindows(known: readonly UsageWindow[], updates: readonly Partial<UsageWindow>[]): UsageWindow[] {
|
|
166
190
|
const merged = known.map((window) => ({ ...window }));
|
|
191
|
+
|
|
167
192
|
for (const update of updates) {
|
|
168
193
|
if (update.id === undefined || update.used === undefined) {
|
|
169
194
|
continue;
|
|
170
195
|
}
|
|
196
|
+
|
|
171
197
|
const at = merged.findIndex((window) => window.id === update.id);
|
|
198
|
+
|
|
172
199
|
if (at === -1) {
|
|
173
200
|
merged.push({
|
|
174
201
|
id: update.id,
|
|
@@ -178,9 +205,12 @@ export function mergeWindows(known: readonly UsageWindow[], updates: readonly Pa
|
|
|
178
205
|
resetsAt: update.resetsAt ?? null,
|
|
179
206
|
durationMs: update.durationMs ?? null
|
|
180
207
|
});
|
|
208
|
+
|
|
181
209
|
continue;
|
|
182
210
|
}
|
|
211
|
+
|
|
183
212
|
merged[at] = { ...merged[at]!, ...update } as UsageWindow;
|
|
184
213
|
}
|
|
214
|
+
|
|
185
215
|
return merged;
|
|
186
216
|
}
|