@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
@@ -58,6 +58,48 @@ const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update:
58
58
 
59
59
  const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
60
60
 
61
+ const tokenCount = z.number().int().nonnegative();
62
+
63
+ // Unstable in the protocol (`PromptResponse.usage`); only the three totals are required.
64
+ const PromptUsageSchema = z.looseObject({
65
+ totalTokens: tokenCount,
66
+ inputTokens: tokenCount,
67
+ outputTokens: tokenCount,
68
+ thoughtTokens: tokenCount.nullish(),
69
+ cachedReadTokens: tokenCount.nullish(),
70
+ cachedWriteTokens: tokenCount.nullish()
71
+ });
72
+
73
+ /* The tokens of one turn with their kinds apart, as a usage record keeps them: `reasoning` is a part of `output`. */
74
+ export interface AcpTurnUsage {
75
+ input: number;
76
+ cacheRead: number;
77
+ cacheWrite: number;
78
+ output: number;
79
+ reasoning: number;
80
+ }
81
+
82
+ /*
83
+ * The `usage` a `session/prompt` response carries, or null where the agent reports none. The protocol
84
+ * leaves open whether the input holds the cached part, so the total decides: when the kinds add up
85
+ * to more than it, the input counted the cache too and loses it here.
86
+ */
87
+ export function promptUsageOf(result: unknown): AcpTurnUsage | null {
88
+ const response = z.looseObject({ usage: PromptUsageSchema }).safeParse(result);
89
+ if (!response.success) {
90
+ return null;
91
+ }
92
+ const usage = response.data.usage;
93
+ const cacheRead = usage.cachedReadTokens ?? 0;
94
+ const cacheWrite = usage.cachedWriteTokens ?? 0;
95
+ const apart = usage.inputTokens + usage.outputTokens + cacheRead + cacheWrite <= usage.totalTokens;
96
+ const input = apart ? usage.inputTokens : Math.max(0, usage.inputTokens - cacheRead - cacheWrite);
97
+ if (input + cacheRead + cacheWrite + usage.outputTokens === 0) {
98
+ return null;
99
+ }
100
+ return { input, cacheRead, cacheWrite, output: usage.outputTokens, reasoning: usage.thoughtTokens ?? 0 };
101
+ }
102
+
61
103
  const ModeStateSchema = z.looseObject({
62
104
  currentModeId: z.string(),
63
105
  availableModes: z.array(z.unknown())
@@ -65,7 +107,7 @@ const ModeStateSchema = z.looseObject({
65
107
 
66
108
  const ModeSchema = z.looseObject({ id: z.string(), name: z.string() });
67
109
 
68
- const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string() });
110
+ const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish() });
69
111
 
