@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
@@ -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
+ }
@@ -5,6 +5,8 @@ export interface FakeAcpOptions {
5
5
  authMethod?: string;
6
6
  modes?: readonly string[];
7
7
  legacyModels?: boolean;
8
+ closeSession?: boolean;
9
+ turnUsage?: Record<string, number>;
8
10
  }
9
11
  export declare function fakeAcpWith(options: FakeAcpOptions): FakeCli;
10
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 = () => {
@@ -180,7 +180,7 @@ export function fakeAcpWith(options) {
180
180
  agentCapabilities: {
181
181
  loadSession: options.loadSession ?? true,
182
182
  promptCapabilities: { image: true, audio: false, embeddedContext: false },
183
- sessionCapabilities: options.resumeSession ? { resume: {} } : {}
183
+ sessionCapabilities: { ...(options.resumeSession ? { resume: {} } : {}), ...(options.closeSession ? { close: {} } : {}) }
184
184
  },
185
185
  authMethods: options.authMethod === undefined ? [] : [{ id: options.authMethod, name: 'Sign in' }],
186
186
  agentInfo: { name: 'fake-acp', title: 'Fake ACP', version: '0.0.1' }
@@ -220,6 +220,14 @@ export function fakeAcpWith(options) {
220
220
  respond(id, sessionState());
221
221
  return;
222
222
  }
223
+ case 'session/close':
224
+ if (!options.closeSession) {
225
+ refuse(id, -32601, 'Method not found: session/close');
226
+ return;
227
+ }
228
+ sessionHistory.delete(String(params.sessionId));
229
+ respond(id, {});
230
+ return;
223
231
  case 'session/set_mode':
224
232
  mode = String(params.modeId);
225
233
  respond(id, {});
@@ -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;
@@ -33,7 +33,11 @@ export function foldByKey(records) {
33
33
  folded.push({ ...record, totals: { ...record.totals } });
34
34
  continue;
35
35
  }
36
- maxTotals(folded[seen].totals, record.totals);
36
+ const into = folded[seen];
37
+ maxTotals(into.totals, record.totals);
38
+ if (record.costUsd !== undefined) {
39
+ into.costUsd = Math.max(into.costUsd ?? 0, record.costUsd);
40
+ }
37
41
  }
38
42
  return folded;
39
43
  }
@@ -1,13 +1,18 @@
1
1
  import { join } from 'node:path';
2
2
  import { homeFolder } from '../home-folder.js';
3
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
- const TRANSCRIPTS = { claude: 'projects', codex: 'sessions' };
4
+ const TRANSCRIPTS = {
5
+ claude: 'projects',
6
+ codex: 'sessions',
7
+ grok: 'sessions'
8
+ };
5
9
  /* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
6
10
  export function usageRoots(env = process.env) {
7
11
  const home = homeFolder(env);
8
12
  return [
9
13
  { provider: 'claude', path: join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), TRANSCRIPTS.claude) },
10
- { provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) }
14
+ { provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) },
15
+ { provider: 'grok', path: join(env.GROK_HOME ?? join(home, '.grok'), TRANSCRIPTS.grok) }
11
16
  ];
12
17
  }
13
18
  /* The transcripts of every account, once per folder: Codex accounts over one home share its sessions. */
@@ -26,5 +26,6 @@ export declare class UsageScanner {
26
26
  scan(): Promise<ScanReport>;
27
27
  records(): UsageRecord[];
28
28
  private readFileInto;
29
+ private readWholeInto;
29
30
  }
30
31
  export {};
@@ -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 { GROK_USAGE_FILE, parseGrokUsage } from './readers/grok.js';
9
10
  import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.js';
10
11
  import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
11
12
  import { usageRoots } from './roots.js';
@@ -49,7 +50,14 @@ function headOf(hash) {
49
50
  // Older indexes hashed only the first 4 KB; they cannot establish that the rest stayed unchanged.
50
51
  return `sha256:${hash.copy().digest('base64')}`;
51
52
  }
