@adecore/agents 0.31.1 → 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 (55) 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 +15 -0
  4. package/dist/chat/acp-protocol.js +36 -2
  5. package/dist/chat/index.d.ts +2 -2
  6. package/dist/providers/accounts/acp-account.d.ts +19 -0
  7. package/dist/providers/accounts/acp-account.js +101 -0
  8. package/dist/providers/accounts/service.d.ts +1 -0
  9. package/dist/providers/accounts/service.js +14 -4
  10. package/dist/providers/accounts/status.d.ts +2 -0
  11. package/dist/providers/accounts/status.js +3 -1
  12. package/dist/providers/acp-provider.d.ts +3 -0
  13. package/dist/providers/acp-provider.js +33 -1
  14. package/dist/providers/acp-usage-log.d.ts +2 -0
  15. package/dist/providers/acp-usage-log.js +21 -0
  16. package/dist/providers/catalog.d.ts +4 -0
  17. package/dist/providers/catalog.js +21 -0
  18. package/dist/providers/index.d.ts +2 -0
  19. package/dist/providers/index.js +2 -0
  20. package/dist/providers/provider.d.ts +2 -0
  21. package/dist/providers/shell-commands.d.ts +1 -0
  22. package/dist/providers/shell-commands.js +119 -0
  23. package/dist/testing/fake-acp.d.ts +2 -0
  24. package/dist/testing/fake-acp.js +10 -2
  25. package/dist/usage/aggregate.js +6 -5
  26. package/dist/usage/index-file.js +25 -17
  27. package/dist/usage/limits/monitor.js +5 -0
  28. package/dist/usage/readers/grok.d.ts +3 -0
  29. package/dist/usage/readers/grok.js +85 -0
  30. package/dist/usage/record.d.ts +1 -0
  31. package/dist/usage/record.js +5 -1
  32. package/dist/usage/roots.js +7 -2
  33. package/dist/usage/scanner.d.ts +1 -0
  34. package/dist/usage/scanner.js +39 -5
  35. package/package.json +3 -3
  36. package/src/chat/acp-backend.ts +40 -2
  37. package/src/chat/acp-protocol.ts +61 -2
  38. package/src/chat/index.ts +12 -2
  39. package/src/providers/accounts/acp-account.ts +129 -0
  40. package/src/providers/accounts/service.ts +15 -5
  41. package/src/providers/accounts/status.ts +4 -1
  42. package/src/providers/acp-provider.ts +45 -2
  43. package/src/providers/acp-usage-log.ts +26 -0
  44. package/src/providers/catalog.ts +23 -0
  45. package/src/providers/index.ts +2 -0
  46. package/src/providers/provider.ts +3 -0
  47. package/src/providers/shell-commands.ts +122 -0
  48. package/src/testing/fake-acp.ts +14 -2
  49. package/src/usage/aggregate.ts +6 -5
  50. package/src/usage/index-file.ts +27 -18
  51. package/src/usage/limits/monitor.ts +5 -0
  52. package/src/usage/readers/grok.ts +89 -0
  53. package/src/usage/record.ts +7 -1
  54. package/src/usage/roots.ts +7 -2
  55. package/src/usage/scanner.ts +42 -5
@@ -20,6 +20,10 @@ export interface FakeAcpOptions {
20
20
  modes?: readonly string[];
21
21
  // Reports models the way agents did before config options: `models` and `session/set_model`.
22
22
  legacyModels?: boolean;
23
+ // Advertises and answers `session/close`.
24
+ closeSession?: boolean;
25
+ // The `usage` every `session/prompt` response carries; absent reports none.
26
+ turnUsage?: Record<string, number>;
23
27
  }
24
28
 
25
29
  export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
@@ -61,7 +65,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
61
65
  return;
62
66
  }
63
67
  update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
64
- respond(prompt.rpcId, { stopReason });
68
+ respond(prompt.rpcId, { stopReason, ...(options.turnUsage === undefined ? {} : { usage: options.turnUsage }) });
65
69
  prompt = null;
66
70
  };
67
71
 
@@ -212,7 +216,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
212
216
  agentCapabilities: {
213
217
  loadSession: options.loadSession ?? true,
214
218
  promptCapabilities: { image: true, audio: false, embeddedContext: false },
215
- sessionCapabilities: options.resumeSession ? { resume: {} } : {}
219
+ sessionCapabilities: { ...(options.resumeSession ? { resume: {} } : {}), ...(options.closeSession ? { close: {} } : {}) }
216
220
  },
