@adecore/agents 0.31.2 → 0.31.3

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.
Files changed (41) hide show
  1. package/dist/chat/acp-backend.d.ts +11 -1
  2. package/dist/chat/acp-backend.js +24 -2
  3. package/dist/chat/acp-protocol.d.ts +14 -0
  4. package/dist/chat/acp-protocol.js +33 -1
  5. package/dist/chat/index.d.ts +2 -2
  6. package/dist/providers/acp-provider.d.ts +1 -0
  7. package/dist/providers/acp-provider.js +11 -1
  8. package/dist/providers/acp-usage-log.d.ts +2 -0
  9. package/dist/providers/acp-usage-log.js +21 -0
  10. package/dist/providers/index.d.ts +1 -0
  11. package/dist/providers/index.js +1 -0
  12. package/dist/providers/shell-commands.d.ts +1 -0
  13. package/dist/providers/shell-commands.js +119 -0
  14. package/dist/testing/fake-acp.d.ts +1 -0
  15. package/dist/testing/fake-acp.js +1 -1
  16. package/dist/usage/aggregate.js +6 -5
  17. package/dist/usage/index-file.js +25 -17
  18. package/dist/usage/limits/monitor.js +5 -0
  19. package/dist/usage/readers/grok.d.ts +3 -0
  20. package/dist/usage/readers/grok.js +85 -0
  21. package/dist/usage/record.d.ts +1 -0
  22. package/dist/usage/record.js +5 -1
  23. package/dist/usage/roots.js +7 -2
  24. package/dist/usage/scanner.d.ts +1 -0
  25. package/dist/usage/scanner.js +39 -5
  26. package/package.json +3 -3
  27. package/src/chat/acp-backend.ts +40 -2
  28. package/src/chat/acp-protocol.ts +56 -1
  29. package/src/chat/index.ts +12 -2
  30. package/src/providers/acp-provider.ts +16 -2
  31. package/src/providers/acp-usage-log.ts +26 -0
  32. package/src/providers/index.ts +1 -0
  33. package/src/providers/shell-commands.ts +122 -0
  34. package/src/testing/fake-acp.ts +3 -1
  35. package/src/usage/aggregate.ts +6 -5
  36. package/src/usage/index-file.ts +27 -18
  37. package/src/usage/limits/monitor.ts +5 -0
  38. package/src/usage/readers/grok.ts +89 -0
  39. package/src/usage/record.ts +7 -1
  40. package/src/usage/roots.ts +7 -2
  41. package/src/usage/scanner.ts +42 -5
@@ -1,5 +1,5 @@
1
1
  import { type ModelSelection } from '@adecore/agent-contracts';
