@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
@@ -1,5 +1,5 @@
1
1
  import { type ModelSelection } from '@adecore/agent-contracts';
2
- import { type AcpModeMap, type AcpSessionInfo } from './acp-protocol.ts';
2
+ import { type AcpModeMap, type AcpPermissionRequest, type AcpSessionInfo, type AcpTurnUsage } from './acp-protocol.ts';
3
3
  import type { ApprovalDecision, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
4
4
  export interface AcpClientInfo {
5
5
  name: string;
@@ -16,6 +16,13 @@ export interface AcpAgentSpec {
16
16
  client?: AcpClientInfo;
17
17
  model?(selection: ModelSelection): string | null;
18
18
  onSession?(info: AcpSessionInfo): void;
19
+ onUsage?(usage: AcpUsageReport): void;
20
+ autoAllow?(request: AcpPermissionRequest): boolean;
21
+ }
22
+ export interface AcpUsageReport extends AcpTurnUsage {
23
+ sessionId: string;
24
+ cwd: string;
25
+ model: string;
19
26
  }
20
27
  export declare class AcpBackend implements ChatBackend {
21
28
  private readonly launch;
@@ -29,6 +36,7 @@ export declare class AcpBackend implements ChatBackend {
29
36
  private replaying;
30
37
  private contextPending;
31
38
  private prompt;
39
+ private model;
32
40
  constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec);
33
41
  get running(): boolean;
34
42
  get pid(): number | null;
@@ -52,7 +60,9 @@ export declare class AcpBackend implements ChatBackend {
52
60
  private respond;
53
61
  private fail;
54
62
  private handleFrame;
63
+ private hostAllows;
55
64
  private handleExit;
65
+ private report;
56
66
  private emitAll;
57
67
  private emit;
58
68
  }
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { attachmentImageMime } from '@adecore/agent-contracts';
3
3
  import { errorText } from '../error-text.js';
4
4
  import { MODE_ORDER } from '../modes.js';
5
- import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, resolveAcpMode, runtimeModeOf, sessionIdOf } from './acp-protocol.js';
5
+ import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, AcpProtocol, acpStopWords, autoAllows, parseInitialize, parseSessionInfo, promptUsageOf, resolveAcpMode, runtimeModeOf, sessionIdOf, thoughtLevelOf } from './acp-protocol.js';
6
6
  import { attachmentNote } from './input.js';
7
7
  import { JsonRpcError, JsonRpcTransport } from './json-rpc-transport.js';
8
8
  export const DEFAULT_ACP_CLIENT = { name: 'agents', title: 'Agents', version: '0.1.0' };
@@ -30,6 +30,7 @@ export class AcpBackend {
30
30
  replaying = false;
31
31
  contextPending = null;
32
32
  prompt = null;
33
+ model = null;
33
34
  constructor(launch, host, spec) {
34
35
  this.launch = launch;
35
36
  this.host = host;
@@ -85,8 +86,9 @@ export class AcpBackend {
85
86
  this.capabilities = capabilities;
86
87
  const info = await this.openSession(transport, capabilities);
87
88
  const modeId = await this.applyMode(transport, info);
88
- const model = await this.applyModel(transport, info);
89
- await this.applyThoughtLevel(transport, info);
89
+ const { model, thoughtLevel } = await this.applyModel(transport, info);
90
+ this.model = model;
91
+ await this.applyThoughtLevel(transport, thoughtLevel);
90
92
  this.spec.onSession?.(info);
91
93
  this.emit({
92
94
  type: 'session',
@@ -189,25 +191,29 @@ export class AcpBackend {
189
191
  }
190
192
  return resolved.modeId;
191
193
  }
192
- /* Switches to the selected model where the agent offers it; answers the model the session runs. */
194
+ /*
195
+ * Switches to the selected model where the agent offers it; answers the model the session runs and the
196
+ * reasoning effort it offers there, which may change with the model.
197
+ */
193
198
  async applyModel(transport, info) {
194
199
  const models = info.models;
200
+ const thoughtLevel = info.thoughtLevel;
195
201
  const wanted = this.spec.model ? this.spec.model(this.launch.selection) : this.launch.selection.model;
196
202
  if (models === null || wanted === null || wanted === models.current) {
197
- return models?.current ?? wanted;
203
+ return { model: models?.current ?? wanted, thoughtLevel };
198
204
  }
199
205
  if (!models.models.some((model) => model.id === wanted)) {
200
206
  this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} does not offer ${this.launch.modelName}. It runs ${models.current}.` });
201
- return models.current;
207
+ return { model: models.current, thoughtLevel };
202
208
  }
203
209
  try {
204
210
  if (models.configId === null) {
205
211
  await transport.request('session/set_model', { sessionId: this.sessionId, modelId: wanted });
212
+ return { model: wanted, thoughtLevel };
206
213
  }
207
- else {
208
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
209
- }
210
- return wanted;
214
+ const result = await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
215
+ const after = thoughtLevelOf(result);
216
+ return { model: wanted, thoughtLevel: after === undefined ? thoughtLevel : after };
211
217
  }
212
218
  catch (error) {
213
219
  if (!(error instanceof JsonRpcError)) {
@@ -218,20 +224,26 @@ export class AcpBackend {
218
224
  level: 'warning',
219
225
  text: `${this.spec.name} did not switch to ${this.launch.modelName} (${errorText(error)}). It runs ${models.current}.`
220
226
  });
221
- return models.current;
227
+ return { model: models.current, thoughtLevel };
222
228
  }
223
229
  }
224
- async applyThoughtLevel(transport, info) {
225
- const level = info.thoughtLevel;
230
+ /* Sets the selected effort where the session offers it; a model without one runs as it is. */
231
+ async applyThoughtLevel(transport, level) {
226
232
  const effort = this.launch.selection.options.effort;
227
- if (level === null || typeof effort !== 'string' || effort === level.current || !level.values.includes(effort)) {
233
+ const choice = level?.choices.find((entry) => entry.id === effort);
234
+ if (level === null || choice === undefined || effort === level.current) {
228
235
  return;
229
236
  }
230
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: effort }).catch((error) => {
237
+ try {
238
+ await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: choice.id });
239
+ }
240
+ catch (error) {
231
241
  if (!(error instanceof JsonRpcError)) {
232
242
  throw error;
233
243
  }
234
- });
244
+ const current = level.choices.find((entry) => entry.id === level.current)?.name ?? level.current;
245
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} kept its reasoning at ${current} instead of ${choice.name}.` });
246
+ }
235
247
  }
236
248
  promptBlocks(input) {
237
249
  const sendsImages = this.capabilities?.images === true;
@@ -265,6 +277,7 @@ export class AcpBackend {
265
277
  .then((result) => {
266
278
  if (this.transport === transport && this.prompt === turn) {
267
279
  this.prompt = null;
280
+ this.report(result);
268
281
  this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
269
282
  }
270
283
  })
@@ -373,7 +386,7 @@ export class AcpBackend {
373
386
  }
374
387
  return;
375
388
  }
376
- if (autoAllows(this.launch.runtimeMode, permission.kind)) {
389
+ if (autoAllows(this.launch.runtimeMode, permission.kind) || this.hostAllows(permission.request)) {
377
390
  this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
378
391
  const answer = this.protocol.answer(permission.requestId, 'allow');
379
392
  if (answer) {
@@ -383,6 +396,14 @@ export class AcpBackend {
383
396
  }
384
397
  this.emitAll(permission.events);
385
398
  }
399
+ hostAllows(request) {
400
+ try {
401
+ return this.spec.autoAllow?.(request) === true;
402
+ }
403
+ catch {
404
+ return false;
405
+ }
406
+ }
386
407
  handleExit(transport, exitCode, stderr) {
387
408
  if (this.transport !== transport) {
388
409
  return;
@@ -392,12 +413,23 @@ export class AcpBackend {
392
413
  this.protocol.cancelPermissions();
393
414
  this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
394
415
  }
416
+ /* A cancelled turn still spent what it reports. */
417
+ report(result) {
418
+ const usage = this.spec.onUsage === undefined ? null : promptUsageOf(result);
419
+ if (usage === null) {
420
+ return;
421
+ }
422
+ this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
423
+ }
395
424
  emitAll(events) {
396
425
  for (const event of events) {
397
426
  this.emit(event);
398
427
  }
399
428
  }
400
429
  emit(event) {
430
+ if (event.type === 'model') {
431
+ this.model = event.model;
432
+ }
401
433
  this.host.onEvent(event);
402
434
  }
403
435
  }
@@ -9,6 +9,14 @@ declare const DiffSchema: z.ZodObject<{
9
9
  oldText: z.ZodOptional<z.ZodNullable<z.ZodString>>;
10
10
  newText: z.ZodString;
11
11
  }, z.core.$loose>;
12
+ export interface AcpTurnUsage {
13
+ input: number;
14
+ cacheRead: number;
15
+ cacheWrite: number;
16
+ output: number;
17
+ reasoning: number;
18
+ }
19
+ export declare function promptUsageOf(result: unknown): AcpTurnUsage | null;
12
20
  export interface AcpAgentInfo {
13
21
  name: string;
14
22
  title: string | null;
@@ -44,17 +52,24 @@ export interface AcpModeState {
44
52
  modes: AcpMode[];
45
53
  configId: string | null;
46
54
  }
55
+ export interface AcpChoice {
56
+ id: string;
57
+ name: string;
58
+ description: string | null;
59
+ }
60
+ export interface AcpThoughtLevel {
61
+ configId: string;
62
+ current: string;
63
+ choices: AcpChoice[];
64
+ }
47
65
  export interface AcpSessionInfo {
48
66
  sessionId: string;
49
67
  agent: AcpAgentInfo | null;
50
68
  models: AcpModelState | null;
51
69
  modes: AcpModeState | null;
52
- thoughtLevel: {
53
- configId: string;
54
- current: string;
55
- values: string[];
56
- } | null;
70
+ thoughtLevel: AcpThoughtLevel | null;
57
71
  }
72
+ export declare function thoughtLevelOf(result: unknown): AcpThoughtLevel | null | undefined;
58
73
  export declare function parseSessionInfo(result: unknown, sessionId: string, agent: AcpAgentInfo | null): AcpSessionInfo;
59
74
  export declare function sessionIdOf(result: unknown): string | null;
60
75
  export type AcpModeMap = Partial<Record<RuntimeMode, readonly string[]>>;
@@ -84,10 +99,16 @@ export declare function toolRow(tool: Pick<ToolState, 'title' | 'name' | 'kind'
84
99
  input: Record<string, unknown>;
85
100
  changes?: ChatFileChange[];
86
101
  };
102
+ export interface AcpPermissionRequest {
103
+ kind: string | null;
104
+ title: string | null;
105
+ commands: string[];
106
+ }
87
107
  export interface AcpPermission {
88
108
  requestId: string;
89
109
  rpcId: number | string;
90
110
  kind: string | null;
111
+ request: AcpPermissionRequest;
91
112
  events: BackendEvent[];
92
113
  }
93
114
  export interface AcpStopWords {
@@ -40,12 +40,42 @@ const PermissionRequestSchema = z.looseObject({
40
40
  });
41
41
  const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update: z.looseObject({ sessionUpdate: z.string() }) });
42
42
  const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
43
+ const tokenCount = z.number().int().nonnegative();
44
+ // Unstable in the protocol (`PromptResponse.usage`); only the three totals are required.
45
+ const PromptUsageSchema = z.looseObject({
46
+ totalTokens: tokenCount,
47
+ inputTokens: tokenCount,
48
+ outputTokens: tokenCount,
49
+ thoughtTokens: tokenCount.nullish(),
50
+ cachedReadTokens: tokenCount.nullish(),
51
+ cachedWriteTokens: tokenCount.nullish()
52
+ });
53
+ /*
54
+ * The `usage` a `session/prompt` response carries, or null where the agent reports none. The protocol
55
+ * leaves open whether the input holds the cached part, so the total decides: when the kinds add up
56
+ * to more than it, the input counted the cache too and loses it here.
57
+ */
58
+ export function promptUsageOf(result) {
59
+ const response = z.looseObject({ usage: PromptUsageSchema }).safeParse(result);
60
+ if (!response.success) {
61
+ return null;
62
+ }
63
+ const usage = response.data.usage;
64
+ const cacheRead = usage.cachedReadTokens ?? 0;
65
+ const cacheWrite = usage.cachedWriteTokens ?? 0;
66
+ const apart = usage.inputTokens + usage.outputTokens + cacheRead + cacheWrite <= usage.totalTokens;
67
+ const input = apart ? usage.inputTokens : Math.max(0, usage.inputTokens - cacheRead - cacheWrite);
68
+ if (input + cacheRead + cacheWrite + usage.outputTokens === 0) {
69
+ return null;
70
+ }
71
+ return { input, cacheRead, cacheWrite, output: usage.outputTokens, reasoning: usage.thoughtTokens ?? 0 };
72
+ }
43
73
  const ModeStateSchema = z.looseObject({
44
74
  currentModeId: z.string(),
45
75
  availableModes: z.array(z.unknown())
46
76
  });
47
77
  const ModeSchema = z.looseObject({ id: z.string(), name: z.string() });
48
- const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string() });
78
+ const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish() });
49
79
  const ConfigOptionSchema = z.looseObject({
50
80
  id: z.string(),
51
81
  name: z.string(),
@@ -114,6 +144,18 @@ function selectValues(option) {
114
144
  function selectOption(options, category) {
115
145
  return options.find((option) => option.type === 'select' && option.category === category && typeof option.currentValue === 'string') ?? null;
116
146
  }
147
+ function thoughtOption(options) {
148
+ return (selectOption(options, 'thought_level') ??
149
+ options.find((option) => option.type === 'select' && option.id === 'reasoning_effort' && typeof option.currentValue === 'string') ??
150
+ null);
151
+ }
152
+ /* The reasoning effort in a response that carries the session's config options; undefined when it carries none. */
153
+ export function thoughtLevelOf(result) {
154
+ if (!isRecord(result) || !Array.isArray(result.configOptions)) {
155
+ return undefined;
156
+ }
157
+ return sessionState(validItems(ConfigOptionSchema, result.configOptions), null, null).thoughtLevel;
158
+ }
117
159
  export function parseSessionInfo(result, sessionId, agent) {
118
160
  const record = isRecord(result) ? result : {};
119
161
  const options = validItems(ConfigOptionSchema, Array.isArray(record.configOptions) ? record.configOptions : []);
@@ -122,7 +164,7 @@ export function parseSessionInfo(result, sessionId, agent) {
122
164
  function sessionState(options, modes, models) {
123
165
  const modelOption = selectOption(options, 'model');
124
166
  const modeOption = selectOption(options, 'mode');
125
- const thoughtOption = selectOption(options, 'thought_level');
167
+ const thought = thoughtOption(options);
126
168
  const modeState = ModeStateSchema.safeParse(modes);
127
169
  const modelState = ModelStateSchema.safeParse(models);
128
170
  return {
@@ -156,8 +198,12 @@ function sessionState(options, modes, models) {
156
198
  configId: null
157
199
  }
158
200
  : null,
159
- thoughtLevel: thoughtOption
160
- ? { configId: thoughtOption.id, current: thoughtOption.currentValue, values: selectValues(thoughtOption).map((value) => value.value) }
201
+ thoughtLevel: thought
202
+ ? {
203
+ configId: thought.id,
204
+ current: thought.currentValue,
205
+ choices: selectValues(thought).map((value) => ({ id: value.value, name: value.name, description: value.description ?? null }))
206
+ }
161
207
  : null
162
208
  };
163
209
  }
@@ -416,7 +462,9 @@ export class AcpProtocol {
416
462
  description: tool.title === '' ? null : tool.title,
417
463
  canAllowAlways: options.some((option) => option.kind === 'allow_always')
418
464
  });
419
- return { requestId, rpcId, kind: tool.kind, events };
465
+ const command = rawField(tool.rawInput, 'command', 'cmd');
466
+ const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
467
+ return { requestId, rpcId, kind: tool.kind, request: asked, events };
420
468
  }
421
469
  /*
422
470
  * The answer to a permission request: allow once, reject, or allow always only when the agent offered it.
@@ -1,5 +1,5 @@
1
- export { type AcpAgentSpec, type AcpClientInfo, createAcpBackend } from './acp-backend.ts';
2
- export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpSessionInfo, AcpModeMap } from './acp-protocol.ts';
1
+ export { type AcpAgentSpec, type AcpClientInfo, type AcpUsageReport, createAcpBackend } from './acp-backend.ts';
2
+ export type { AcpAgentInfo, AcpModel, AcpModelState, AcpMode, AcpModeState, AcpPermissionRequest, AcpSessionInfo, AcpModeMap, AcpChoice, AcpThoughtLevel, AcpTurnUsage } from './acp-protocol.ts';
3
3
  export { AttachmentStore } from './attachment-store.ts';
4
4
  export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
5
5
  export { runningInBackground, type BackgroundWork } from './background-work.ts';
@@ -21,5 +21,6 @@ export interface AcpProviderSpec extends AcpAgentSpec {
21
21
  enabled?(): boolean;
22
22
  accountProbe?: boolean;
23
23
  adoptModels?: boolean;
24
+ usageLog?: string;
24
25
  }
25
26
  export declare function createAcpProvider(spec: AcpProviderSpec): ChatProvider;
@@ -1,5 +1,6 @@
1
1
  import { createAcpBackend } from '../chat/acp-backend.js';
2
2
  import { askAcpAccount } from './accounts/acp-account.js';
3
+ import { writeAcpUsage } from './acp-usage-log.js';
3
4
  import { detectCli } from './detect.js';
4
5
  /*
5
6
  * What every ACP agent can do through this backend. A file change comes as the text before and after;
@@ -22,18 +23,47 @@ export const ACP_CAPABILITIES = {
22
23
  reportsThinking: true,
23
24
  slashCommands: true
24
25
  };
26
+ /* Agents whose own usage files the scanner reads; a log of their turns would count every one twice. */
27
+ const OWN_USAGE_FILES = new Set(['grok']);
25
28
  /* An agent's model id may reach a command line, so one that reads as a flag is left out. */
26
29
  function adoptableModels(info) {
27
30
  return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
28
31
  }
32
+ /* The reasoning effort a session offers, as the `effort` option the composer draws for every provider. */
33
+ function effortOption(info) {
34
+ const level = info.thoughtLevel;
35
+ if (level === null || !level.choices.some((choice) => choice.id === level.current)) {
36
+ return [];
37
+ }
38
+ return [
39
+ {
40
+ id: 'effort',
41
+ label: 'Reasoning',
42
+ type: 'select',
43
+ defaultChoice: level.current,
44
+ choices: level.choices.map((choice) => ({
45
+ id: choice.id,
46
+ label: choice.name,
47
+ ...(choice.description === null ? {} : { description: choice.description })
48
+ }))
49
+ }
50
+ ];
51
+ }
29
52
  export function createAcpProvider(spec) {
30
53
  const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
31
54
  const onSession = (info) => {
32
55
  if (spec.adoptModels !== false && info.models !== null) {
33
- spec.catalog.adopt(adoptableModels(info), info.models.current);
56
+ spec.catalog.adopt(adoptableModels(info), info.models.current, effortOption(info));
34
57
  }
35
58
  spec.onSession?.(info);
36
59
  };
60
+ const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
61
+ const onUsage = usageLog === null
62
+ ? spec.onUsage
63
+ : (report) => {
64
+ writeAcpUsage(usageLog, report, Date.now());
65
+ spec.onUsage?.(report);
66
+ };
37
67
  return {
38
68
  kind: spec.kind,
39
69
  name: spec.name,
@@ -63,6 +93,6 @@ export function createAcpProvider(spec) {
63
93
  onSession
64
94
  })
65
95
  }),
66
- createBackend: createAcpBackend({ ...spec, onSession })
96
+ createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
67
97
  };
68
98
  }
@@ -0,0 +1,2 @@
1
+ import type { AcpUsageReport } from '../chat/acp-backend.ts';
2
+ export declare function writeAcpUsage(folder: string, report: AcpUsageReport, at: number): void;
@@ -0,0 +1,21 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { errorText } from '../error-text.js';
4
+ import { hostUsageLine } from '../usage/readers/host-log.js';
5
+ /*
6
+ * Appends one turn to the session's own file in the folder, so a scan that hashes what it already read
7
+ * of a file never rereads the log of every other session. Written at once, since it is one line per turn.
8
+ */
9
+ export function writeAcpUsage(folder, report, at) {
10
+ const { sessionId, cwd, model, ...tokens } = report;
11
+ try {
12
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
13
+ appendFileSync(join(folder, `${encodeURIComponent(sessionId === '' ? 'session' : sessionId)}.jsonl`), hostUsageLine({ at, model, session: sessionId, cwd, ...tokens }), {
14
+ mode: 0o600
15
+ });
16
+ }
17
+ catch (e) {
18
+ // A turn whose line could not be written is missing from the usage page; the chat itself goes on.
19
+ console.warn('Could not write the usage of an ACP turn:', errorText(e));
20
+ }
21
+ }
@@ -1,4 +1,4 @@
1
- import type { ModelCatalogData, ModelInfo, ModelSelection } from '@adecore/agent-contracts';
1
+ import type { ModelCatalogData, ModelInfo, ModelOptionDescriptor, ModelSelection } from '@adecore/agent-contracts';
2
2
  export type ModelCatalogInput = Omit<ModelCatalogData, 'updatedAt'> & {
3
3
  updatedAt?: string;
4
4
  };
@@ -11,7 +11,7 @@ export declare class ModelCatalog {
11
11
  adopt(models: ReadonlyArray<{
12
12
  slug: string;
13
13
  name: string;
14
- }>, defaultModel: string): boolean;
14
+ }>, defaultModel: string, options?: readonly ModelOptionDescriptor[]): boolean;
15
15
  list(): ModelInfo[];
16
16
  nameOf(slug: string): string;
17
17
  resolveModel(name: string): string | null;
@@ -30,13 +30,16 @@ export class ModelCatalog {
30
30
  /*
31
31
  * Takes the models an agent reported for itself in place of the list, the agent's current model as the
32
32
  * default and its order kept; answers whether the models changed. Every model gets the profile the
33
- * default has now, as an agent names its models but not what each can be asked for.
33
+ * default has now, as an agent names its models but not what each can be asked for, with `options`
34
+ * in place of the profile's options of the same id.
34
35
  */
35
- adopt(models, defaultModel) {
36
+ adopt(models, defaultModel, options = []) {
36
37
  if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
37
38
  return false;
38
39
  }
39
- const profile = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
40
+ const base = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
41
+ const replaced = new Set(options.map((option) => option.id));
42
+ const profile = { ...base, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
40
43
  const data = {
41
44
  ...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
42
45
  defaultModel,
@@ -12,5 +12,6 @@ export { type CodexRules, codexRulesText, codexRulesPathIn, defaultCodexHome, in
12
12
  export { codexThreadOptions, codexServiceTier } from './codex.ts';
13
13
  export { type CliDetection, detectCli } from './detect.ts';
14
14
  export type { ChatProvider, ProviderHome } from './provider.ts';
15
+ export { isOnlyCommands } from './shell-commands.ts';
15
16
  export { ProviderRegistry, type ProviderRegistryOptions } from './registry.ts';
16
17
  export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.ts';
@@ -10,5 +10,6 @@ export { createCodexProvider } from './codex-provider.js';
10
10
  export { codexRulesText, codexRulesPathIn, defaultCodexHome, installCodexRules } from './codex-rules.js';
11
11
  export { codexThreadOptions, codexServiceTier } from './codex.js';
12
12
  export { detectCli } from './detect.js';
13
+ export { isOnlyCommands } from './shell-commands.js';
13
14
  export { ProviderRegistry } from './registry.js';
14
15
  export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.js';
@@ -0,0 +1 @@
1
+ export declare function isOnlyCommands(command: string, allowed: readonly string[]): boolean;
@@ -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
+ }
@@ -6,6 +6,8 @@ export interface FakeAcpOptions {
6
6
  modes?: readonly string[];
7
7
  legacyModels?: boolean;
8
8
  closeSession?: boolean;
9
+ turnUsage?: Record<string, number>;
10
+ reasoningEffort?: 'accept' | 'reject';
9
11
  }
10
12
  export declare function fakeAcpWith(options: FakeAcpOptions): FakeCli;
11
13
  export declare const fakeAcp: FakeCli;