@adecore/agents 0.31.2 → 0.31.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/chat/acp-backend.d.ts +11 -1
  2. package/dist/chat/acp-backend.js +24 -2
  3. package/dist/chat/acp-protocol.d.ts +14 -0
  4. package/dist/chat/acp-protocol.js +33 -1
  5. package/dist/chat/index.d.ts +2 -2
  6. package/dist/providers/acp-provider.d.ts +1 -0
  7. package/dist/providers/acp-provider.js +11 -1
  8. package/dist/providers/acp-usage-log.d.ts +2 -0
  9. package/dist/providers/acp-usage-log.js +21 -0
  10. package/dist/providers/index.d.ts +1 -0
  11. package/dist/providers/index.js +1 -0
  12. package/dist/providers/shell-commands.d.ts +1 -0
  13. package/dist/providers/shell-commands.js +119 -0
  14. package/dist/testing/fake-acp.d.ts +1 -0
  15. package/dist/testing/fake-acp.js +1 -1
  16. package/dist/usage/aggregate.js +6 -5
  17. package/dist/usage/index-file.js +25 -17
  18. package/dist/usage/limits/monitor.js +5 -0
  19. package/dist/usage/readers/grok.d.ts +3 -0
  20. package/dist/usage/readers/grok.js +85 -0
  21. package/dist/usage/record.d.ts +1 -0
  22. package/dist/usage/record.js +5 -1
  23. package/dist/usage/roots.js +7 -2
  24. package/dist/usage/scanner.d.ts +1 -0
  25. package/dist/usage/scanner.js +39 -5
  26. package/package.json +3 -3
  27. package/src/chat/acp-backend.ts +40 -2
  28. package/src/chat/acp-protocol.ts +56 -1
  29. package/src/chat/index.ts +12 -2
  30. package/src/providers/acp-provider.ts +16 -2
  31. package/src/providers/acp-usage-log.ts +26 -0
  32. package/src/providers/index.ts +1 -0
  33. package/src/providers/shell-commands.ts +122 -0
  34. package/src/testing/fake-acp.ts +3 -1
  35. package/src/usage/aggregate.ts +6 -5
  36. package/src/usage/index-file.ts +27 -18
  37. package/src/usage/limits/monitor.ts +5 -0
  38. package/src/usage/readers/grok.ts +89 -0
  39. package/src/usage/record.ts +7 -1
  40. package/src/usage/roots.ts +7 -2
  41. package/src/usage/scanner.ts +42 -5
@@ -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.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.2",
85
- "@adecore/intelligent-ui": "0.31.2",
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
  }
@@ -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())
@@ -440,11 +482,22 @@ function toolOutput(tool: ToolState): string | null {
440
482
  return null;
441
483
  }
442
484
 
485
+ /* What an agent asks permission for, in the plain form a host's `autoAllow` judges it by. */
486
+ export interface AcpPermissionRequest {
487
+ // The ACP tool kind (`execute`, `edit`, `fetch`, ...), or null when the agent names none.
488
+ kind: string | null;
489
+ title: string | null;
490
+ // Every text the call carries that may be what it runs: the command in its raw input and each text in its
491
+ // content. The agent decides which one it runs, so a host allows the call only when every one passes.
492
+ commands: string[];
493
+ }
494
+
443
495
  /* 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
496
  export interface AcpPermission {
445
497
  requestId: string;
446
498
  rpcId: number | string;
447
499
  kind: string | null;
500
+ request: AcpPermissionRequest;
448
501
  events: BackendEvent[];
449
502
  }
450
503
 
@@ -562,7 +615,9 @@ export class AcpProtocol {
562
615
  description: tool.title === '' ? null : tool.title,
563
616
  canAllowAlways: options.some((option) => option.kind === 'allow_always')
564
617
  });
565
- return { requestId, rpcId, kind: tool.kind, events };
618
+ const command = rawField(tool.rawInput, 'command', 'cmd');
619
+ const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
620
+ return { requestId, rpcId, kind: tool.kind, request: asked, events };
566
621
  }
567
622
 
568
623
  /*
package/src/chat/index.ts CHANGED
@@ -1,6 +1,16 @@
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
+ AcpTurnUsage
13
+ } from './acp-protocol.ts';
4
14
  export { AttachmentStore } from './attachment-store.ts';
5
15
  export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
6
16
  export { runningInBackground, type BackgroundWork } from './background-work.ts';
@@ -1,7 +1,8 @@
1
1
  import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
2
- import { createAcpBackend, type AcpAgentSpec } from '../chat/acp-backend.ts';
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,8 +54,13 @@ 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 }));
@@ -68,6 +74,14 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
68
74
  }
69
75
  spec.onSession?.(info);
70
76
  };
77
+ const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
78
+ const onUsage =
79
+ usageLog === null
80
+ ? spec.onUsage
81
+ : (report: AcpUsageReport): void => {
82
+ writeAcpUsage(usageLog, report, Date.now());
83
+ spec.onUsage?.(report);
84
+ };
71
85
  return {
72
86
  kind: spec.kind,
73
87
  name: spec.name,
@@ -99,6 +113,6 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
99
113
  onSession
100
114
  })
101
115
  }),
102
- createBackend: createAcpBackend({ ...spec, onSession })
116
+ createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
103
117
  };
104
118
  }
@@ -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
+ }
@@ -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,8 @@ 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>;
25
27
  }
26
28
 
27
29
  export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
@@ -63,7 +65,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
63
65
  return;
64
66
  }
65
67
  update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
66
- respond(prompt.rpcId, { stopReason });
68
+ respond(prompt.rpcId, { stopReason, ...(options.turnUsage === undefined ? {} : { usage: options.turnUsage }) });
67
69
  prompt = null;
68
70
  };
69
71
 
@@ -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 !== '') {