@adecore/agents 0.31.2 → 0.31.4

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 (44) hide show
  1. package/dist/chat/acp-backend.d.ts +11 -1
  2. package/dist/chat/acp-backend.js +49 -17
  3. package/dist/chat/acp-protocol.d.ts +26 -5
  4. package/dist/chat/acp-protocol.js +53 -5
  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 +32 -2
  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/catalog.d.ts +2 -2
  11. package/dist/providers/catalog.js +6 -3
  12. package/dist/providers/index.d.ts +1 -0
  13. package/dist/providers/index.js +1 -0
  14. package/dist/providers/shell-commands.d.ts +1 -0
  15. package/dist/providers/shell-commands.js +119 -0
  16. package/dist/testing/fake-acp.d.ts +2 -0
  17. package/dist/testing/fake-acp.js +23 -2
  18. package/dist/usage/aggregate.js +6 -5
  19. package/dist/usage/index-file.js +25 -17
  20. package/dist/usage/limits/monitor.js +5 -0
  21. package/dist/usage/readers/grok.d.ts +3 -0
  22. package/dist/usage/readers/grok.js +85 -0
  23. package/dist/usage/record.d.ts +1 -0
  24. package/dist/usage/record.js +5 -1
  25. package/dist/usage/roots.js +7 -2
  26. package/dist/usage/scanner.d.ts +1 -0
  27. package/dist/usage/scanner.js +39 -5
  28. package/package.json +3 -3
  29. package/src/chat/acp-backend.ts +67 -17
  30. package/src/chat/acp-protocol.ts +95 -7
  31. package/src/chat/index.ts +14 -2
  32. package/src/providers/acp-provider.ts +39 -4
  33. package/src/providers/acp-usage-log.ts +26 -0
  34. package/src/providers/catalog.ts +7 -4
  35. package/src/providers/index.ts +1 -0
  36. package/src/providers/shell-commands.ts +122 -0
  37. package/src/testing/fake-acp.ts +29 -2
  38. package/src/usage/aggregate.ts +6 -5
  39. package/src/usage/index-file.ts +27 -18
  40. package/src/usage/limits/monitor.ts +5 -0
  41. package/src/usage/readers/grok.ts +89 -0
  42. package/src/usage/record.ts +7 -1
  43. package/src/usage/roots.ts +7 -2
  44. package/src/usage/scanner.ts +42 -5
@@ -7,6 +7,7 @@ export function fakeAcpWith(options) {
7
7
  let sessionId = '';
8
8
  let mode = modes[0] ?? '';
9
9
  let model = 'fake-small';
10
+ let effort = 'low';
10
11
  let serverRequestId = 0;
11
12
  let callCounter = 0;
12
13
  let prompt = null;
@@ -33,7 +34,7 @@ export function fakeAcpWith(options) {
33
34
  return;
34
35
  }
35
36
  update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
36
- respond(prompt.rpcId, { stopReason });
37
+ respond(prompt.rpcId, { stopReason, ...(options.turnUsage === undefined ? {} : { usage: options.turnUsage }) });
37
38
  prompt = null;
38
39
  };
39
40
  const sessionState = () => {
@@ -41,11 +42,24 @@ export function fakeAcpWith(options) {
41
42
  { value: 'fake-small', name: 'Fake small' },
42
43
  { value: 'fake-large', name: 'Fake large' }
43
44
  ];
45
+ const effortChoices = [
46
+ { value: 'low', name: 'Low', description: 'Answers quickly' },
47
+ { value: 'high', name: 'High' },
48
+ ...(model === 'fake-large' ? [{ value: 'xhigh', name: 'Extra high' }] : [])
49
+ ];
50
+ const effortOption = options.reasoningEffort === undefined
51
+ ? []
52
+ : [{ id: 'reasoning_effort', name: 'Reasoning effort', type: 'select', currentValue: effort, options: effortChoices }];
44
53
  return {
45
54
  ...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
46
55
  ...(options.legacyModels
47
56
  ? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId: choice.value, name: choice.name })) } }
48
- : { configOptions: [{ id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices }] })
57
+ : {
58
+ configOptions: [
59
+ { id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices },
60
+ ...effortOption
61
+ ]
62
+ })
49
63
  };
50
64
  };
