@adecore/agents 0.30.0 → 0.31.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.
@@ -105,17 +105,22 @@ export class ProviderAccountsService {
105
105
  }
106
106
  snapshot() {
107
107
  const loginCommands = {};
108
+ const chatLogins = [];
108
109
  for (const kind of this.kinds()) {
109
- const command = this.providers.get(kind).home?.loginCommand;
110
- if (command !== undefined) {
111
- loginCommands[kind] = command;
110
+ const home = this.providers.get(kind).home;
111
+ if (home?.loginCommand !== undefined) {
112
+ loginCommands[kind] = home.loginCommand;
113
+ }
114
+ else if (home !== undefined) {
115
+ chatLogins.push(kind);
112
116
  }
113
117
  }
114
118
  return {
115
119
  accounts: this.accounts,
116
120
  statuses: Object.entries(this.accounts).map(([id, account]) => this.statusOf(id, account)),
117
121
  secretsAvailable: this.secrets !== null,
118
- loginCommands
122
+ loginCommands,
123
+ ...(chatLogins.length > 0 ? { chatLogins } : {})
119
124
  };
120
125
  }
121
126
  /* The account under an id, for a launch; null for an id this machine does not have. */
@@ -501,6 +506,10 @@ export class ProviderAccountsService {
501
506
  if (missing !== null) {
502
507
  throw new Error(`The keychain does not give the value of ${missing}`);
503
508
  }
509
+ // A CLI that signs in from its chat has no command that says who is signed in; its chat asks when it must.
510
+ if (provider.home.loginCommand === undefined) {
511
+ return this.status(id, account, 'ready', { message });
512
+ }
504
513
  const reading = await this.ask(kind, provider.command, accountEnv(id, account, provider, this.env, values));
505
514
  return this.status(id, account, reading.signedIn ? 'ready' : 'signed-out', {
506
515
  email: reading.email,
@@ -6,7 +6,7 @@ export interface ProviderHome {
6
6
  readonly env: string;
7
7
  readonly fallback: string;
8
8
  readonly loginEnv: readonly string[];
9
- readonly loginCommand: string;
9
+ readonly loginCommand?: string;
10
10
  }
11
11
  export interface ChatProvider {
12
12
  readonly kind: AgentKind;
@@ -1,4 +1,4 @@
1
- import type { UsageProvider } from '@adecore/agent-contracts';
1
+ import { type UsageProvider } from '@adecore/agent-contracts';
2
2
  import type { UsageRecord } from './record.ts';
3
3
  import type { CodexParserState } from './readers/codex.ts';
4
4
  export interface IndexedFile {
@@ -1,3 +1,4 @@
1
+ import { UsageProviderSchema } from '@adecore/agent-contracts';
1
2
  /* Another version is thrown away rather than migrated, since rebuilding costs one cold scan; version 1 is read as it is. */
2
3
  const INDEX_VERSION = 2;
3
4
  /* Before accounts: every record was the default account's, so it reads as that without a scan. */
@@ -89,7 +90,8 @@ export function decodeIndex(text) {
89
90
  const accounts = Array.isArray(document.accounts) ? document.accounts : [];
90
91
  const name = (table, at) => (typeof table[at] === 'string' ? table[at] : null);
91
92
  for (const [path, file] of Object.entries(document.files ?? {})) {
92
- if (file.p !== 'claude' && file.p !== 'codex') {
93
+ // Every provider the schema knows, so a new one is never dropped from the index it was saved in.
94
+ if (!UsageProviderSchema.safeParse(file.p).success) {
93
95
  continue;
94
96
  }
95
97
  if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0 || !Array.isArray(file.r)) {
@@ -1,8 +1,9 @@
1
1
  export { usageAccountsOf, usageRootsOf, limitAccountsOf, chatSessionAccounts } from './accounts.ts';
2
2
  export { UsageMonitor, type ProbeEnv, type LimitAccount, type LimitAccounts, type UsageMonitorOptions } from './limits/monitor.ts';
3
- export type { LimitsUpdate, ProviderReading } from './limits/normalize.ts';
3
+ export { fraction, type LimitsUpdate, type ProviderReading } from './limits/normalize.ts';
4
4
  export type { ProbeResult } from './limits/probe.ts';
5
5
  export { LITELLM_URL } from './pricing.ts';
6
+ export { hostUsageLine, type HostUsageEntry } from './readers/host-log.ts';
6
7
  export type { KnownProject } from './projects.ts';
7
8
  export { type UsageRootPath, usageRoots } from './roots.ts';
8
9
  export type { UsageScannerOptions } from './scanner.ts';
@@ -1,6 +1,8 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
2
  export { usageAccountsOf, usageRootsOf, limitAccountsOf, chatSessionAccounts } from './accounts.js';
3
3
  export { UsageMonitor } from './limits/monitor.js';
4
+ export { fraction } from './limits/normalize.js';
4
5
  export { LITELLM_URL } from './pricing.js';
6
+ export { hostUsageLine } from './readers/host-log.js';
5
7
  export { usageRoots } from './roots.js';
6
8
  export { UsageService } from './usage-service.js';
@@ -1,4 +1,4 @@
1
- import type { UsageLimitsSnapshot, UsageProvider } from '@adecore/agent-contracts';
1
+ import { type UsageLimitsSnapshot, type UsageProvider } from '@adecore/agent-contracts';
2
2
  import type { CodexClientInfo } from '../../chat/codex-transport.ts';
3
3
  import type { AgentSink } from '../../events.ts';
4
4
  import type { ProviderRegistry } from '../../providers/registry.ts';
@@ -21,6 +21,7 @@ export interface UsageMonitorOptions {
21
21
  providers: ProviderRegistry;
22
22
  accounts?: LimitAccounts;
23
23
  probe?: (kind: UsageProvider, command: readonly string[], env: ProbeEnv) => Promise<ProbeResult>;
24
+ plans?: Partial<Record<UsageProvider, () => Promise<ProbeResult>>>;
24
25
  now?: () => number;
25
26
  client?: CodexClientInfo;
26
27
  }
@@ -28,6 +29,7 @@ export declare class UsageMonitor {
28
29
  private readonly registry;
29
30
  private readonly accounts;
30
31
  private readonly probe;
32
+ private readonly plans;
31
33
  private readonly now;
32
34
  private readonly sinks;
33
35
  private readonly states;
@@ -1,3 +1,4 @@
1
+ import { USAGE_PROVIDERS } from '@adecore/agent-contracts';
1
2
  import { ClientSinks } from '../../client-sinks.js';
2
3
  import { errorText } from '../../error-text.js';
3
4
  import { definedEnv } from '../../providers/accounts/launch.js';
@@ -8,7 +9,7 @@ const PROBE_INTERVAL_MS = 5 * 60_000;
8
9
  const MAX_BACKOFF_MS = 60 * 60_000;
9
10
  /* An account other than the default one that nobody started a CLI under for this long is only read when a person asks. */
10
11
  const RECENT_USE_MS = 24 * 60 * 60_000;
11
- const PROVIDERS = ['claude', 'codex'];
12
+ const PROVIDERS = USAGE_PROVIDERS;
12
13
  function blank(kind) {
13
14
  return {
14
15
  kind,
@@ -24,12 +25,14 @@ function blank(kind) {
24
25
  * What is left of each plan, per account. Two sources feed it: a read of our own every five minutes,
25
26
  * which starts the CLI, asks and ends it, and the events a running turn streams in between, which
26
27
  * cost nothing and arrive the moment a number moves. Neither reads a credential: both CLIs already
27
- * hold their own login and answer the question themselves.
28
+ * hold their own login and answer the question themselves. A provider without a CLI is read by the
29
+ * host that holds its key (`plans`), on the same clock.
28
30
  */
29
31
  export class UsageMonitor {
30
32
  registry;
31
33
  accounts;
32
34
  probe;
35
+ plans;
33
36
  now;
34
37
  sinks = new ClientSinks();
35
38
  // By account id, so the default account of a CLI is under the CLI's kind.
@@ -39,6 +42,7 @@ export class UsageMonitor {
39
42
  constructor(options) {
40
43
  this.registry = options.providers;
41
44
  this.accounts = options.accounts ?? null;
45
+ this.plans = options.plans ?? {};
42
46
  this.probe = options.probe ?? ((kind, command, env) => (kind === 'claude' ? probeClaude(command, env) : probeCodex(command, env, options.client)));
43
47
  this.now = options.now ?? Date.now;
44
48
  for (const kind of PROVIDERS) {
@@ -138,16 +142,18 @@ export class UsageMonitor {
138
142
  continue;
139
143
  }
140
144
  const info = installed.find((provider) => provider.kind === kind);
141
- if (info === undefined || !info.installed) {
145
+ if (this.plans[kind] === undefined && (info === undefined || !info.installed)) {
142
146
  state.published = { ...blank(kind), checkedAt: this.now(), unavailable: { reason: 'not-installed', message: null } };
143
147
  state.nextAt = this.now() + PROBE_INTERVAL_MS;
144
148
  changed = true;
145
149
  continue;
146
150
  }
151
+ // 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.
152
+ const startedAt = this.now();
147
153
  const result = await this.read(account);
148
154
  const failed = 'unavailable' in result && result.unavailable?.reason === 'failed';
149
155
  state.backoffMs = failed ? Math.min(MAX_BACKOFF_MS, state.backoffMs * 2) : PROBE_INTERVAL_MS;
150
- state.nextAt = this.now() + state.backoffMs;
156
+ state.nextAt = startedAt + state.backoffMs;
151
157
  if (failed && state.published.windows.length > 0) {
152
158
  // A pass that could not reach the CLI leaves the last good numbers where they are.
153
159
  continue;
@@ -163,6 +169,15 @@ export class UsageMonitor {
163
169
  }
164
170
  }
165
171
  async read(account) {
172
+ const plan = this.plans[account.kind];
173
+ if (plan !== undefined) {
174
+ try {
175
+ return await plan();
176
+ }
177
+ catch (e) {
178
+ return { unavailable: { reason: 'failed', message: errorText(e) } };
179
+ }
180
+ }
166
181
  let env;
167
182
  try {
168
183
  env = this.accounts === null ? definedEnv(process.env) : this.accounts.envFor(account.kind, account.id);
@@ -12,6 +12,7 @@ export interface LimitsUpdate {
12
12
  plan?: string | null;
13
13
  windows: Partial<UsageWindow>[];
14
14
  }
15
+ export declare function fraction(value: number): number;
15
16
  export declare function kindOfDuration(durationMs: number | null): UsageWindow['kind'];
16
17
  export declare function readClaudeUsage(response: unknown): ProviderReading | {
17
18
  unavailable: UsageLimitsProvider['unavailable'];
@@ -6,7 +6,8 @@ const MONTH_MS = 30 * 24 * 60 * MINUTE_MS;
6
6
  function num(value) {
7
7
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
8
8
  }
9
- function fraction(value) {
9
+ /* A share of a plan used, held to the bar: a provider may report more than all of it. */
10
+ export function fraction(value) {
10
11
  return Math.min(1, Math.max(0, value));
11
12
  }
12
13
  /* A window is named after how long it lasts, never after the position it came in. */
@@ -0,0 +1,16 @@
1
+ import type { UsageProvider } from '@adecore/agent-contracts';
2
+ import { type UsageRecord } from '../record.ts';
3
+ export interface HostUsageEntry {
4
+ at: number;
5
+ model: string;
6
+ session: string;
7
+ cwd: string;
8
+ input: number;
9
+ cacheRead: number;
10
+ cacheWrite: number;
11
+ output: number;
12
+ reasoning: number;
13
+ }
14
+ export declare function hostUsageLine(entry: HostUsageEntry): string;
15
+ export declare function hostLogMightCarryUsage(line: string): boolean;
16
+ export declare function parseHostUsageLine(line: string, provider: UsageProvider): UsageRecord | null;
@@ -0,0 +1,49 @@
1
+ import { asObject, asString, int } from '../record.js';
2
+ export function hostUsageLine(entry) {
3
+ return `${JSON.stringify(entry)}\n`;
4
+ }
5
+ /* A line of this log names its model, which keeps `JSON.parse` off anything else a folder holds. */
6
+ export function hostLogMightCarryUsage(line) {
7
+ return line.includes('"model"');
8
+ }
9
+ /* The provider is the root's, so one reader serves every API a host logs. */
10
+ export function parseHostUsageLine(line, provider) {
11
+ let parsed;
12
+ try {
13
+ parsed = JSON.parse(line);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ const entry = asObject(parsed);
19
+ if (entry === null) {
20
+ return null;
21
+ }
22
+ const model = asString(entry.model);
23
+ const timestampMs = typeof entry.at === 'number' && Number.isFinite(entry.at) ? entry.at : Number.NaN;
24
+ if (model === '' || Number.isNaN(timestampMs)) {
25
+ return null;
26
+ }
27
+ const totals = {
28
+ calls: 1,
29
+ input: int(entry.input),
30
+ cacheRead: int(entry.cacheRead),
31
+ cacheWrite: int(entry.cacheWrite),
32
+ cacheWrite1h: 0,
33
+ output: int(entry.output),
34
+ reasoning: int(entry.reasoning)
35
+ };
36
+ if (totals.input + totals.cacheRead + totals.cacheWrite + totals.output === 0) {
37
+ return null;
38
+ }
39
+ return {
40
+ provider,
41
+ timestampMs,
42
+ model,
43
+ sessionId: asString(entry.session),
44
+ cwd: asString(entry.cwd),
45
+ totals,
46
+ // Every line is a call of its own, so nothing is folded.
47
+ dedupeKey: null
48
+ };
49
+ }
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { homeFolder } from '../home-folder.js';
3
- /* Where in its config folder each CLI leaves its transcripts. */
3
+ /* Where in its config folder each CLI leaves its transcripts. A provider with no CLI has a log its host passes as a root of its own. */
4
4
  const TRANSCRIPTS = { claude: 'projects', codex: 'sessions' };
5
5
  /* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
6
6
  export function usageRoots(env = process.env) {
@@ -14,7 +14,11 @@ export function usageRoots(env = process.env) {
14
14
  export function accountRoots(folders) {
15
15
  const roots = new Map();
16
16
  for (const { id, kind, folder } of folders) {
17
- const path = join(folder, TRANSCRIPTS[kind]);
17
+ const transcripts = TRANSCRIPTS[kind];
18
+ if (transcripts === undefined) {
19
+ continue;
20
+ }
21
+ const path = join(folder, transcripts);
18
22
  const key = `${kind}\0${path}`;
19
23
  const root = roots.get(key);
20
24
  if (root === undefined) {
@@ -6,6 +6,7 @@ import { isNotFound, writeAtomic } from '../fs.js';
6
6
  import { decodeIndex, encodeIndex } from './index-file.js';
7
7
  import { foldByKey } from './record.js';
8
8
  import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.js';
9
+ import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.js';
9
10
  import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
10
11
  import { usageRoots } from './roots.js';
11
12
  const HASH_CHUNK_BYTES = 65536;
@@ -93,8 +94,18 @@ function parseChunk(text, provider, from, state) {
93
94
  }
94
95
  continue;
95
96
  }
96
- if (codex !== null && codexMightCarryUsage(line)) {
97
- const record = parseCodexLine(line, codex);
97
+ if (provider === 'codex') {
98
+ if (codex !== null && codexMightCarryUsage(line)) {
99
+ const record = parseCodexLine(line, codex);
100
+ if (record !== null) {
101
+ records.push(record);
102
+ }
103
+ }
104
+ continue;
105
+ }
106
+ // Claude Code and Codex write transcripts of their own; every other provider is a log its host keeps.
107
+ if (hostLogMightCarryUsage(line)) {
108
+ const record = parseHostUsageLine(line, provider);
98
109
  if (record !== null) {
99
110
  records.push(record);
100
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,8 +81,8 @@
81
81
  "test": "bun --conditions=source test"
82
82
  },
83
83
  "dependencies": {
84
- "@adecore/agent-contracts": "0.30.0",
85
- "@adecore/intelligent-ui": "0.30.0",
84
+ "@adecore/agent-contracts": "0.31.0",
85
+ "@adecore/intelligent-ui": "0.31.0",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -175,17 +175,21 @@ export class ProviderAccountsService implements AccountLaunches {
175
175
 
176
176
  snapshot(): ProviderAccounts {
177
177
  const loginCommands: Partial<Record<AgentKind, string>> = {};
178
+ const chatLogins: AgentKind[] = [];
178
179
  for (const kind of this.kinds()) {
179
- const command = this.providers.get(kind).home?.loginCommand;
180
- if (command !== undefined) {
181
- loginCommands[kind] = command;
180
+ const home = this.providers.get(kind).home;
181
+ if (home?.loginCommand !== undefined) {
182
+ loginCommands[kind] = home.loginCommand;
183
+ } else if (home !== undefined) {
184
+ chatLogins.push(kind);
182
185
  }
183
186
  }
184
187
  return {
185
188
  accounts: this.accounts,
186
189
  statuses: Object.entries(this.accounts).map(([id, account]) => this.statusOf(id, account)),
187
190
  secretsAvailable: this.secrets !== null,
188
- loginCommands
191
+ loginCommands,
192
+ ...(chatLogins.length > 0 ? { chatLogins } : {})
189
193
  };
190
194
  }
191
195
 
@@ -611,6 +615,10 @@ export class ProviderAccountsService implements AccountLaunches {
611
615
  if (missing !== null) {
612
616
  throw new Error(`The keychain does not give the value of ${missing}`);
613
617
  }
618
+ // A CLI that signs in from its chat has no command that says who is signed in; its chat asks when it must.
619
+ if (provider.home.loginCommand === undefined) {
620
+ return this.status(id, account, 'ready', { message });
621
+ }
614
622
  const reading = await this.ask(kind, provider.command, accountEnv(id, account, provider, this.env, values));
615
623
  return this.status(id, account, reading.signedIn ? 'ready' : 'signed-out', {
616
624
  email: reading.email,
@@ -11,7 +11,8 @@ export interface ProviderHome {
11
11
  // Variables that sign the CLI in on their own, over whatever login the folder holds.
12
12
  readonly loginEnv: readonly string[];
13
13
  // What a person types in a terminal to sign in; the account's folder comes from the terminal's environment.
14
- readonly loginCommand: string;
14
+ // Absent on a CLI that signs in from its first chat, which nothing outside that chat can ask about.
15
+ readonly loginCommand?: string;
15
16
  }
16
17
 
17
18
  /*
@@ -1,4 +1,4 @@
1
- import type { UsageProvider } from '@adecore/agent-contracts';
1
+ import { UsageProviderSchema, type UsageProvider } from '@adecore/agent-contracts';
2
2
  import type { UsageRecord } from './record.ts';
3
3
  import type { CodexParserState } from './readers/codex.ts';
4
4
 
@@ -147,7 +147,8 @@ export function decodeIndex(text: string): UsageIndex {
147
147
  const accounts = Array.isArray(document.accounts) ? document.accounts : [];
148
148
  const name = (table: string[], at: number): string | null => (typeof table[at] === 'string' ? table[at] : null);
149
149
  for (const [path, file] of Object.entries(document.files ?? {})) {
150
- if (file.p !== 'claude' && file.p !== 'codex') {
150
+ // Every provider the schema knows, so a new one is never dropped from the index it was saved in.
151
+ if (!UsageProviderSchema.safeParse(file.p).success) {
151
152
  continue;
152
153
  }
153
154
  if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0 || !Array.isArray(file.r)) {
@@ -1,9 +1,10 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
2
  export { usageAccountsOf, usageRootsOf, limitAccountsOf, chatSessionAccounts } from './accounts.ts';
3
3
  export { UsageMonitor, type ProbeEnv, type LimitAccount, type LimitAccounts, type UsageMonitorOptions } from './limits/monitor.ts';
4
- export type { LimitsUpdate, ProviderReading } from './limits/normalize.ts';
4
+ export { fraction, type LimitsUpdate, type ProviderReading } from './limits/normalize.ts';
5
5
  export type { ProbeResult } from './limits/probe.ts';
6
6
  export { LITELLM_URL } from './pricing.ts';
7
+ export { hostUsageLine, type HostUsageEntry } from './readers/host-log.ts';
7
8
  export type { KnownProject } from './projects.ts';
8
9
  export { type UsageRootPath, usageRoots } from './roots.ts';
9
10
  export type { UsageScannerOptions } from './scanner.ts';
@@ -1,4 +1,4 @@
1
- import type { UsageLimitsProvider, UsageLimitsSnapshot, UsageProvider } from '@adecore/agent-contracts';
1
+ import { USAGE_PROVIDERS, type UsageLimitsProvider, type UsageLimitsSnapshot, type UsageProvider } from '@adecore/agent-contracts';
2
2
  import type { CodexClientInfo } from '../../chat/codex-transport.ts';
3
3
  import { ClientSinks } from '../../client-sinks.ts';
4
4
  import { errorText } from '../../error-text.ts';
@@ -14,7 +14,7 @@ const MAX_BACKOFF_MS = 60 * 60_000;
14
14
  /* An account other than the default one that nobody started a CLI under for this long is only read when a person asks. */
15
15
  const RECENT_USE_MS = 24 * 60 * 60_000;
16
16
 
17
- const PROVIDERS: readonly UsageProvider[] = ['claude', 'codex'];
17
+ const PROVIDERS: readonly UsageProvider[] = USAGE_PROVIDERS;
18
18
 
19
19
  export type ProbeEnv = Record<string, string>;
20
20
 
@@ -62,6 +62,12 @@ export interface UsageMonitorOptions {
62
62
  accounts?: LimitAccounts;
63
63
  /* How to ask; a test answers without starting a CLI. */
64
64
  probe?: (kind: UsageProvider, command: readonly string[], env: ProbeEnv) => Promise<ProbeResult>;
65
+ /*
66
+ * A plan the host reads itself, for a provider with no CLI to ask (a subscription API the host calls with
67
+ * its own key). It answers in place of the probe, with no environment and no check that a CLI is installed:
68
+ * what it says is the whole story, a missing key included.
69
+ */
70
+ plans?: Partial<Record<UsageProvider, () => Promise<ProbeResult>>>;
65
71
  now?: () => number;
66
72
  // How the host names itself to the app-server a probe opens.
67
73
  client?: CodexClientInfo;
@@ -71,12 +77,14 @@ export interface UsageMonitorOptions {
71
77
  * What is left of each plan, per account. Two sources feed it: a read of our own every five minutes,
72
78
  * which starts the CLI, asks and ends it, and the events a running turn streams in between, which
73
79
  * cost nothing and arrive the moment a number moves. Neither reads a credential: both CLIs already
74
- * hold their own login and answer the question themselves.
80
+ * hold their own login and answer the question themselves. A provider without a CLI is read by the
81
+ * host that holds its key (`plans`), on the same clock.
75
82
  */
76
83
  export class UsageMonitor {
77
84
  private readonly registry: ProviderRegistry;
78
85
  private readonly accounts: LimitAccounts | null;
79
86
  private readonly probe: NonNullable<UsageMonitorOptions['probe']>;
87
+ private readonly plans: NonNullable<UsageMonitorOptions['plans']>;
80
88
  private readonly now: () => number;
81
89
  private readonly sinks = new ClientSinks<AgentEvent>();
82
90
  // By account id, so the default account of a CLI is under the CLI's kind.
@@ -87,6 +95,7 @@ export class UsageMonitor {
87
95
  constructor(options: UsageMonitorOptions) {
88
96
  this.registry = options.providers;
89
97
  this.accounts = options.accounts ?? null;
98
+ this.plans = options.plans ?? {};
90
99
  this.probe = options.probe ?? ((kind, command, env) => (kind === 'claude' ? probeClaude(command, env) : probeCodex(command, env, options.client)));
91
100
  this.now = options.now ?? Date.now;
92
101
  for (const kind of PROVIDERS) {
@@ -197,16 +206,18 @@ export class UsageMonitor {
197
206
  continue;
198
207
  }
199
208
  const info = installed.find((provider) => provider.kind === kind);
200
- if (info === undefined || !info.installed) {
209
+ if (this.plans[kind] === undefined && (info === undefined || !info.installed)) {
201
210
  state.published = { ...blank(kind), checkedAt: this.now(), unavailable: { reason: 'not-installed', message: null } };
202
211
  state.nextAt = this.now() + PROBE_INTERVAL_MS;
203
212
  changed = true;
204
213
  continue;
205
214
  }
215
+ // 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.
216
+ const startedAt = this.now();
206
217
  const result = await this.read(account);
207
218
  const failed = 'unavailable' in result && result.unavailable?.reason === 'failed';
208
219
  state.backoffMs = failed ? Math.min(MAX_BACKOFF_MS, state.backoffMs * 2) : PROBE_INTERVAL_MS;
209
- state.nextAt = this.now() + state.backoffMs;
220
+ state.nextAt = startedAt + state.backoffMs;
210
221
  if (failed && state.published.windows.length > 0) {
211
222
  // A pass that could not reach the CLI leaves the last good numbers where they are.
212
223
  continue;
@@ -223,6 +234,14 @@ export class UsageMonitor {
223
234
  }
224
235
 
225
236
  private async read(account: LimitAccount): Promise<ProbeResult> {
237
+ const plan = this.plans[account.kind];
238
+ if (plan !== undefined) {
239
+ try {
240
+ return await plan();
241
+ } catch (e) {
242
+ return { unavailable: { reason: 'failed', message: errorText(e) } };
243
+ }
244
+ }
226
245
  let env: ProbeEnv;
227
246
  try {
228
247
  env = this.accounts === null ? definedEnv(process.env) : this.accounts.envFor(account.kind, account.id);
@@ -26,7 +26,8 @@ function num(value: unknown): number | null {
26
26
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
27
27
  }
28
28
 
29
- function fraction(value: number): number {
29
+ /* A share of a plan used, held to the bar: a provider may report more than all of it. */
30
+ export function fraction(value: number): number {
30
31
  return Math.min(1, Math.max(0, value));
31
32
  }
32
33
 
@@ -0,0 +1,72 @@
1
+ import type { UsageProvider } from '@adecore/agent-contracts';
2
+ import { asObject, asString, int, type UsageRecord } from '../record.ts';
3
+
4
+ /*
5
+ * One call to an API a host makes itself, a line of the usage log it keeps. Such an API has no CLI and so
6
+ * no transcript, so the host writes this line for each call and the scanner reads the folder like one.
7
+ * The kinds are disjoint, as they are in a record: `input` is what was neither read from nor written to
8
+ * a cache, and `reasoning` is a part of `output`. Each host maps the counts of its own API onto them, since
9
+ * the APIs do not agree: some report a prompt that holds its cached part, some report the input apart from both
10
+ * cache kinds, and only some have a cache write at all.
11
+ */
12
+ export interface HostUsageEntry {
13
+ at: number;
14
+ model: string;
15
+ session: string;
16
+ cwd: string;
17
+ input: number;
18
+ cacheRead: number;
19
+ cacheWrite: number;
20
+ output: number;
21
+ reasoning: number;
22
+ }
23
+
24
+ export function hostUsageLine(entry: HostUsageEntry): string {
25
+ return `${JSON.stringify(entry)}\n`;
26
+ }
27
+
28
+ /* A line of this log names its model, which keeps `JSON.parse` off anything else a folder holds. */
29
+ export function hostLogMightCarryUsage(line: string): boolean {
30
+ return line.includes('"model"');
31
+ }
32
+
33
+ /* The provider is the root's, so one reader serves every API a host logs. */
34
+ export function parseHostUsageLine(line: string, provider: UsageProvider): UsageRecord | null {
35
+ let parsed: unknown;
36
+ try {
37
+ parsed = JSON.parse(line);
38
+ } catch {
39
+ return null;
40
+ }
41
+ const entry = asObject(parsed);
42
+ if (entry === null) {
43
+ return null;
44
+ }
45
+ const model = asString(entry.model);
46
+ const timestampMs = typeof entry.at === 'number' && Number.isFinite(entry.at) ? entry.at : Number.NaN;
47
+ if (model === '' || Number.isNaN(timestampMs)) {
48
+ return null;
49
+ }
50
+ const totals = {
51
+ calls: 1,
52
+ input: int(entry.input),
53
+ cacheRead: int(entry.cacheRead),
54
+ cacheWrite: int(entry.cacheWrite),
55
+ cacheWrite1h: 0,
56
+ output: int(entry.output),
57
+ reasoning: int(entry.reasoning)
58
+ };
59
+ if (totals.input + totals.cacheRead + totals.cacheWrite + totals.output === 0) {
60
+ return null;
61
+ }
62
+ return {
63
+ provider,
64
+ timestampMs,
65
+ model,
66
+ sessionId: asString(entry.session),
67
+ cwd: asString(entry.cwd),
68
+ totals,
69
+ // Every line is a call of its own, so nothing is folded.
70
+ dedupeKey: null
71
+ };
72
+ }
@@ -9,8 +9,8 @@ export interface UsageRootPath {
9
9
  accounts?: string[];
10
10
  }
11
11
 
12
- /* Where in its config folder each CLI leaves its transcripts. */
13
- const TRANSCRIPTS: Record<UsageProvider, string> = { claude: 'projects', codex: 'sessions' };
12
+ /* Where in its config folder each CLI leaves its transcripts. A provider with no CLI has a log its host passes as a root of its own. */
13
+ const TRANSCRIPTS: Partial<Record<UsageProvider, string>> & Record<'claude' | 'codex', string> = { claude: 'projects', codex: 'sessions' };
14
14
 
15
15
  /* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
16
16
  export function usageRoots(env: Record<string, string | undefined> = process.env): UsageRootPath[] {
@@ -25,7 +25,11 @@ export function usageRoots(env: Record<string, string | undefined> = process.env
25
25
  export function accountRoots(folders: ReadonlyArray<{ id: string; kind: UsageProvider; folder: string }>): UsageRootPath[] {
26
26
  const roots = new Map<string, UsageRootPath & { accounts: string[] }>();
27
27
  for (const { id, kind, folder } of folders) {
28
- const path = join(folder, TRANSCRIPTS[kind]);
28
+ const transcripts = TRANSCRIPTS[kind];
29
+ if (transcripts === undefined) {
30
+ continue;
31
+ }
32
+ const path = join(folder, transcripts);
29
33
  const key = `${kind}\0${path}`;
30
34
  const root = roots.get(key);
31
35
  if (root === undefined) {
@@ -1,12 +1,13 @@
1
1
  import { createHash, type Hash } from 'node:crypto';
2
2
  import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import type { UsageRoot } from '@adecore/agent-contracts';
4
+ import type { UsageProvider, UsageRoot } from '@adecore/agent-contracts';
5
5
  import { errorText } from '../error-text.ts';
6
6
  import { isNotFound, writeAtomic } from '../fs.ts';
7
7
  import { decodeIndex, encodeIndex, type IndexedFile, type UsageIndex } from './index-file.ts';
8
8
  import { foldByKey, type UsageRecord } from './record.ts';
9
9
  import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.ts';
10
+ import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.ts';
10
11
  import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine, type CodexParserState } from './readers/codex.ts';
11
12
  import { usageRoots, type UsageRootPath } from './roots.ts';
12
13
 
@@ -98,7 +99,7 @@ async function listJsonl(dir: string, out: string[] = []): Promise<string[]> {
98
99
  * after the last newline is a line still being written, so it is parsed for what it is worth and
99
100
  * read again next time. The Codex state is snapshotted before that tail for the same reason.
100
101
  */
101
- function parseChunk(text: string, provider: 'claude' | 'codex', from: number, state: CodexParserState | null): ParsedFile {
102
+ function parseChunk(text: string, provider: UsageProvider, from: number, state: CodexParserState | null): ParsedFile {
102
103
  const end = text.lastIndexOf('\n');
103
104
  const whole = end === -1 ? '' : text.slice(0, end);
104
105
  const tail = text.slice(end + 1);
@@ -118,8 +119,18 @@ function parseChunk(text: string, provider: 'claude' | 'codex', from: number, st
118
119
  }
119
120
  continue;
120
121
  }
121
- if (codex !== null && codexMightCarryUsage(line)) {
122
- const record = parseCodexLine(line, codex);
122
+ if (provider === 'codex') {
123
+ if (codex !== null && codexMightCarryUsage(line)) {
124
+ const record = parseCodexLine(line, codex);
125
+ if (record !== null) {
126
+ records.push(record);
127
+ }
128
+ }
129
+ continue;
130
+ }
131
+ // Claude Code and Codex write transcripts of their own; every other provider is a log its host keeps.
132
+ if (hostLogMightCarryUsage(line)) {
133
+ const record = parseHostUsageLine(line, provider);
123
134
  if (record !== null) {
124
135
  records.push(record);
125
136
  }
@@ -265,7 +276,7 @@ export class UsageScanner {
265
276
  return foldByKey(all);
266
277
  }
267
278
 
268
- private async readFileInto(path: string, provider: 'claude' | 'codex', accountOf: AccountOf): Promise<boolean> {
279
+ private async readFileInto(path: string, provider: UsageProvider, accountOf: AccountOf): Promise<boolean> {
269
280
  const info = await stat(path);
270
281
  const known = this.index.get(path);
271
282
  if (known !== undefined && known.provider === provider && known.size === info.size && known.mtimeMs === info.mtimeMs) {