70
112
  const ConfigOptionSchema = z.looseObject({
71
113
  id: z.string(),
@@ -179,14 +221,27 @@ export interface AcpModeState {
179
221
  configId: string | null;
180
222
  }
181
223
 
224
+ export interface AcpChoice {
225
+ id: string;
226
+ name: string;
227
+ description: string | null;
228
+ }
229
+
230
+ /* The reasoning effort a session offers: the config option that sets it, the value it runs at and the values it takes. */
231
+ export interface AcpThoughtLevel {
232
+ configId: string;
233
+ current: string;
234
+ choices: AcpChoice[];
235
+ }
236
+
182
237
  /* What `session/new`, `session/load` or `session/resume` said about the session, for a host that fills its catalog from it. */
183
238
  export interface AcpSessionInfo {
184
239
  sessionId: string;
185
240
  agent: AcpAgentInfo | null;
186
241
  models: AcpModelState | null;
187
242
  modes: AcpModeState | null;
188
- // The reasoning effort a session offers, as a config option with the `thought_level` category.
189
- thoughtLevel: { configId: string; current: string; values: string[] } | null;
243
+ // A config option of the `thought_level` category, or one with the id `reasoning_effort` where an agent sets no category.
244
+ thoughtLevel: AcpThoughtLevel | null;
190
245
  }
191
246
 
192
247
  type ConfigOption = z.infer<typeof ConfigOptionSchema>;
@@ -201,6 +256,22 @@ function selectOption(options: ConfigOption[], category: string): ConfigOption |
201
256
  return options.find((option) => option.type === 'select' && option.category === category && typeof option.currentValue === 'string') ?? null;
202
257
  }
203
258
 
259
+ function thoughtOption(options: ConfigOption[]): ConfigOption | null {
260
+ return (
261
+ selectOption(options, 'thought_level') ??
262
+ options.find((option) => option.type === 'select' && option.id === 'reasoning_effort' && typeof option.currentValue === 'string') ??
263
+ null
264
+ );
265
+ }
266
+
267
+ /* The reasoning effort in a response that carries the session's config options; undefined when it carries none. */
268
+ export function thoughtLevelOf(result: unknown): AcpThoughtLevel | null | undefined {
269
+ if (!isRecord(result) || !Array.isArray(result.configOptions)) {
270
+ return undefined;
271
+ }
272
+ return sessionState(validItems(ConfigOptionSchema, result.configOptions), null, null).thoughtLevel;
273
+ }
274
+
204
275
  export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpAgentInfo | null): AcpSessionInfo {
205
276
  const record = isRecord(result) ? result : {};
206
277
  const options = validItems(ConfigOptionSchema, Array.isArray(record.configOptions) ? record.configOptions : []);
@@ -210,7 +281,7 @@ export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpA
210
281
  function sessionState(options: ConfigOption[], modes: unknown, models: unknown): Omit<AcpSessionInfo, 'sessionId' | 'agent'> {
211
282
  const modelOption = selectOption(options, 'model');
212
283
  const modeOption = selectOption(options, 'mode');
213
- const thoughtOption = selectOption(options, 'thought_level');
284
+ const thought = thoughtOption(options);
214
285
  const modeState = ModeStateSchema.safeParse(modes);
215
286
  const modelState = ModelStateSchema.safeParse(models);
216
287
  return {
@@ -244,8 +315,12 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
244
315
  configId: null
245
316
  }
246
317
  : null,
247
- thoughtLevel: thoughtOption
248
- ? { configId: thoughtOption.id, current: thoughtOption.currentValue as string, values: selectValues(thoughtOption).map((value) => value.value) }
318
+ thoughtLevel: thought
319
+ ? {
320
+ configId: thought.id,
321
+ current: thought.currentValue as string,
322
+ choices: selectValues(thought).map((value) => ({ id: value.value, name: value.name, description: value.description ?? null }))
323
+ }
249
324
  : null
250
325
  };
251
326
  }
@@ -440,11 +515,22 @@ function toolOutput(tool: ToolState): string | null {
440
515
  return null;
441
516
  }
442
517
 
518
+ /* What an agent asks permission for, in the plain form a host's `autoAllow` judges it by. */
519
+ export interface AcpPermissionRequest {
520
+ // The ACP tool kind (`execute`, `edit`, `fetch`, ...), or null when the agent names none.
521
+ kind: string | null;
522
+ title: string | null;
523
+ // Every text the call carries that may be what it runs: the command in its raw input and each text in its
524
+ // content. The agent decides which one it runs, so a host allows the call only when every one passes.
525
+ commands: string[];
526
+ }
527
+
443
528
  /* A permission request as the backend acts on it: the event it raises, and the tool kind a runtime mode may answer it by. */
444
529
  export interface AcpPermission {
445
530
  requestId: string;
446
531
  rpcId: number | string;
447
532
  kind: string | null;
533
+ request: AcpPermissionRequest;
448
534
  events: BackendEvent[];
449
535
  }
450
536
 
@@ -562,7 +648,9 @@ export class AcpProtocol {
562
648
  description: tool.title === '' ? null : tool.title,
563
649
  canAllowAlways: options.some((option) => option.kind === 'allow_always')
564
650
  });
565
- return { requestId, rpcId, kind: tool.kind, events };
651
+ const command = rawField(tool.rawInput, 'command', 'cmd');
652
+ const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
653
+ return { requestId, rpcId, kind: tool.kind, request: asked, events };
566
654
  }
567
655
 