51
65
  const askPermission = (toolCall, onAnswer) => {
@@ -241,6 +255,13 @@ export function fakeAcpWith(options) {
241
255
  if (params.configId === 'model') {
242
256
  model = String(params.value);
243
257
  }
258
+ if (params.configId === 'reasoning_effort') {
259
+ if (options.reasoningEffort === 'reject') {
260
+ refuse(id, -32602, 'Reasoning effort is locked');
261
+ return;
262
+ }
263
+ effort = String(params.value);
264
+ }
244
265
  respond(id, { configOptions: sessionState().configOptions ?? [] });
245
266
  return;
246
267
  case 'session/prompt':
@@ -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.2",
3
+ "version": "0.31.4",
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.2",
85
- "@adecore/intelligent-ui": "0.31.2",
84
+ "@adecore/agent-contracts": "0.31.4",
85
+ "@adecore/intelligent-ui": "0.31.4",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -10,12 +10,17 @@ import {
10
10
  autoAllows,
11
11
  parseInitialize,
12
12
  parseSessionInfo,
13
+ promptUsageOf,
13
14
  resolveAcpMode,
14
15
  runtimeModeOf,
15
16
  sessionIdOf,
17
+ thoughtLevelOf,
16
18
  type AcpAgentCapabilities,
17
19
  type AcpModeMap,
18
- type AcpSessionInfo
20
+ type AcpPermissionRequest,
21
+ type AcpSessionInfo,
22
+ type AcpThoughtLevel,
23
+ type AcpTurnUsage
19
24
  } from './acp-protocol.ts';
20
25
  import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
21
26
  import { attachmentNote } from './input.ts';
@@ -49,6 +54,18 @@ export interface AcpAgentSpec {
49
54
  model?(selection: ModelSelection): string | null;
50
55
  // What every new, loaded or resumed session offers, for a host that fills its model catalog from the agent.
51
56
  onSession?(info: AcpSessionInfo): void;
57
+ // The tokens of every turn whose `session/prompt` response reports them, for a host that keeps a usage log.
58
+ onUsage?(usage: AcpUsageReport): void;
59
+ // A permission request the host answers itself, in any runtime mode: true allows it once without asking the
60
+ // person, for a call the host already guards. A throw counts as false.
61
+ autoAllow?(request: AcpPermissionRequest): boolean;
62
+ }
63
+
64
+ /* One turn's usage, with the session, folder and model it ran in. */
65
+ export interface AcpUsageReport extends AcpTurnUsage {
66
+ sessionId: string;
67
+ cwd: string;
68
+ model: string;
52
69
  }
53
70
 
54
71
  // What `initialize` and a session request may take; a prompt and a login wait for as long as the agent runs.
@@ -77,6 +94,7 @@ export class AcpBackend implements ChatBackend {
77
94
  private replaying = false;
78
95
  private contextPending: string | null = null;
79
96
  private prompt: { promptId: string | undefined; cancelled: boolean } | null = null;
97
+ private model: string | null = null;
80
98
 
81
99
  constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec) {
82
100
  this.launch = launch;
@@ -138,8 +156,9 @@ export class AcpBackend implements ChatBackend {
138
156
  this.capabilities = capabilities;
139
157
  const info = await this.openSession(transport, capabilities);
140
158
  const modeId = await this.applyMode(transport, info);
141
- const model = await this.applyModel(transport, info);
142
- await this.applyThoughtLevel(transport, info);
159
+ const { model, thoughtLevel } = await this.applyModel(transport, info);
160
+ this.model = model;
161
+ await this.applyThoughtLevel(transport, thoughtLevel);
143
162
  this.spec.onSession?.(info);
144
163
  this.emit({
145
164
  type: 'session',
@@ -246,24 +265,29 @@ export class AcpBackend implements ChatBackend {
246
265
  return resolved.modeId;
247
266
  }
248
267
 
249
- /* Switches to the selected model where the agent offers it; answers the model the session runs. */
250
- private async applyModel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<string | null> {
268
+ /*
269
+ * Switches to the selected model where the agent offers it; answers the model the session runs and the
270
+ * reasoning effort it offers there, which may change with the model.
271
+ */
272
+ private async applyModel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<{ model: string | null; thoughtLevel: AcpThoughtLevel | null }> {
251
273
  const models = info.models;
274
+ const thoughtLevel = info.thoughtLevel;
252
275
  const wanted = this.spec.model ? this.spec.model(this.launch.selection) : this.launch.selection.model;
253
276
  if (models === null || wanted === null || wanted === models.current) {
254
- return models?.current ?? wanted;
277
+ return { model: models?.current ?? wanted, thoughtLevel };
255
278
  }
256
279
  if (!models.models.some((model) => model.id === wanted)) {
257
280
  this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} does not offer ${this.launch.modelName}. It runs ${models.current}.` });
258
- return models.current;
281
+ return { model: models.current, thoughtLevel };
259
282
  }
260
283
  try {
261
284
  if (models.configId === null) {
262
285
  await transport.request('session/set_model', { sessionId: this.sessionId, modelId: wanted });
263
- } else {
264
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
286
+ return { model: wanted, thoughtLevel };
265
287
  }
266
- return wanted;
288
+ const result = await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
289
+ const after = thoughtLevelOf(result);
290
+ return { model: wanted, thoughtLevel: after === undefined ? thoughtLevel : after };
267
291
  } catch (error) {
268
292
  if (!(error instanceof JsonRpcError)) {
269
293
  throw error;
@@ -273,21 +297,26 @@ export class AcpBackend implements ChatBackend {
273
297
  level: 'warning',
274
298
  text: `${this.spec.name} did not switch to ${this.launch.modelName} (${errorText(error)}). It runs ${models.current}.`
275
299
  });
276
- return models.current;
300
+ return { model: models.current, thoughtLevel };
277
301
  }
278
302
  }
279
303
 
280
- private async applyThoughtLevel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<void> {
281
- const level = info.thoughtLevel;
304
+ /* Sets the selected effort where the session offers it; a model without one runs as it is. */
305
+ private async applyThoughtLevel(transport: JsonRpcTransport, level: AcpThoughtLevel | null): Promise<void> {
282
306
  const effort = this.launch.selection.options.effort;
283
- if (level === null || typeof effort !== 'string' || effort === level.current || !level.values.includes(effort)) {
307
+ const choice = level?.choices.find((entry) => entry.id === effort);
308
+ if (level === null || choice === undefined || effort === level.current) {
284
309
  return;
285
310
  }
286
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: effort }).catch((error: unknown) => {
311
+ try {
312
+ await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: choice.id });
313
+ } catch (error) {
287
314
  if (!(error instanceof JsonRpcError)) {
288
315
  throw error;
289
316
  }
290
- });
317
+ const current = level.choices.find((entry) => entry.id === level.current)?.name ?? level.current;
318
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} kept its reasoning at ${current} instead of ${choice.name}.` });
319
+ }
291
320
  }
292
321
 
293
322
  private promptBlocks(input: TurnInput): unknown[] {
@@ -322,6 +351,7 @@ export class AcpBackend implements ChatBackend {
322
351
  .then((result) => {
323
352
  if (this.transport === transport && this.prompt === turn) {
324
353
  this.prompt = null;
354
+ this.report(result);
325
355
  this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
326
356
  }
327
357
  })
@@ -438,7 +468,7 @@ export class AcpBackend implements ChatBackend {
438
468
  }
439
469
  return;
440
470
  }
441
- if (autoAllows(this.launch.runtimeMode, permission.kind)) {
471
+ if (autoAllows(this.launch.runtimeMode, permission.kind) || this.hostAllows(permission.request)) {
442
472
  this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
443
473
  const answer = this.protocol.answer(permission.requestId, 'allow');
444
474
  if (answer) {
@@ -449,6 +479,14 @@ export class AcpBackend implements ChatBackend {
449
479
  this.emitAll(permission.events);
450
480
  }
451
481
 
482
+ private hostAllows(request: AcpPermissionRequest): boolean {
483
+ try {
484
+ return this.spec.autoAllow?.(request) === true;
485
+ } catch {
486
+ return false;
487
+ }
488
+ }
489
+
452
490
  private handleExit(transport: JsonRpcTransport, exitCode: number | null, stderr: string | null): void {
453
491
  if (this.transport !== transport) {
454
492
  return;
@@ -459,6 +497,15 @@ export class AcpBackend implements ChatBackend {
459
497
  this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
460
498
  }
461
499
 
500
+ /* A cancelled turn still spent what it reports. */
501
+ private report(result: unknown): void {
502
+ const usage = this.spec.onUsage === undefined ? null : promptUsageOf(result);
503
+ if (usage === null) {
504
+ return;
505
+ }
506
+ this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
507
+ }
508
+
462
509
  private emitAll(events: BackendEvent[]): void {
463
510
  for (const event of events) {
464
511
  this.emit(event);
@@ -466,6 +513,9 @@ export class AcpBackend implements ChatBackend {
466
513
  }
467
514
 
468
515
  private emit(event: BackendEvent): void {
516
+ if (event.type === 'model') {
517
+ this.model = event.model;
518
+ }
469
519
  this.host.onEvent(event);
470
520
  }
471
521
  }