52
- async function listJsonl(dir, out = []) {
53
+ /* A log of lines for every provider but Grok, which keeps one JSON file per session that it rewrites whole. */
54
+ function readsWhole(provider) {
55
+ return provider === 'grok';
56
+ }
57
+ function isUsageFile(provider, name) {
58
+ return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl');
59
+ }
60
+ async function listUsageFiles(dir, provider, out = []) {
53
61
  let entries;
54
62
  try {
55
63
  entries = await readdir(dir, { withFileTypes: true });
@@ -61,9 +69,9 @@ async function listJsonl(dir, out = []) {
61
69
  for (const entry of entries) {
62
70
  const path = join(dir, entry.name);
63
71
  if (entry.isDirectory()) {
64
- await listJsonl(path, out);
72
+ await listUsageFiles(path, provider, out);
65
73
  }
66
- else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
74
+ else if (entry.isFile() && isUsageFile(provider, entry.name)) {
67
75
  out.push(path);
68
76
  }
69
77
  }
@@ -103,7 +111,7 @@ function parseChunk(text, provider, from, state) {
103
111
  }
104
112
  continue;
105
113
  }
106
- // Claude Code and Codex write transcripts of their own; every other provider is a log its host keeps.
114
+ // Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
107
115
  if (hostLogMightCarryUsage(line)) {
108
116
  const record = parseHostUsageLine(line, provider);
109
117
  if (record !== null) {
@@ -207,7 +215,7 @@ export class UsageScanner {
207
215
  }
208
216
  let failure = null;
209
217
  const accountOf = accountResolver(root, sessionAccounts);
210
- for (const path of await listJsonl(root.path)) {
218
+ for (const path of await listUsageFiles(root.path, root.provider)) {
211
219
  files += 1;
212
220
  try {
213
221
  if (await this.readFileInto(path, root.provider, accountOf)) {
@@ -242,6 +250,9 @@ export class UsageScanner {
242
250
  if (known !== undefined && known.provider === provider && known.size === info.size && known.mtimeMs === info.mtimeMs) {
243
251
  return false;
244
252
  }
253
+ if (readsWhole(provider)) {
254
+ return this.readWholeInto(path, provider, accountOf, info);
255
+ }
245
256
  const appended = known !== undefined && known.provider === provider && info.size > known.size && known.head?.startsWith('sha256:') === true;
246
257
  let read = await readHashed(path, appended ? known.offset : 0);
247
258
  // A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
@@ -268,4 +279,27 @@ export class UsageScanner {
268
279
  this.dirty = true;
269
280
  return true;
270
281
  }
282
+ /*
283
+ * A file rewritten as a whole replaces what the index held of it, so a turn it still lists is never
284
+ * counted twice. One that does not parse, such as one caught halfway through a write, keeps the
285
+ * records it had and is read again on the next scan.
286
+ */
287
+ async readWholeInto(path, provider, accountOf, info) {
288
+ const records = parseGrokUsage(await readFile(path, 'utf8'), path);
289
+ if (records === null) {
290
+ return false;
291
+ }
292
+ this.index.set(path, {
293
+ provider,
294
+ size: info.size,
295
+ mtimeMs: info.mtimeMs,
296
+ offset: info.size,
297
+ records: foldByKey(withAccount(records, accountOf)),
298
+ tail: [],
299
+ codex: null,
300
+ head: null
301
+ });
302
+ this.dirty = true;
303
+ return true;
304
+ }
271
305
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.31.1",
3
+ "version": "0.31.3",
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.31.1",
85
- "@adecore/intelligent-ui": "0.31.1",
84
+ "@adecore/agent-contracts": "0.31.3",
85
+ "@adecore/intelligent-ui": "0.31.3",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -10,12 +10,15 @@ import {
10
10
  autoAllows,
11
11
  parseInitialize,
12
12
  parseSessionInfo,
13
+ promptUsageOf,
13
14
  resolveAcpMode,
14
15
  runtimeModeOf,
15
16
  sessionIdOf,
16
17
  type AcpAgentCapabilities,
17
18
  type AcpModeMap,
18
- type AcpSessionInfo
19
+ type AcpPermissionRequest,
20
+ type AcpSessionInfo,
21
+ type AcpTurnUsage
19
22
  } from './acp-protocol.ts';
20
23
  import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
21
24
  import { attachmentNote } from './input.ts';
@@ -49,6 +52,18 @@ export interface AcpAgentSpec {
49
52
  model?(selection: ModelSelection): string | null;
50
53
  // What every new, loaded or resumed session offers, for a host that fills its model catalog from the agent.
51
54
  onSession?(info: AcpSessionInfo): void;
55
+ // The tokens of every turn whose `session/prompt` response reports them, for a host that keeps a usage log.
56
+ onUsage?(usage: AcpUsageReport): void;
57
+ // A permission request the host answers itself, in any runtime mode: true allows it once without asking the
58
+ // person, for a call the host already guards. A throw counts as false.
59
+ autoAllow?(request: AcpPermissionRequest): boolean;
60
+ }
61
+
62
+ /* One turn's usage, with the session, folder and model it ran in. */
63
+ export interface AcpUsageReport extends AcpTurnUsage {
64
+ sessionId: string;
65
+ cwd: string;
66
+ model: string;
52
67
  }
53
68
 
54
69
  // What `initialize` and a session request may take; a prompt and a login wait for as long as the agent runs.
@@ -77,6 +92,7 @@ export class AcpBackend implements ChatBackend {
77
92
  private replaying = false;
78
93
  private contextPending: string | null = null;
79
94
  private prompt: { promptId: string | undefined; cancelled: boolean } | null = null;
95
+ private model: string | null = null;
80
96
 
81
97
  constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec) {
82
98
  this.launch = launch;
@@ -139,6 +155,7 @@ export class AcpBackend implements ChatBackend {
139
155
  const info = await this.openSession(transport, capabilities);
140
156
  const modeId = await this.applyMode(transport, info);
141
157
  const model = await this.applyModel(transport, info);
158
+ this.model = model;
142
159
  await this.applyThoughtLevel(transport, info);
143
160
  this.spec.onSession?.(info);
144
161
  this.emit({
@@ -322,6 +339,7 @@ export class AcpBackend implements ChatBackend {
322
339
  .then((result) => {
323
340
  if (this.transport === transport && this.prompt === turn) {
324
341
  this.prompt = null;
342
+ this.report(result);
325
343
  this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
326
344
  }
327
345
  })
@@ -438,7 +456,7 @@ export class AcpBackend implements ChatBackend {
438
456
  }
439
457
  return;
440
458
  }
441
- if (autoAllows(this.launch.runtimeMode, permission.kind)) {
459
+ if (autoAllows(this.launch.runtimeMode, permission.kind) || this.hostAllows(permission.request)) {
442
460
  this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
443
461
  const answer = this.protocol.answer(permission.requestId, 'allow');
444
462
  if (answer) {
@@ -449,6 +467,14 @@ export class AcpBackend implements ChatBackend {
449
467
  this.emitAll(permission.events);
450
468
  }
451
469
 
470
+ private hostAllows(request: AcpPermissionRequest): boolean {
471
+ try {
472
+ return this.spec.autoAllow?.(request) === true;
473
+ } catch {
474
+ return false;
475
+ }
476
+ }
477
+
452
478
  private handleExit(transport: JsonRpcTransport, exitCode: number | null, stderr: string | null): void {
453
479
  if (this.transport !== transport) {
454
480
  return;
@@ -459,6 +485,15 @@ export class AcpBackend implements ChatBackend {
459
485
  this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
460
486
  }
461
487
 
488
+ /* A cancelled turn still spent what it reports. */
489
+ private report(result: unknown): void {
490
+ const usage = this.spec.onUsage === undefined ? null : promptUsageOf(result);
491
+ if (usage === null) {
492
+ return;
493
+ }
494
+ this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
495
+ }
496
+
462
497
  private emitAll(events: BackendEvent[]): void {
463
498
  for (const event of events) {
464
499
  this.emit(event);
@@ -466,6 +501,9 @@ export class AcpBackend implements ChatBackend {
466
501
  }
467
502
 
468
503
  private emit(event: BackendEvent): void {
504
+ if (event.type === 'model') {
505
+ this.model = event.model;
506
+ }
469
507
  this.host.onEvent(event);
470
508
  }
471
509
  }