568
656
  /*
package/src/chat/index.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
- export { type AcpAgentSpec, type AcpClientInfo, createAcpBackend } from './acp-backend.ts';
3
- export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpSessionInfo, AcpModeMap } from './acp-protocol.ts';
2
+ export { type AcpAgentSpec, type AcpClientInfo, type AcpUsageReport, createAcpBackend } from './acp-backend.ts';
3
+ export type {
4
+ AcpAgentInfo,
5
+ AcpModel,
6
+ AcpModelState,
7
+ AcpMode,
8
+ AcpModeState,
9
+ AcpPermissionRequest,
10
+ AcpSessionInfo,
11
+ AcpModeMap,
12
+ AcpChoice,
13
+ AcpThoughtLevel,
14
+ AcpTurnUsage
15
+ } from './acp-protocol.ts';
4
16
  export { AttachmentStore } from './attachment-store.ts';
5
17
  export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
6
18
  export { runningInBackground, type BackgroundWork } from './background-work.ts';
@@ -1,7 +1,8 @@
1
- import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
2
- import { createAcpBackend, type AcpAgentSpec } from '../chat/acp-backend.ts';
1
+ import type { AgentKind, ModelOptionDescriptor, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
2
+ import { createAcpBackend, type AcpAgentSpec, type AcpUsageReport } from '../chat/acp-backend.ts';
3
3
  import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
4
4
  import { askAcpAccount } from './accounts/acp-account.ts';
5
+ import { writeAcpUsage } from './acp-usage-log.ts';
5
6
  import type { ModelCatalog } from './catalog.ts';
6
7
  import { detectCli, type CliDetection } from './detect.ts';
7
8
  import type { ChatProvider, ProviderHome } from './provider.ts';
@@ -53,21 +54,55 @@ export interface AcpProviderSpec extends AcpAgentSpec {
53
54
  accountProbe?: boolean;
54
55
  // Absent or true lets the models a session reports replace the catalog's; false keeps the catalog as the host built it.
55
56
  adoptModels?: boolean;
57
+ // The folder the usage of every turn the agent reports is logged to, for a usage root of the agent's kind; absent logs nothing.
58
+ usageLog?: string;
56
59
  }
57
60
 
61
+ /* Agents whose own usage files the scanner reads; a log of their turns would count every one twice. */
62
+ const OWN_USAGE_FILES: ReadonlySet<AgentKind> = new Set(['grok']);
63
+
58
64
  /* An agent's model id may reach a command line, so one that reads as a flag is left out. */
59
65
  function adoptableModels(info: AcpSessionInfo): Array<{ slug: string; name: string }> {
60
66
  return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
61
67
  }
62
68
 
69
+ /* The reasoning effort a session offers, as the `effort` option the composer draws for every provider. */
70
+ function effortOption(info: AcpSessionInfo): ModelOptionDescriptor[] {
71
+ const level = info.thoughtLevel;
72
+ if (level === null || !level.choices.some((choice) => choice.id === level.current)) {
73
+ return [];
74
+ }
75
+ return [
76
+ {
77
+ id: 'effort',
78
+ label: 'Reasoning',
79
+ type: 'select',
80
+ defaultChoice: level.current,
81
+ choices: level.choices.map((choice) => ({
82
+ id: choice.id,
83
+ label: choice.name,
84
+ ...(choice.description === null ? {} : { description: choice.description })
85
+ }))
86
+ }
87
+ ];
88
+ }
89
+
63
90
  export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
64
91
  const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
65
92
  const onSession = (info: AcpSessionInfo): void => {
66
93
  if (spec.adoptModels !== false && info.models !== null) {
67
- spec.catalog.adopt(adoptableModels(info), info.models.current);
94
+ spec.catalog.adopt(adoptableModels(info), info.models.current, effortOption(info));
68
95
  }
69
96
  spec.onSession?.(info);
70
97
  };
98
+ const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
99
+ const onUsage =
100
+ usageLog === null
101
+ ? spec.onUsage
102
+ : (report: AcpUsageReport): void => {
103
+ writeAcpUsage(usageLog, report, Date.now());
104
+ spec.onUsage?.(report);
105
+ };
71
106
  return {
72
107
  kind: spec.kind,
73
108
  name: spec.name,
@@ -99,6 +134,6 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
99
134
  onSession
100
135
  })
101
136
  }),
102
- createBackend: createAcpBackend({ ...spec, onSession })
137
+ createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
103
138
  };
104
139
  }
@@ -0,0 +1,26 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { AcpUsageReport } from '../chat/acp-backend.ts';
4
+ import { errorText } from '../error-text.ts';
5
+ import { hostUsageLine } from '../usage/readers/host-log.ts';
6
+
7
+ /*
8
+ * Appends one turn to the session's own file in the folder, so a scan that hashes what it already read
9
+ * of a file never rereads the log of every other session. Written at once, since it is one line per turn.
10
+ */
11
+ export function writeAcpUsage(folder: string, report: AcpUsageReport, at: number): void {
12
+ const { sessionId, cwd, model, ...tokens } = report;
13
+ try {
14
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
15
+ appendFileSync(
16
+ join(folder, `${encodeURIComponent(sessionId === '' ? 'session' : sessionId)}.jsonl`),
17
+ hostUsageLine({ at, model, session: sessionId, cwd, ...tokens }),
18
+ {
19
+ mode: 0o600
20
+ }
21
+ );
22
+ } catch (e) {
23
+ // A turn whose line could not be written is missing from the usage page; the chat itself goes on.
24
+ console.warn('Could not write the usage of an ACP turn:', errorText(e));
25
+ }
26
+ }
@@ -1,4 +1,4 @@
1
- import type { ModelCatalogData, ModelCatalogProfile, ModelInfo, ModelSelection } from '@adecore/agent-contracts';
1
+ import type { ModelCatalogData, ModelCatalogProfile, ModelInfo, ModelOptionDescriptor, ModelSelection } from '@adecore/agent-contracts';
2
2
  import manifest from './claude-models.json' with { type: 'json' };