2
- import { type AcpModeMap, type AcpSessionInfo } from './acp-protocol.ts';
2
+ import { type AcpModeMap, type AcpPermissionRequest, type AcpSessionInfo, type AcpTurnUsage } from './acp-protocol.ts';
3
3
  import type { ApprovalDecision, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
4
4
  export interface AcpClientInfo {
5
5
  name: string;
@@ -16,6 +16,13 @@ export interface AcpAgentSpec {
16
16
  client?: AcpClientInfo;
17
17
  model?(selection: ModelSelection): string | null;
18
18
  onSession?(info: AcpSessionInfo): void;
19
+ onUsage?(usage: AcpUsageReport): void;
20
+ autoAllow?(request: AcpPermissionRequest): boolean;
21
+ }
22
+ export interface AcpUsageReport extends AcpTurnUsage {
23
+ sessionId: string;
24
+ cwd: string;
25
+ model: string;
19
26
  }
20
27
  export declare class AcpBackend implements ChatBackend {
21
28
  private readonly launch;
@@ -29,6 +36,7 @@ export declare class AcpBackend implements ChatBackend {
29
36
  private replaying;
30
37
  private contextPending;
31
38
  private prompt;
39
+ private model;
32
40
  constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec);
33
41
  get running(): boolean;
34
42
  get pid(): number | null;
@@ -52,7 +60,9 @@ export declare class AcpBackend implements ChatBackend {
52
60
  private respond;
53
61
  private fail;
54
62
  private handleFrame;
63
+ private hostAllows;
55
64
  private handleExit;
65
+ private report;
56
66
  private emitAll;
57
67
  private emit;
58
68
  }
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { attachmentImageMime } from '@adecore/agent-contracts';
3
3
  import { errorText } from '../error-text.js';
4
4
  import { MODE_ORDER } from '../modes.js';
5
- import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, resolveAcpMode, runtimeModeOf, sessionIdOf } from './acp-protocol.js';
5
+ import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, promptUsageOf, resolveAcpMode, runtimeModeOf, sessionIdOf } from './acp-protocol.js';
6
6
  import { attachmentNote } from './input.js';
7
7
  import { JsonRpcError, JsonRpcTransport } from './json-rpc-transport.js';
8
8
  export const DEFAULT_ACP_CLIENT = { name: 'agents', title: 'Agents', version: '0.1.0' };
@@ -30,6 +30,7 @@ export class AcpBackend {
30
30
  replaying = false;
31
31
  contextPending = null;
32
32
  prompt = null;
33
+ model = null;
33
34
  constructor(launch, host, spec) {
34
35
  this.launch = launch;
35
36
  this.host = host;
@@ -86,6 +87,7 @@ export class AcpBackend {
86
87
  const info = await this.openSession(transport, capabilities);
87
88
  const modeId = await this.applyMode(transport, info);
88
89
  const model = await this.applyModel(transport, info);
90
+ this.model = model;
89
91
  await this.applyThoughtLevel(transport, info);
90
92
  this.spec.onSession?.(info);
91
93
  this.emit({
@@ -265,6 +267,7 @@ export class AcpBackend {
265
267
  .then((result) => {
266
268
  if (this.transport === transport && this.prompt === turn) {
267
269
  this.prompt = null;
270
+ this.report(result);
268
271
  this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
269
272
  }
270
273
  })
@@ -373,7 +376,7 @@ export class AcpBackend {
373
376
  }
374
377
  return;
375
378
  }
376
- if (autoAllows(this.launch.runtimeMode, permission.kind)) {
379
+ if (autoAllows(this.launch.runtimeMode, permission.kind) || this.hostAllows(permission.request)) {
377
380
  this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
378
381
  const answer = this.protocol.answer(permission.requestId, 'allow');
379
382
  if (answer) {
@@ -383,6 +386,14 @@ export class AcpBackend {
383
386
  }
384
387
  this.emitAll(permission.events);
385
388
  }
389
+ hostAllows(request) {
390
+ try {
391
+ return this.spec.autoAllow?.(request) === true;
392
+ }
393
+ catch {
394
+ return false;
395
+ }
396
+ }
386
397
  handleExit(transport, exitCode, stderr) {
387
398
  if (this.transport !== transport) {
388
399
  return;
@@ -392,12 +403,23 @@ export class AcpBackend {
392
403
  this.protocol.cancelPermissions();
393
404
  this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
394
405
  }
406
+ /* A cancelled turn still spent what it reports. */
407
+ report(result) {
408
+ const usage = this.spec.onUsage === undefined ? null : promptUsageOf(result);
409
+ if (usage === null) {
410
+ return;
411
+ }
412
+ this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
413
+ }
395
414
  emitAll(events) {
396
415
  for (const event of events) {
397
416
  this.emit(event);
398
417
  }
399
418
  }
400
419
  emit(event) {
420
+ if (event.type === 'model') {
421
+ this.model = event.model;
422
+ }
401
423
  this.host.onEvent(event);
402
424
  }
403
425
  }
@@ -9,6 +9,14 @@ declare const DiffSchema: z.ZodObject<{
9
9
  oldText: z.ZodOptional<z.ZodNullable<z.ZodString>>;
10
10
  newText: z.ZodString;
11
11
  }, z.core.$loose>;
12
+ export interface AcpTurnUsage {
13
+ input: number;
14
+ cacheRead: number;
15
+ cacheWrite: number;
16
+ output: number;
17
+ reasoning: number;
18
+ }
19
+ export declare function promptUsageOf(result: unknown): AcpTurnUsage | null;
12
20
  export interface AcpAgentInfo {
13
21
  name: string;
14
22
  title: string | null;
@@ -84,10 +92,16 @@ export declare function toolRow(tool: Pick<ToolState, 'title' | 'name' | 'kind'
84
92
  input: Record<string, unknown>;
85
93
  changes?: ChatFileChange[];
86
94
  };
95
+ export interface AcpPermissionRequest {
96
+ kind: string | null;
97
+ title: string | null;
98
+ commands: string[];
99
+ }
87
100
  export interface AcpPermission {
88
101
  requestId: string;
89
102
  rpcId: number | string;
90
103
  kind: string | null;
104
+ request: AcpPermissionRequest;
91
105
  events: BackendEvent[];
92
106
  }
93
107
  export interface AcpStopWords {
@@ -40,6 +40,36 @@ const PermissionRequestSchema = z.looseObject({
40
40
  });
41
41
  const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update: z.looseObject({ sessionUpdate: z.string() }) });
42
42
  const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
43
+ const tokenCount = z.number().int().nonnegative();
44
+ // Unstable in the protocol (`PromptResponse.usage`); only the three totals are required.
45
+ const PromptUsageSchema = z.looseObject({
46
+ totalTokens: tokenCount,
47
+ inputTokens: tokenCount,
48
+ outputTokens: tokenCount,
49
+ thoughtTokens: tokenCount.nullish(),
50
+ cachedReadTokens: tokenCount.nullish(),
51
+ cachedWriteTokens: tokenCount.nullish()
52
+ });
53
+ /*
54
+ * The `usage` a `session/prompt` response carries, or null where the agent reports none. The protocol
55
+ * leaves open whether the input holds the cached part, so the total decides: when the kinds add up
56
+ * to more than it, the input counted the cache too and loses it here.
57
+ */
58
+ export function promptUsageOf(result) {
59
+ const response = z.looseObject({ usage: PromptUsageSchema }).safeParse(result);
60
+ if (!response.success) {
61
+ return null;
62
+ }
63
+ const usage = response.data.usage;
64
+ const cacheRead = usage.cachedReadTokens ?? 0;
65
+ const cacheWrite = usage.cachedWriteTokens ?? 0;
66
+ const apart = usage.inputTokens + usage.outputTokens + cacheRead + cacheWrite <= usage.totalTokens;
67
+ const input = apart ? usage.inputTokens : Math.max(0, usage.inputTokens - cacheRead - cacheWrite);
68
+ if (input + cacheRead + cacheWrite + usage.outputTokens === 0) {
69
+ return null;
70
+ }
71
+ return { input, cacheRead, cacheWrite, output: usage.outputTokens, reasoning: usage.thoughtTokens ?? 0 };
72
+ }
43
73
  const ModeStateSchema = z.looseObject({
44
74
  currentModeId: z.string(),
45
75
  availableModes: z.array(z.unknown())
@@ -416,7 +446,9 @@ export class AcpProtocol {
416
446
  description: tool.title === '' ? null : tool.title,
417
447
  canAllowAlways: options.some((option) => option.kind === 'allow_always')
418
448
  });
419
- return { requestId, rpcId, kind: tool.kind, events };
449
+ const command = rawField(tool.rawInput, 'command', 'cmd');
450
+ const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
451
+ return { requestId, rpcId, kind: tool.kind, request: asked, events };
420
452
  }
421
453
  /*
422
454
  * The answer to a permission request: allow once, reject, or allow always only when the agent offered it.
@@ -1,5 +1,5 @@
1
- export { type AcpAgentSpec, type AcpClientInfo, createAcpBackend } from './acp-backend.ts';
2
- export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpSessionInfo, AcpModeMap } from './acp-protocol.ts';
1
+ export { type AcpAgentSpec, type AcpClientInfo, type AcpUsageReport, createAcpBackend } from './acp-backend.ts';
2
+ export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpPermissionRequest, AcpSessionInfo, AcpModeMap, AcpTurnUsage } from './acp-protocol.ts';
3
3
  export { AttachmentStore } from './attachment-store.ts';
4
4
  export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
5
5
  export { runningInBackground, type BackgroundWork } from './background-work.ts';
@@ -21,5 +21,6 @@ export interface AcpProviderSpec extends AcpAgentSpec {
21
21
  enabled?(): boolean;
22
22
  accountProbe?: boolean;
23
23
  adoptModels?: boolean;
24
+ usageLog?: string;
24
25
  }
25
26
  export declare function createAcpProvider(spec: AcpProviderSpec): ChatProvider;
@@ -1,5 +1,6 @@
1
1
  import { createAcpBackend } from '../chat/acp-backend.js';
2
2
  import { askAcpAccount } from './accounts/acp-account.js';
3
+ import { writeAcpUsage } from './acp-usage-log.js';
3
4
  import { detectCli } from './detect.js';
4
5
  /*
5
6
  * What every ACP agent can do through this backend. A file change comes as the text before and after;
@@ -22,6 +23,8 @@ export const ACP_CAPABILITIES = {
22
23
  reportsThinking: true,
23
24
  slashCommands: true
24
25
  };
26
+ /* Agents whose own usage files the scanner reads; a log of their turns would count every one twice. */
27
+ const OWN_USAGE_FILES = new Set(['grok']);
25
28
  /* An agent's model id may reach a command line, so one that reads as a flag is left out. */
26
29
  function adoptableModels(info) {
27
30
  return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
@@ -34,6 +37,13 @@ export function createAcpProvider(spec) {
34
37
  }
35
38
  spec.onSession?.(info);
36
39
  };
40
+ const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
41
+ const onUsage = usageLog === null
42
+ ? spec.onUsage
43
+ : (report) => {
44
+ writeAcpUsage(usageLog, report, Date.now());
45
+ spec.onUsage?.(report);
46
+ };
37
47
  return {
38
48
  kind: spec.kind,
39
49
  name: spec.name,
@@ -63,6 +73,6 @@ export function createAcpProvider(spec) {
63
73
  onSession
64
74
  })
65
75
  }),
66
- createBackend: createAcpBackend({ ...spec, onSession })
76
+ createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
67
77
  };
68
78
  }
@@ -0,0 +1,2 @@
1
+ import type { AcpUsageReport } from '../chat/acp-backend.ts';
2
+ export declare function writeAcpUsage(folder: string, report: AcpUsageReport, at: number): void;
@@ -0,0 +1,21 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { errorText } from '../error-text.js';
4
+ import { hostUsageLine } from '../usage/readers/host-log.js';
5
+ /*
6
+ * Appends one turn to the session's own file in the folder, so a scan that hashes what it already read
7
+ * of a file never rereads the log of every other session. Written at once, since it is one line per turn.
8
+ */
9
+ export function writeAcpUsage(folder, report, at) {
10
+ const { sessionId, cwd, model, ...tokens } = report;
11
+ try {
12
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
13
+ appendFileSync(join(folder, `${encodeURIComponent(sessionId === '' ? 'session' : sessionId)}.jsonl`), hostUsageLine({ at, model, session: sessionId, cwd, ...tokens }), {
14
+ mode: 0o600
15
+ });
16
+ }
17
+ catch (e) {
18
+ // A turn whose line could not be written is missing from the usage page; the chat itself goes on.
19
+ console.warn('Could not write the usage of an ACP turn:', errorText(e));
20
+ }
21
+ }
@@ -12,5 +12,6 @@ export { type CodexRules, codexRulesText, codexRulesPathIn, defaultCodexHome, in
12
12
  export { codexThreadOptions, codexServiceTier } from './codex.ts';
13
13
  export { type CliDetection, detectCli } from './detect.ts';
14
14
  export type { ChatProvider, ProviderHome } from './provider.ts';
15
+ export { isOnlyCommands } from './shell-commands.ts';
15
16
  export { ProviderRegistry, type ProviderRegistryOptions } from './registry.ts';
16
17
  export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.ts';
@@ -10,5 +10,6 @@ export { createCodexProvider } from './codex-provider.js';
10
10
  export { codexRulesText, codexRulesPathIn, defaultCodexHome, installCodexRules } from './codex-rules.js';
11
11
  export { codexThreadOptions, codexServiceTier } from './codex.js';
12
12
  export { detectCli } from './detect.js';
13
+ export { isOnlyCommands } from './shell-commands.js';
13
14
  export { ProviderRegistry } from './registry.js';
14
15
  export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.js';
@@ -0,0 +1 @@
1
+ export declare function isOnlyCommands(command: string, allowed: readonly string[]): boolean;
@@ -0,0 +1,119 @@
1
+ // Unquoted, each of these makes the shell do more than run a word list: a pipe, a redirect, an expansion,
2
+ // a subshell or group, a glob, a comment or a history or negation mark.
3
+ const REFUSED = new Set(['|', '>', '<', '$', '`', '(', ')', '{', '}', '*', '?', '[', '~', '#', '!']);
4
+ /*
5
+ * Whether a shell command line is only simple commands, joined by `&&`, `||`, `;` or newlines, each of
6
+ * which starts with one of `allowed`. Anything the tokenizer cannot prove simple answers false, so a
7
+ * true is safe to act on without a person: a pipe, a redirect, an expansion, a subshell, a background
8
+ * `&`, an unquoted glob or an unbalanced quote all refuse the whole line.
9
+ */
10
+ export function isOnlyCommands(command, allowed) {
11
+ let word = '';
12
+ let inWord = false;
13
+ let words = [];
14
+ let commands = 0;
15
+ // A separator that still needs a command after it; a trailing `;` or newline needs none.
16
+ let dangling = false;
17
+ const endWord = () => {
18
+ if (inWord) {
19
+ words.push(word);
20
+ }
21
+ word = '';
22
+ inWord = false;
23
+ };
24
+ const endCommand = (separator) => {
25
+ endWord();
26
+ if (words.length === 0) {
27
+ // Blank lines are fine, and so is a newline after `&&` or `||`; an empty command before any other separator is not.
28
+ return separator === '\n';
29
+ }
30
+ if (!allowed.includes(words[0])) {
31
+ return false;
32
+ }
33
+ commands++;
34
+ words = [];
35
+ dangling = separator === '&&' || separator === '||';
36
+ return true;
37
+ };
38
+ for (let i = 0; i < command.length; i++) {
39
+ const char = command[i];
40
+ const next = command[i + 1];
41
+ if (char === ' ' || char === '\t') {
42
+ endWord();
43
+ continue;
44
+ }
45
+ if (char === '\n' || char === ';') {
46
+ if (!endCommand(char)) {
47
+ return false;
48
+ }
49
+ continue;
50
+ }
51
+ if ((char === '&' || char === '|') && next === char) {
52
+ if (!endCommand(char + next)) {
53
+ return false;
54
+ }
55
+ i++;
56
+ continue;
57
+ }
58
+ if (char === '&' || REFUSED.has(char)) {
59
+ return false;
60
+ }
61
+ if (char === '\\') {
62
+ if (next === undefined) {
63
+ return false;
64
+ }
65
+ i++;
66
+ if (next !== '\n') {
67
+ word += next;
68
+ inWord = true;
69
+ }
70
+ continue;
71
+ }
72
+ if (char === "'") {
73
+ const close = command.indexOf("'", i + 1);
74
+ if (close === -1) {
75
+ return false;
76
+ }
77
+ word += command.slice(i + 1, close);
78
+ inWord = true;
79
+ i = close;
80
+ continue;
81
+ }
82
+ if (char === '"') {
83
+ inWord = true;
84
+ let closed = false;
85
+ for (i++; i < command.length; i++) {
86
+ const quoted = command[i];
87
+ if (quoted === '"') {
88
+ closed = true;
89
+ break;
90
+ }
91
+ if (quoted === '$' || quoted === '`') {
92
+ return false;
93
+ }
94
+ if (quoted === '\\') {
95
+ const escaped = command[i + 1];
96
+ if (escaped === undefined) {
97
+ return false;
98
+ }
99
+ i++;
100
+ if (escaped === '"' || escaped === '\\' || escaped === '$' || escaped === '`') {
101
+ word += escaped;
102
+ }
103
+ else if (escaped !== '\n') {
104
+ word += quoted + escaped;
105
+ }
106
+ continue;
107
+ }
108
+ word += quoted;
109
+ }
110
+ if (!closed) {
111
+ return false;
112
+ }
113
+ continue;
114
+ }
115
+ word += char;
116
+ inWord = true;
117
+ }
118
+ return endCommand('\n') && commands > 0 && !dangling;
119
+ }
@@ -6,6 +6,7 @@ export interface FakeAcpOptions {
6
6
  modes?: readonly string[];
7
7
  legacyModels?: boolean;
8
8
  closeSession?: boolean;
9
+ turnUsage?: Record<string, number>;
9
10
  }
10
11
  export declare function fakeAcpWith(options: FakeAcpOptions): FakeCli;
11
12
  export declare const fakeAcp: FakeCli;
@@ -33,7 +33,7 @@ export function fakeAcpWith(options) {
33
33
  return;
34
34
  }
35
35
  update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
36
- respond(prompt.rpcId, { stopReason });
36
+ respond(prompt.rpcId, { stopReason, ...(options.turnUsage === undefined ? {} : { usage: options.turnUsage }) });
37
37
  prompt = null;
38
38
  };
39
39
  const sessionState = () => {
@@ -47,7 +47,7 @@ function foldBucket(buckets, slot, record, account, price, cost) {
47
47
  }
48
48
  bucket.totals = addTotals(bucket.totals, record.totals);
49
49
  bucket.costUsd += cost;
50
- bucket.priced ||= price !== null;
50
+ bucket.priced ||= price !== null || record.costUsd !== undefined;
51
51
  bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
52
52
  if (record.sessionId !== '') {
53
53
  bucket.sessions.add(record.sessionId);
@@ -64,14 +64,15 @@ function foldModel(models, record, account, lookup, cost) {
64
64
  totals: emptyTotals(),
65
65
  costUsd: 0,
66
66
  priced: false,
67
- basis: lookup.basis,
68
- pricedAs: lookup.pricedAs
67
+ // A cost the CLI reported is the bill itself, not a price looked up for the model.
68
+ basis: record.costUsd === undefined ? lookup.basis : 'exact',
69
+ pricedAs: record.costUsd === undefined ? lookup.pricedAs : null
69
70
  };
70
71
  models.set(key, model);
71
72
  }
72
73
  model.totals = addTotals(model.totals, record.totals);
73
74
  model.costUsd += cost;
74
- model.priced ||= lookup.price !== null;
75
+ model.priced ||= lookup.price !== null || record.costUsd !== undefined;
75
76
  }
76
77
  function foldProject(project, record, cost) {
77
78
  project.totals = addTotals(project.totals, record.totals);
@@ -111,7 +112,7 @@ export async function aggregate(records, payload, prices, known) {
111
112
  continue;
112
113
  }
113
114
  const lookup = prices.look(record.model);
114
- const cost = lookup.price === null ? 0 : costOf(record.totals, lookup.price);
115
+ const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
115
116
  foldBucket(buckets, slot, record, account, lookup.price, cost);
116
117
  foldModel(models, record, account, lookup, cost);
117
118
  if (record.sessionId !== '') {
@@ -23,21 +23,27 @@ export function encodeIndex(index) {
23
23
  const folders = new Interner();
24
24
  const accounts = new Interner();
25
25
  const files = {};
26
- const row = (record) => [
27
- record.timestampMs,
28
- models.index(record.model),
29
- sessions.index(record.sessionId),
30
- folders.index(record.cwd),
31
- record.totals.calls,
32
- record.totals.input,
33
- record.totals.cacheRead,
34
- record.totals.cacheWrite,
35
- record.totals.cacheWrite1h,
36
- record.totals.output,
37
- record.totals.reasoning,
38
- record.dedupeKey ?? 0,
39
- record.account === undefined ? 0 : accounts.index(record.account) + 1
40
- ];
26
+ const row = (record) => {
27
+ const counted = [
28
+ record.timestampMs,
29
+ models.index(record.model),
30
+ sessions.index(record.sessionId),
31
+ folders.index(record.cwd),
32
+ record.totals.calls,
33
+ record.totals.input,
34
+ record.totals.cacheRead,
35
+ record.totals.cacheWrite,
36
+ record.totals.cacheWrite1h,
37
+ record.totals.output,
38
+ record.totals.reasoning,
39
+ record.dedupeKey ?? 0,
40
+ record.account === undefined ? 0 : accounts.index(record.account) + 1
41
+ ];
42
+ if (record.costUsd !== undefined) {
43
+ counted.push(record.costUsd);
44
+ }
45
+ return counted;
46
+ };
41
47
  for (const [path, file] of index) {
42
48
  files[path] = {
43
49
  p: file.provider,
@@ -62,10 +68,11 @@ export function encodeIndex(index) {
62
68
  }
63
69
  function isRow(row, version) {
64
70
  return (Array.isArray(row) &&
65
- row.length === (version === ACCOUNTLESS_VERSION ? 12 : 13) &&
71
+ (version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length === 13 || row.length === 14) &&
66
72
  row.slice(0, 11).every((value) => typeof value === 'number') &&
67
73
  (typeof row[11] === 'string' || row[11] === 0) &&
68
- (version === ACCOUNTLESS_VERSION || typeof row[12] === 'number'));
74
+ (version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
75
+ (row.length < 14 || typeof row[13] === 'number'));
69
76
  }
70
77
  /*
71
78
  * A row that does not read back costs a cold parse of the file it belonged to, never a wrong total,
@@ -119,6 +126,7 @@ export function decodeIndex(text) {
119
126
  cwd,
120
127
  ...(account === undefined ? {} : { account }),
121
128
  totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
129
+ ...(row[13] === undefined ? {} : { costUsd: row[13] }),
122
130
  dedupeKey: row[11] === 0 ? null : row[11]
123
131
  });
124
132
  }
@@ -10,6 +10,8 @@ const MAX_BACKOFF_MS = 60 * 60_000;
10
10
  /* An account other than the default one that nobody started a CLI under for this long is only read when a person asks. */
11
11
  const RECENT_USE_MS = 24 * 60 * 60_000;
12
12
  const PROVIDERS = USAGE_PROVIDERS;
13
+ /* The CLIs the probes know how to ask; any other provider has a plan only through the host's `plans`. */
14
+ const PROBED = ['claude', 'codex'];
13
15
  function blank(kind) {
14
16
  return {
15
17
  kind,
@@ -106,6 +108,9 @@ export class UsageMonitor {
106
108
  listed() {
107
109
  const accounts = this.accounts?.list() ?? PROVIDERS.map((kind) => ({ id: kind, kind, label: kind, isDefault: true }));
108
110
  return PROVIDERS.flatMap((kind) => {
111
+ if (!PROBED.includes(kind) && this.plans[kind] === undefined) {
112
+ return [];
113
+ }
109
114
  const own = accounts.filter((account) => account.kind === kind);
110
115
  return [...own.filter((account) => account.isDefault), ...own.filter((account) => !account.isDefault)];
111
116
  });
@@ -0,0 +1,3 @@
1
+ import { type UsageRecord } from '../record.ts';
2
+ export declare const GROK_USAGE_FILE = "usage.json";
3
+ export declare function parseGrokUsage(text: string, path: string): UsageRecord[] | null;
@@ -0,0 +1,85 @@
1
+ import { basename, dirname } from 'node:path';
2
+ import { asObject, asString, int } from '../record.js';
3
+ /* The file Grok keeps the counts of a session in, beside its other files in the session's folder. */
4
+ export const GROK_USAGE_FILE = 'usage.json';
5
+ // `costUsdTicks` counts in ticks of 10^-10 USD, as Grok's own docs on `grok usage` say.
6
+ const TICKS_PER_USD = 1e10;
7
+ /* The folder above a session's is its working directory, URL-encoded into one name. */
8
+ function cwdOf(path) {
9
+ try {
10
+ return decodeURIComponent(basename(dirname(dirname(path))));
11
+ }
12
+ catch {
13
+ return '';
14
+ }
15
+ }
16
+ function recordOf(counts, model, base, turn) {
17
+ const cacheRead = int(counts.cachedReadTokens);
18
+ const cacheWrite = int(counts.cacheCreationTokens);
19
+ const totals = {
20
+ calls: int(counts.modelCalls),
21
+ // Grok's input holds both cache kinds: its total is input plus output.
22
+ input: Math.max(0, int(counts.inputTokens) - cacheRead - cacheWrite),
23
+ cacheRead,
24
+ cacheWrite,
25
+ cacheWrite1h: 0,
26
+ output: int(counts.outputTokens),
27
+ reasoning: int(counts.reasoningTokens)
28
+ };
29
+ if (totals.input + totals.cacheRead + totals.cacheWrite + totals.output === 0) {
30
+ return null;
31
+ }
32
+ const ticks = int(counts.costUsdTicks);
33
+ return {
34
+ ...base,
35
+ model,
36
+ totals,
37
+ // A missing or zero cost is one Grok did not report, which the price table then estimates.
38
+ ...(ticks === 0 ? {} : { costUsd: ticks / TICKS_PER_USD }),
39
+ dedupeKey: `grok\0${base.sessionId}\0${turn}\0${model}`
40
+ };
41
+ }
42
+ /*
43
+ * A session's `usage.json` under `$GROK_HOME/sessions/<cwd>/<session id>/`, which Grok rewrites whole
44
+ * at the end of every turn. One record per turn per model the turn called; null when the file does not
45
+ * read as one, which leaves what the scanner held of it.
46
+ */
47
+ export function parseGrokUsage(text, path) {
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(text);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ const file = asObject(parsed);
56
+ if (file === null || !Array.isArray(file.turns)) {
57
+ return null;
58
+ }
59
+ const sessionId = asString(file.sessionId) || basename(dirname(path));
60
+ const cwd = cwdOf(path);
61
+ const records = [];
62
+ for (const entry of file.turns) {
63
+ const turn = asObject(entry);
64
+ const timestampMs = turn === null ? Number.NaN : Date.parse(asString(turn.endedAt));
65
+ if (turn === null || Number.isNaN(timestampMs)) {
66
+ continue;
67
+ }
68
+ const number = typeof turn.turnNumber === 'number' ? String(turn.turnNumber) : asString(turn.endedAt);
69
+ const base = { provider: 'grok', timestampMs, sessionId, cwd };
70
+ const byModel = asObject(turn.modelUsage);
71
+ const models = byModel === null
72
+ ? [[asString(turn.primaryModelId), turn]]
73
+ : Object.entries(byModel).flatMap(([model, counts]) => {
74
+ const object = asObject(counts);
75
+ return object === null ? [] : [[model, object]];
76
+ });
77
+ for (const [model, counts] of models) {
78
+ const record = model === '' ? null : recordOf(counts, model, base, number);
79
+ if (record !== null) {
80
+ records.push(record);
81
+ }
82
+ }
83
+ }
84
+ return records;
85
+ }
@@ -7,6 +7,7 @@ export interface UsageRecord {
7
7
  cwd: string;
8
8
  account?: string;
9
9
  totals: UsageTotals;
10
+ costUsd?: number;
10
11
  dedupeKey: string | null;
11
12
  }
12
13
  export declare function int(value: unknown): number;