217
221
  authMethods: options.authMethod === undefined ? [] : [{ id: options.authMethod, name: 'Sign in' }],
218
222
  agentInfo: { name: 'fake-acp', title: 'Fake ACP', version: '0.0.1' }
@@ -252,6 +256,14 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
252
256
  respond(id, sessionState());
253
257
  return;
254
258
  }
259
+ case 'session/close':
260
+ if (!options.closeSession) {
261
+ refuse(id, -32601, 'Method not found: session/close');
262
+ return;
263
+ }
264
+ sessionHistory.delete(String(params.sessionId));
265
+ respond(id, {});
266
+ return;
255
267
  case 'session/set_mode':
256
268
  mode = String(params.modeId);
257
269
  respond(id, {});
@@ -112,7 +112,7 @@ function foldBucket(
112
112
  }
113
113
  bucket.totals = addTotals(bucket.totals, record.totals);
114
114
  bucket.costUsd += cost;
115
- bucket.priced ||= price !== null;
115
+ bucket.priced ||= price !== null || record.costUsd !== undefined;
116
116
  bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
117
117
  if (record.sessionId !== '') {
118
118
  bucket.sessions.add(record.sessionId);
@@ -130,14 +130,15 @@ function foldModel(models: Map<string, ModelRow>, record: UsageRecord, account:
130
130
  totals: emptyTotals(),
131
131
  costUsd: 0,
132
132
  priced: false,
133
- basis: lookup.basis,
134
- pricedAs: lookup.pricedAs
133
+ // A cost the CLI reported is the bill itself, not a price looked up for the model.
134
+ basis: record.costUsd === undefined ? lookup.basis : 'exact',
135
+ pricedAs: record.costUsd === undefined ? lookup.pricedAs : null
135
136
  };
136
137
  models.set(key, model);
137
138
  }
138
139
  model.totals = addTotals(model.totals, record.totals);
139
140
  model.costUsd += cost;
140
- model.priced ||= lookup.price !== null;
141
+ model.priced ||= lookup.price !== null || record.costUsd !== undefined;
141
142
  }
142
143
 
143
144
  function foldProject(project: ProjectAccumulator, record: UsageRecord, cost: number): void {
@@ -186,7 +187,7 @@ export async function aggregate(
186
187
  continue;
187
188
  }
188
189
  const lookup = prices.look(record.model);
189
- const cost = lookup.price === null ? 0 : costOf(record.totals, lookup.price);
190
+ const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
190
191
  foldBucket(buckets, slot, record, account, lookup.price, cost);
191
192
  foldModel(models, record, account, lookup, cost);
192
193
  if (record.sessionId !== '') {
@@ -30,8 +30,9 @@ export type UsageIndex = Map<string, IndexedFile>;
30
30
  * directory and account strings over and over. Interning those and writing a record as a tuple is
31
31
  * what keeps the file at a few megabytes instead of tens of them. The account is one past its place
32
32
  * in its table, and 0 for the default account, which is also what a version 1 row without it means.
33
+ * A cost the CLI reported itself rides last, only on a row that has one, so every other row keeps its length.
33
34
  */
34
- type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?];
35
+ type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?];
35
36
 