3
3
 
4
4
  // What a host builds a catalog from; one that is never replaced needs no date.
@@ -41,13 +41,16 @@ export class ModelCatalog {
41
41
  /*
42
42
  * Takes the models an agent reported for itself in place of the list, the agent's current model as the
43
43
  * default and its order kept; answers whether the models changed. Every model gets the profile the
44
- * default has now, as an agent names its models but not what each can be asked for.
44
+ * default has now, as an agent names its models but not what each can be asked for, with `options`
45
+ * in place of the profile's options of the same id.
45
46
  */
46
- adopt(models: ReadonlyArray<{ slug: string; name: string }>, defaultModel: string): boolean {
47
+ adopt(models: ReadonlyArray<{ slug: string; name: string }>, defaultModel: string, options: readonly ModelOptionDescriptor[] = []): boolean {
47
48
  if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
48
49
  return false;
49
50
  }
50
- const profile = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
51
+ const base = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
52
+ const replaced = new Set(options.map((option) => option.id));
53
+ const profile = { ...base, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
51
54
  const data: ModelCatalogInput = {
52
55
  ...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
53
56
  defaultModel,
@@ -13,5 +13,6 @@ export { type CodexRules, codexRulesText, codexRulesPathIn, defaultCodexHome, in
13
13
  export { codexThreadOptions, codexServiceTier } from './codex.ts';
14
14
  export { type CliDetection, detectCli } from './detect.ts';
15
15
  export type { ChatProvider, ProviderHome } from './provider.ts';
16
+ export { isOnlyCommands } from './shell-commands.ts';
16
17
  export { ProviderRegistry, type ProviderRegistryOptions } from './registry.ts';
17
18
  export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.ts';
@@ -0,0 +1,122 @@
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
+ /*
6
+ * Whether a shell command line is only simple commands, joined by `&&`, `||`, `;` or newlines, each of
7
+ * which starts with one of `allowed`. Anything the tokenizer cannot prove simple answers false, so a
8
+ * true is safe to act on without a person: a pipe, a redirect, an expansion, a subshell, a background
9
+ * `&`, an unquoted glob or an unbalanced quote all refuse the whole line.
10
+ */
11
+ export function isOnlyCommands(command: string, allowed: readonly string[]): boolean {
12
+ let word = '';
13
+ let inWord = false;
14
+ let words: string[] = [];
15
+ let commands = 0;
16
+ // A separator that still needs a command after it; a trailing `;` or newline needs none.
17
+ let dangling = false;
18
+
19
+ const endWord = (): void => {
20
+ if (inWord) {
21
+ words.push(word);
22
+ }
23
+ word = '';
24
+ inWord = false;
25
+ };
26
+
27
+ const endCommand = (separator: string): boolean => {
28
+ endWord();
29
+ if (words.length === 0) {
30
+ // Blank lines are fine, and so is a newline after `&&` or `||`; an empty command before any other separator is not.
31
+ return separator === '\n';
32
+ }
33
+ if (!allowed.includes(words[0]!)) {
34
+ return false;
35
+ }
36
+ commands++;
37
+ words = [];
38
+ dangling = separator === '&&' || separator === '||';
39
+ return true;
40
+ };
41
+
42
+ for (let i = 0; i < command.length; i++) {
43
+ const char = command[i]!;
44
+ const next = command[i + 1];
45
+ if (char === ' ' || char === '\t') {
46
+ endWord();
47
+ continue;
48
+ }
49
+ if (char === '\n' || char === ';') {
50
+ if (!endCommand(char)) {
51
+ return false;
52
+ }
53
+ continue;
54
+ }
55
+ if ((char === '&' || char === '|') && next === char) {
56
+ if (!endCommand(char + next)) {
57
+ return false;
58
+ }
59
+ i++;
60
+ continue;
61
+ }
62
+ if (char === '&' || REFUSED.has(char)) {
63
+ return false;
64
+ }
65
+ if (char === '\\') {
66
+ if (next === undefined) {
67
+ return false;
68
+ }
69
+ i++;
70
+ if (next !== '\n') {
71
+ word += next;
72
+ inWord = true;
73
+ }
74
+ continue;
75
+ }
76
+ if (char === "'") {
77
+ const close = command.indexOf("'", i + 1);
78
+ if (close === -1) {
79
+ return false;
80
+ }
81
+ word += command.slice(i + 1, close);
82
+ inWord = true;
83
+ i = close;
84
+ continue;
85
+ }
86
+ if (char === '"') {
87
+ inWord = true;
88
+ let closed = false;
89
+ for (i++; i < command.length; i++) {
90
+ const quoted = command[i]!;
91
+ if (quoted === '"') {
92
+ closed = true;
93
+ break;
94
+ }
95
+ if (quoted === '$' || quoted === '`') {
96
+ return false;
97
+ }
98
+ if (quoted === '\\') {
99
+ const escaped = command[i + 1];
100
+ if (escaped === undefined) {
101
+ return false;
102
+ }
103
+ i++;
104
+ if (escaped === '"' || escaped === '\\' || escaped === '$' || escaped === '`') {
105
+ word += escaped;
106
+ } else if (escaped !== '\n') {
107
+ word += quoted + escaped;
108
+ }
109
+ continue;
110
+ }
111
+ word += quoted;
112
+ }
113
+ if (!closed) {
114
+ return false;
115
+ }
116
+ continue;
117
+ }
118
+ word += char;
119
+ inWord = true;
120
+ }
121
+ return endCommand('\n') && commands > 0 && !dangling;
122
+ }
@@ -22,6 +22,11 @@ export interface FakeAcpOptions {
22
22
  legacyModels?: boolean;
23
23
  // Advertises and answers `session/close`.
24
24
  closeSession?: boolean;
25
+ // The `usage` every `session/prompt` response carries; absent reports none.
26
+ turnUsage?: Record<string, number>;
27
+ // Offers a reasoning effort as the config option `reasoning_effort`, without a category: Low and High, and
28
+ // Extra high on `fake-large`. `reject` refuses every change to it.
29
+ reasoningEffort?: 'accept' | 'reject';
25
30
  }
26
31
 
27
32
  export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
@@ -31,6 +36,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
31
36
  let sessionId = '';
32
37
  let mode = modes[0] ?? '';
33
38
  let model = 'fake-small';
39
+ let effort = 'low';
34
40
  let serverRequestId = 0;
35
41
  let callCounter = 0;
36
42
  let prompt: { rpcId: number | string; cancelled: boolean } | null = null;
@@ -63,7 +69,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
63
69
  return;
64
70
  }
65
71
  update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
66
- respond(prompt.rpcId, { stopReason });
72
+ respond(prompt.rpcId, { stopReason, ...(options.turnUsage === undefined ? {} : { usage: options.turnUsage }) });
67
73
  prompt = null;
68
74
  };
69
75
 
@@ -72,11 +78,25 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
72
78
  { value: 'fake-small', name: 'Fake small' },
73
79
  { value: 'fake-large', name: 'Fake large' }
74
80
  ];
81
+ const effortChoices = [
82
+ { value: 'low', name: 'Low', description: 'Answers quickly' },
83
+ { value: 'high', name: 'High' },
84
+ ...(model === 'fake-large' ? [{ value: 'xhigh', name: 'Extra high' }] : [])
85
+ ];
86
+ const effortOption =
87
+ options.reasoningEffort === undefined
88
+ ? []
89
+ : [{ id: 'reasoning_effort', name: 'Reasoning effort', type: 'select', currentValue: effort, options: effortChoices }];
75
90
  return {
76
91
  ...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
77
92
  ...(options.legacyModels
78
93
  ? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId: choice.value, name: choice.name })) } }
79
- : { configOptions: [{ id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices }] })
94
+ : {
95
+ configOptions: [
96
+ { id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices },
97
+ ...effortOption
98
+ ]
99
+ })
80
100
  };
81
101
  };
82
102
 
@@ -275,6 +295,13 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
275
295
  if (params.configId === 'model') {
276
296
  model = String(params.value);
277
297
  }
298
+ if (params.configId === 'reasoning_effort') {
299
+ if (options.reasoningEffort === 'reject') {
300
+ refuse(id, -32602, 'Reasoning effort is locked');
301
+ return;
302
+ }
303
+ effort = String(params.value);
304
+ }
278
305
  respond(id, { configOptions: sessionState().configOptions ?? [] });
279
306
  return;
280
307
  case 'session/prompt':
@@ -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
  });