36
37
  interface FileRow {
37
38
  p: UsageProvider;
@@ -75,21 +76,27 @@ export function encodeIndex(index: UsageIndex): string {
75
76
  const folders = new Interner();
76
77
  const accounts = new Interner();
77
78
  const files: Record<string, FileRow> = {};
78
- const row = (record: UsageRecord): RecordRow => [
79
- record.timestampMs,
80
- models.index(record.model),
81
- sessions.index(record.sessionId),
82
- folders.index(record.cwd),
83
- record.totals.calls,
84
- record.totals.input,
85
- record.totals.cacheRead,
86
- record.totals.cacheWrite,
87
- record.totals.cacheWrite1h,
88
- record.totals.output,
89
- record.totals.reasoning,
90
- record.dedupeKey ?? 0,
91
- record.account === undefined ? 0 : accounts.index(record.account) + 1
92
- ];
79
+ const row = (record: UsageRecord): RecordRow => {
80
+ const counted: RecordRow = [
81
+ record.timestampMs,
82
+ models.index(record.model),
83
+ sessions.index(record.sessionId),
84
+ folders.index(record.cwd),
85
+ record.totals.calls,
86
+ record.totals.input,
87
+ record.totals.cacheRead,
88
+ record.totals.cacheWrite,
89
+ record.totals.cacheWrite1h,
90
+ record.totals.output,
91
+ record.totals.reasoning,
92
+ record.dedupeKey ?? 0,
93
+ record.account === undefined ? 0 : accounts.index(record.account) + 1
94
+ ];
95
+ if (record.costUsd !== undefined) {
96
+ counted.push(record.costUsd);
97
+ }
98
+ return counted;
99
+ };
93
100
  for (const [path, file] of index) {
94
101
  files[path] = {
95
102
  p: file.provider,
@@ -116,10 +123,11 @@ export function encodeIndex(index: UsageIndex): string {
116
123
  function isRow(row: unknown, version: number): row is RecordRow {
117
124
  return (
118
125
  Array.isArray(row) &&
119
- row.length === (version === ACCOUNTLESS_VERSION ? 12 : 13) &&
126
+ (version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length === 13 || row.length === 14) &&
120
127
  row.slice(0, 11).every((value) => typeof value === 'number') &&
121
128
  (typeof row[11] === 'string' || row[11] === 0) &&
122
- (version === ACCOUNTLESS_VERSION || typeof row[12] === 'number')
129
+ (version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
130
+ (row.length < 14 || typeof row[13] === 'number')
123
131
  );
124
132
  }
125
133
 
@@ -176,6 +184,7 @@ export function decodeIndex(text: string): UsageIndex {
176
184
  cwd,
177
185
  ...(account === undefined ? {} : { account }),
178
186
  totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
187
+ ...(row[13] === undefined ? {} : { costUsd: row[13] }),
179
188
  dedupeKey: row[11] === 0 ? null : row[11]
180
189
  });
181
190
  }
@@ -15,6 +15,8 @@ const MAX_BACKOFF_MS = 60 * 60_000;
15
15
  const RECENT_USE_MS = 24 * 60 * 60_000;
16
16
 
17
17
  const PROVIDERS: readonly UsageProvider[] = USAGE_PROVIDERS;
18
+ /* The CLIs the probes know how to ask; any other provider has a plan only through the host's `plans`. */
19
+ const PROBED: readonly UsageProvider[] = ['claude', 'codex'];
18
20
 
19
21
  export type ProbeEnv = Record<string, string>;
20
22
 
@@ -167,6 +169,9 @@ export class UsageMonitor {
167
169
  private listed(): LimitAccount[] {
168
170
  const accounts = this.accounts?.list() ?? PROVIDERS.map((kind) => ({ id: kind, kind, label: kind, isDefault: true }));
169
171
  return PROVIDERS.flatMap((kind) => {
172
+ if (!PROBED.includes(kind) && this.plans[kind] === undefined) {
173
+ return [];
174
+ }
170
175
  const own = accounts.filter((account) => account.kind === kind);
171
176
  return [...own.filter((account) => account.isDefault), ...own.filter((account) => !account.isDefault)];
172
177
  });
@@ -0,0 +1,89 @@
1
+ import { basename, dirname } from 'node:path';
2
+ import { asObject, asString, int, type UsageRecord } from '../record.ts';
3
+
4
+ /* The file Grok keeps the counts of a session in, beside its other files in the session's folder. */
5
+ export const GROK_USAGE_FILE = 'usage.json';
6
+
7
+ // `costUsdTicks` counts in ticks of 10^-10 USD, as Grok's own docs on `grok usage` say.
8
+ const TICKS_PER_USD = 1e10;
9
+
10
+ /* The folder above a session's is its working directory, URL-encoded into one name. */
11
+ function cwdOf(path: string): string {
12
+ try {
13
+ return decodeURIComponent(basename(dirname(dirname(path))));
14
+ } catch {
15
+ return '';
16
+ }
17
+ }
18
+
19
+ function recordOf(counts: Record<string, unknown>, model: string, base: Omit<UsageRecord, 'model' | 'totals' | 'dedupeKey'>, turn: string): UsageRecord | null {
20
+ const cacheRead = int(counts.cachedReadTokens);
21
+ const cacheWrite = int(counts.cacheCreationTokens);
22
+ const totals = {
23
+ calls: int(counts.modelCalls),
24
+ // Grok's input holds both cache kinds: its total is input plus output.
25
+ input: Math.max(0, int(counts.inputTokens) - cacheRead - cacheWrite),
26
+ cacheRead,
27
+ cacheWrite,
28
+ cacheWrite1h: 0,
29
+ output: int(counts.outputTokens),
30
+ reasoning: int(counts.reasoningTokens)
31
+ };
32
+ if (totals.input + totals.cacheRead + totals.cacheWrite + totals.output === 0) {
33
+ return null;
34
+ }
35
+ const ticks = int(counts.costUsdTicks);
36
+ return {
37
+ ...base,
38
+ model,
39
+ totals,
40
+ // A missing or zero cost is one Grok did not report, which the price table then estimates.
41
+ ...(ticks === 0 ? {} : { costUsd: ticks / TICKS_PER_USD }),
42
+ dedupeKey: `grok\0${base.sessionId}\0${turn}\0${model}`
43
+ };
44
+ }
45
+
46
+ /*
47
+ * A session's `usage.json` under `$GROK_HOME/sessions/<cwd>/<session id>/`, which Grok rewrites whole
48
+ * at the end of every turn. One record per turn per model the turn called; null when the file does not
49
+ * read as one, which leaves what the scanner held of it.
50
+ */
51
+ export function parseGrokUsage(text: string, path: string): UsageRecord[] | null {
52
+ let parsed: unknown;
53
+ try {
54
+ parsed = JSON.parse(text);
55
+ } catch {
56
+ return null;
57
+ }
58
+ const file = asObject(parsed);
59
+ if (file === null || !Array.isArray(file.turns)) {
60
+ return null;
61
+ }
62
+ const sessionId = asString(file.sessionId) || basename(dirname(path));
63
+ const cwd = cwdOf(path);
64
+ const records: UsageRecord[] = [];
65
+ for (const entry of file.turns) {
66
+ const turn = asObject(entry);
67
+ const timestampMs = turn === null ? Number.NaN : Date.parse(asString(turn.endedAt));
68
+ if (turn === null || Number.isNaN(timestampMs)) {
69
+ continue;
70
+ }
71
+ const number = typeof turn.turnNumber === 'number' ? String(turn.turnNumber) : asString(turn.endedAt);
72
+ const base = { provider: 'grok' as const, timestampMs, sessionId, cwd };
73
+ const byModel = asObject(turn.modelUsage);
74
+ const models: Array<[string, Record<string, unknown>]> =
75
+ byModel === null
76
+ ? [[asString(turn.primaryModelId), turn]]
77
+ : Object.entries(byModel).flatMap(([model, counts]) => {
78
+ const object = asObject(counts);
79
+ return object === null ? [] : [[model, object] as [string, Record<string, unknown>]];
80
+ });
81
+ for (const [model, counts] of models) {
82
+ const record = model === '' ? null : recordOf(counts, model, base, number);
83
+ if (record !== null) {
84
+ records.push(record);
85
+ }
86
+ }
87
+ }
88
+ return records;
89
+ }
@@ -11,6 +11,8 @@ export interface UsageRecord {
11
11
  /* The account the call was made under; absent is the default account of the CLI. */
12
12
  account?: string;
13
13
  totals: UsageTotals;
14
+ /* What the CLI itself says the call cost, which wins over a price from the table; absent where it says nothing. */
15
+ costUsd?: number;
14
16
  /* What identifies the message this line reports on, or null when nothing does. */
15
17
  dedupeKey: string | null;
16
18
  }
@@ -54,7 +56,11 @@ export function foldByKey(records: readonly UsageRecord[]): UsageRecord[] {
54
56
  folded.push({ ...record, totals: { ...record.totals } });
55
57
  continue;
56
58
  }
57
- maxTotals(folded[seen]!.totals, record.totals);
59
+ const into = folded[seen]!;
60
+ maxTotals(into.totals, record.totals);
61
+ if (record.costUsd !== undefined) {
62
+ into.costUsd = Math.max(into.costUsd ?? 0, record.costUsd);
63
+ }
58
64
  }
59
65
  return folded;
60
66
  }
@@ -10,14 +10,19 @@ export interface UsageRootPath {
10
10
  }
11
11
 
12
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' };
13
+ const TRANSCRIPTS: Partial<Record<UsageProvider, string>> & Record<'claude' | 'codex' | 'grok', string> = {
14
+ claude: 'projects',
15
+ codex: 'sessions',
16
+ grok: 'sessions'
17
+ };
14
18
 
15
19
  /* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
16
20
  export function usageRoots(env: Record<string, string | undefined> = process.env): UsageRootPath[] {
17
21
  const home = homeFolder(env);
18
22
  return [
19
23
  { provider: 'claude', path: join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), TRANSCRIPTS.claude) },
20
- { provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) }
24
+ { provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) },
25
+ { provider: 'grok', path: join(env.GROK_HOME ?? join(home, '.grok'), TRANSCRIPTS.grok) }
21
26
  ];
22
27
  }
23
28
 
@@ -7,6 +7,7 @@ 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 { GROK_USAGE_FILE, parseGrokUsage } from './readers/grok.ts';
10
11
  import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.ts';
11
12
  import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine, type CodexParserState } from './readers/codex.ts';
12
13
  import { usageRoots, type UsageRootPath } from './roots.ts';
@@ -75,7 +76,16 @@ interface ParsedFile {
75
76
  codex: CodexParserState | null;
76
77
  }
77
78
 
78
- async function listJsonl(dir: string, out: string[] = []): Promise<string[]> {
79
+ /* A log of lines for every provider but Grok, which keeps one JSON file per session that it rewrites whole. */
80
+ function readsWhole(provider: UsageProvider): boolean {
81
+ return provider === 'grok';
82
+ }
83
+
84
+ function isUsageFile(provider: UsageProvider, name: string): boolean {
85
+ return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl');
86
+ }
87
+
88
+ async function listUsageFiles(dir: string, provider: UsageProvider, out: string[] = []): Promise<string[]> {
79
89
  let entries;
80
90
  try {
81
91
  entries = await readdir(dir, { withFileTypes: true });
@@ -86,8 +96,8 @@ async function listJsonl(dir: string, out: string[] = []): Promise<string[]> {
86
96
  for (const entry of entries) {
87
97
  const path = join(dir, entry.name);
88
98
  if (entry.isDirectory()) {
89
- await listJsonl(path, out);
90
- } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
99
+ await listUsageFiles(path, provider, out);
100
+ } else if (entry.isFile() && isUsageFile(provider, entry.name)) {
91
101
  out.push(path);
92
102
  }
93
103
  }
@@ -128,7 +138,7 @@ function parseChunk(text: string, provider: UsageProvider, from: number, state:
128
138
  }
129
139
  continue;
130
140
  }
131
- // Claude Code and Codex write transcripts of their own; every other provider is a log its host keeps.
141
+ // Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
132
142
  if (hostLogMightCarryUsage(line)) {
133
143
  const record = parseHostUsageLine(line, provider);
134
144
  if (record !== null) {
@@ -245,7 +255,7 @@ export class UsageScanner {
245
255
  }
246
256
  let failure: string | null = null;
247
257
  const accountOf = accountResolver(root, sessionAccounts);
248
- for (const path of await listJsonl(root.path)) {
258
+ for (const path of await listUsageFiles(root.path, root.provider)) {
249
259
  files += 1;
250
260
  try {
251
261
  if (await this.readFileInto(path, root.provider, accountOf)) {
@@ -282,6 +292,9 @@ export class UsageScanner {
282
292
  if (known !== undefined && known.provider === provider && known.size === info.size && known.mtimeMs === info.mtimeMs) {
283
293
  return false;
284
294
  }
295
+ if (readsWhole(provider)) {
296
+ return this.readWholeInto(path, provider, accountOf, info);
297
+ }
285
298
  const appended = known !== undefined && known.provider === provider && info.size > known.size && known.head?.startsWith('sha256:') === true;
286
299
  let read = await readHashed(path, appended ? known.offset : 0);
287
300
  // A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
@@ -308,4 +321,28 @@ export class UsageScanner {
308
321
  this.dirty = true;
309
322
  return true;
310
323
  }
324
+
325
+ /*
326
+ * A file rewritten as a whole replaces what the index held of it, so a turn it still lists is never
327
+ * counted twice. One that does not parse, such as one caught halfway through a write, keeps the
328
+ * records it had and is read again on the next scan.
329
+ */
330
+ private async readWholeInto(path: string, provider: UsageProvider, accountOf: AccountOf, info: { size: number; mtimeMs: number }): Promise<boolean> {
331
+ const records = parseGrokUsage(await readFile(path, 'utf8'), path);
332
+ if (records === null) {
333
+ return false;
334
+ }
335
+ this.index.set(path, {
336
+ provider,
337
+ size: info.size,
338
+ mtimeMs: info.mtimeMs,
339
+ offset: info.size,
340
+ records: foldByKey(withAccount(records, accountOf)),
341
+ tail: [],
342
+ codex: null,
343
+ head: null
344
+ });
345
+ this.dirty = true;
346
+ return true;
347
+ }
311
348
  }