@adecore/agents 0.31.1 → 0.31.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/chat/acp-backend.d.ts +11 -1
  2. package/dist/chat/acp-backend.js +24 -2
  3. package/dist/chat/acp-protocol.d.ts +15 -0
  4. package/dist/chat/acp-protocol.js +36 -2
  5. package/dist/chat/index.d.ts +2 -2
  6. package/dist/providers/accounts/acp-account.d.ts +19 -0
  7. package/dist/providers/accounts/acp-account.js +101 -0
  8. package/dist/providers/accounts/service.d.ts +1 -0
  9. package/dist/providers/accounts/service.js +14 -4
  10. package/dist/providers/accounts/status.d.ts +2 -0
  11. package/dist/providers/accounts/status.js +3 -1
  12. package/dist/providers/acp-provider.d.ts +3 -0
  13. package/dist/providers/acp-provider.js +33 -1
  14. package/dist/providers/acp-usage-log.d.ts +2 -0
  15. package/dist/providers/acp-usage-log.js +21 -0
  16. package/dist/providers/catalog.d.ts +4 -0
  17. package/dist/providers/catalog.js +21 -0
  18. package/dist/providers/index.d.ts +2 -0
  19. package/dist/providers/index.js +2 -0
  20. package/dist/providers/provider.d.ts +2 -0
  21. package/dist/providers/shell-commands.d.ts +1 -0
  22. package/dist/providers/shell-commands.js +119 -0
  23. package/dist/testing/fake-acp.d.ts +2 -0
  24. package/dist/testing/fake-acp.js +10 -2
  25. package/dist/usage/aggregate.js +6 -5
  26. package/dist/usage/index-file.js +25 -17
  27. package/dist/usage/limits/monitor.js +5 -0
  28. package/dist/usage/readers/grok.d.ts +3 -0
  29. package/dist/usage/readers/grok.js +85 -0
  30. package/dist/usage/record.d.ts +1 -0
  31. package/dist/usage/record.js +5 -1
  32. package/dist/usage/roots.js +7 -2
  33. package/dist/usage/scanner.d.ts +1 -0
  34. package/dist/usage/scanner.js +39 -5
  35. package/package.json +3 -3
  36. package/src/chat/acp-backend.ts +40 -2
  37. package/src/chat/acp-protocol.ts +61 -2
  38. package/src/chat/index.ts +12 -2
  39. package/src/providers/accounts/acp-account.ts +129 -0
  40. package/src/providers/accounts/service.ts +15 -5
  41. package/src/providers/accounts/status.ts +4 -1
  42. package/src/providers/acp-provider.ts +45 -2
  43. package/src/providers/acp-usage-log.ts +26 -0
  44. package/src/providers/catalog.ts +23 -0
  45. package/src/providers/index.ts +2 -0
  46. package/src/providers/provider.ts +3 -0
  47. package/src/providers/shell-commands.ts +122 -0
  48. package/src/testing/fake-acp.ts +14 -2
  49. package/src/usage/aggregate.ts +6 -5
  50. package/src/usage/index-file.ts +27 -18
  51. package/src/usage/limits/monitor.ts +5 -0
  52. package/src/usage/readers/grok.ts +89 -0
  53. package/src/usage/record.ts +7 -1
  54. package/src/usage/roots.ts +7 -2
  55. package/src/usage/scanner.ts +42 -5
@@ -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 } 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;
@@ -86,6 +87,7 @@ export class AcpBackend {
86
87
  const info = await this.openSession(transport, capabilities);
87
88
  const modeId = await this.applyMode(transport, info);
88
89
  const model = await this.applyModel(transport, info);
90
+ this.model = model;
89
91
  await this.applyThoughtLevel(transport, info);
90
92
  this.spec.onSession?.(info);
91
93
  this.emit({
@@ -265,6 +267,7 @@ export class AcpBackend {
265
267
  .then((result) => {
266
268
  if (this.transport === transport && this.prompt === turn) {
267
269
  this.prompt = null;
270
+ this.report(result);
268
271
  this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
269
272
  }
270
273
  })
@@ -373,7 +376,7 @@ export class AcpBackend {
373
376
  }
374
377
  return;
375
378
  }
376
- if (autoAllows(this.launch.runtimeMode, permission.kind)) {
379
+ if (autoAllows(this.launch.runtimeMode, permission.kind) || this.hostAllows(permission.request)) {
377
380
  this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
378
381
  const answer = this.protocol.answer(permission.requestId, 'allow');
379
382
  if (answer) {
@@ -383,6 +386,14 @@ export class AcpBackend {
383
386
  }
384
387
  this.emitAll(permission.events);
385
388
  }
389
+ hostAllows(request) {
390
+ try {
391
+ return this.spec.autoAllow?.(request) === true;
392
+ }
393
+ catch {
394
+ return false;
395
+ }
396
+ }
386
397
  handleExit(transport, exitCode, stderr) {
387
398
  if (this.transport !== transport) {
388
399
  return;
@@ -392,12 +403,23 @@ export class AcpBackend {
392
403
  this.protocol.cancelPermissions();
393
404
  this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
394
405
  }
406
+ /* A cancelled turn still spent what it reports. */
407
+ report(result) {
408
+ const usage = this.spec.onUsage === undefined ? null : promptUsageOf(result);
409
+ if (usage === null) {
410
+ return;
411
+ }
412
+ this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
413
+ }
395
414
  emitAll(events) {
396
415
  for (const event of events) {
397
416
  this.emit(event);
398
417
  }
399
418
  }
400
419
  emit(event) {
420
+ if (event.type === 'model') {
421
+ this.model = event.model;
422
+ }
401
423
  this.host.onEvent(event);
402
424
  }
403
425
  }
@@ -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;
@@ -19,6 +27,7 @@ export interface AcpAgentCapabilities {
19
27
  loadSession: boolean;
20
28
  resumeSession: boolean;
21
29
  additionalDirectories: boolean;
30
+ closeSession: boolean;
22
31
  images: boolean;
23
32
  authMethods: string[];
24
33
  agent: AcpAgentInfo | null;
@@ -83,10 +92,16 @@ export declare function toolRow(tool: Pick<ToolState, 'title' | 'name' | 'kind'
83
92
  input: Record<string, unknown>;
84
93
  changes?: ChatFileChange[];
85
94
  };
95
+ export interface AcpPermissionRequest {
96
+ kind: string | null;
97
+ title: string | null;
98
+ commands: string[];
99
+ }
86
100
  export interface AcpPermission {
87
101
  requestId: string;
88
102
  rpcId: number | string;
89
103
  kind: string | null;
104
+ request: AcpPermissionRequest;
90
105
  events: BackendEvent[];
91
106
  }
92
107
  export interface AcpStopWords {
@@ -40,6 +40,36 @@ 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())
@@ -70,7 +100,8 @@ const InitializeResponseSchema = z.looseObject({
70
100
  sessionCapabilities: z
71
101
  .looseObject({
72
102
  resume: z.unknown().optional(),
73
- additionalDirectories: z.unknown().optional()
103
+ additionalDirectories: z.unknown().optional(),
104
+ close: z.unknown().optional()
74
105
  })
75
106
  .optional()
76
107
  })
@@ -99,6 +130,7 @@ export function parseInitialize(result) {
99
130
  loadSession: capabilities?.loadSession === true,
100
131
  resumeSession: sessions?.resume !== undefined && sessions.resume !== null,
101
132
  additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
133
+ closeSession: sessions?.close !== undefined && sessions.close !== null,
102
134
  images: capabilities?.promptCapabilities?.image === true,
103
135
  authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
104
136
  agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
@@ -414,7 +446,9 @@ export class AcpProtocol {
414
446
  description: tool.title === '' ? null : tool.title,
415
447
  canAllowAlways: options.some((option) => option.kind === 'allow_always')
416
448
  });
417
- return { requestId, rpcId, kind: tool.kind, events };
449
+ const command = rawField(tool.rawInput, 'command', 'cmd');
450
+ const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
451
+ return { requestId, rpcId, kind: tool.kind, request: asked, events };
418
452
  }
419
453
  /*
420
454
  * 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, 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';
@@ -0,0 +1,19 @@
1
+ import { type AcpClientInfo } from '../../chat/acp-backend.ts';
2
+ import { type AcpSessionInfo } from '../../chat/acp-protocol.ts';
3
+ import type { SpawnChatProcess } from '../../chat/chat-process.ts';
4
+ import { type AccountReading } from './status.ts';
5
+ export interface ProbeTimer {
6
+ setTimeout(run: () => void, ms: number): unknown;
7
+ clearTimeout(handle: unknown): void;
8
+ }
9
+ export interface AcpAccountProbe {
10
+ name: string;
11
+ command: readonly string[];
12
+ env: Record<string, string | undefined>;
13
+ client?: AcpClientInfo;
14
+ spawn?: SpawnChatProcess;
15
+ timeoutMs?: number;
16
+ timer?: ProbeTimer;
17
+ onSession?(info: AcpSessionInfo): void;
18
+ }
19
+ export declare function askAcpAccount(probe: AcpAccountProbe): Promise<AccountReading>;
@@ -0,0 +1,101 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { DEFAULT_ACP_CLIENT } from '../../chat/acp-backend.js';
5
+ import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, parseInitialize, parseSessionInfo, sessionIdOf } from '../../chat/acp-protocol.js';
6
+ import { JsonRpcError, JsonRpcTransport } from '../../chat/json-rpc-transport.js';
7
+ import { ASK_TIMEOUT_MS } from './status.js';
8
+ const SIGNED_OUT = { signedIn: false, email: null, plan: null, organization: null };
9
+ const SIGNED_IN = { signedIn: true, email: null, plan: null, organization: null };
10
+ const REAL_TIMER = {
11
+ setTimeout: (run, ms) => setTimeout(run, ms),
12
+ clearTimeout: (handle) => clearTimeout(handle)
13
+ };
14
+ function definedEnv(env) {
15
+ return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== undefined));
16
+ }
17
+ function withDeadline(work, ms, timer, message) {
18
+ return new Promise((resolve, reject) => {
19
+ const handle = timer.setTimeout(() => reject(new Error(message)), ms);
20
+ work.then((value) => {
21
+ timer.clearTimeout(handle);
22
+ resolve(value);
23
+ }, (e) => {
24
+ timer.clearTimeout(handle);
25
+ reject(e instanceof Error ? e : new Error(String(e)));
26
+ });
27
+ });
28
+ }
29
+ /*
30
+ * Asks an ACP agent whether the account's environment is signed in: a session it opens means yes, "authentication
31
+ * required" means no. ACP has no request that says who, so a reading carries no email or plan. The session is opened
32
+ * in an empty folder of its own with no MCP servers, and `authenticate` is never sent: a probe must not start a login.
33
+ */
34
+ export async function askAcpAccount(probe) {
35
+ const folder = await mkdtemp(join(tmpdir(), 'acp-account-'));
36
+ const client = probe.client ?? DEFAULT_ACP_CLIENT;
37
+ let transport = null;
38
+ try {
39
+ const started = new JsonRpcTransport({
40
+ command: [...probe.command],
41
+ cwd: folder,
42
+ env: definedEnv(probe.env),
43
+ ...(probe.spawn ? { spawn: probe.spawn } : {}),
44
+ label: probe.name,
45
+ errorName: 'AcpRpcError',
46
+ // The probe's own deadline covers every request.
47
+ requestTimeoutMs: null,
48
+ versionField: true,
49
+ onFrame: (frame) => refuseRequest(started, frame),
50
+ onExit: () => undefined
51
+ });
52
+ transport = started;
53
+ const reading = async () => {
54
+ const capabilities = parseInitialize(await started.request('initialize', {
55
+ protocolVersion: ACP_PROTOCOL_VERSION,
56
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
57
+ clientInfo: { name: client.name, title: client.title ?? client.name, version: client.version }
58
+ }));
59
+ if (capabilities.protocolVersion !== ACP_PROTOCOL_VERSION) {
60
+ throw new Error(`${probe.name} speaks ACP version ${capabilities.protocolVersion}; only version ${ACP_PROTOCOL_VERSION} is supported`);
61
+ }
62
+ let result;
63
+ try {
64
+ result = await started.request('session/new', { cwd: folder, mcpServers: [] });
65
+ }
66
+ catch (error) {
67
+ if (error instanceof JsonRpcError && error.code === ACP_AUTH_REQUIRED) {
68
+ return SIGNED_OUT;
69
+ }
70
+ throw error;
71
+ }
72
+ const sessionId = sessionIdOf(result);
73
+ if (sessionId === null) {
74
+ throw new Error(`${probe.name} opened a session without an id`);
75
+ }
76
+ probe.onSession?.(parseSessionInfo(result, sessionId, capabilities.agent));
77
+ if (capabilities.closeSession) {
78
+ // The answer is known by now; an agent that cannot close it keeps an empty session, nothing worse.
79
+ await started.request('session/close', { sessionId }).catch(() => undefined);
80
+ }
81
+ return SIGNED_IN;
82
+ };
83
+ return await withDeadline(reading(), probe.timeoutMs ?? ASK_TIMEOUT_MS, probe.timer ?? REAL_TIMER, `${probe.name} did not answer in time`);
84
+ }
85
+ finally {
86
+ await transport?.dispose();
87
+ await rm(folder, { recursive: true, force: true });
88
+ }
89
+ }
90
+ /* The probe advertises nothing, so a request from the agent gets a refusal rather than a silence it would wait on. */
91
+ function refuseRequest(transport, frame) {
92
+ if (frame.id === undefined || frame.id === null || !transport.alive) {
93
+ return;
94
+ }
95
+ try {
96
+ transport.respondError(frame.id, -32601, `Method not found: ${String(frame.method)}`);
97
+ }
98
+ catch {
99
+ // The agent closed its input; the deadline or the exit settles the probe.
100
+ }
101
+ }
@@ -33,6 +33,7 @@ export declare class ProviderAccountsService implements AccountLaunches {
33
33
  private readonly providers;
34
34
  private readonly env;
35
35
  private readonly ask;
36
+ private readonly asksEveryKind;
36
37
  private readonly prepareShadow;
37
38
  private readonly folderExists;
38
39
  private readonly install;
@@ -8,7 +8,7 @@ import { errorText } from '../../error-text.js';
8
8
  import { accountEnv, accountFolder, canContinue, defaultFolder, expandHome, isDefaultAccount, isOfferedKind, transcriptFolder } from './accounts.js';
9
9
  import { AccountError, isDefaultAccountOf } from './launch.js';
10
10
  import { prepareShadowHome } from './shadow-home.js';
11
- import { askAccountAs } from './status.js';
11
+ import { ASKED_KINDS, askAccountAs } from './status.js';
12
12
  import { acceptAccounts, accountsPath, InvalidAccountError, readAccounts, wireAccounts, writeAccounts } from './store.js';
13
13
  import { DEFAULT_ACCOUNTS_HOST, platformSecrets, secretKey, SecretsUnavailableError } from './variables.js';
14
14
  /* Who is signed in changes by hand and rarely; often enough to notice, rarely enough not to start CLIs all day. */
@@ -61,6 +61,8 @@ export class ProviderAccountsService {
61
61
  providers;
62
62
  env;
63
63
  ask;
64
+ // A host's own `ask` is taken to answer for every kind; the default only for `ASKED_KINDS`.
65
+ asksEveryKind;
64
66
  prepareShadow;
65
67
  folderExists;
66
68
  install;
@@ -87,6 +89,7 @@ export class ProviderAccountsService {
87
89
  this.providers = options.providers;
88
90
  this.env = options.env ?? process.env;
89
91
  this.ask = options.ask ?? askAccountAs(options.client ?? DEFAULT_CODEX_CLIENT);
92
+ this.asksEveryKind = options.ask !== undefined;
90
93
  this.prepareShadow = options.prepareShadow ?? prepareShadowHome;
91
94
  this.folderExists = options.folderExists ?? isFolder;
92
95
  this.install = options.install;
@@ -106,10 +109,15 @@ export class ProviderAccountsService {
106
109
  snapshot() {
107
110
  const loginCommands = {};
108
111
  const chatLogins = [];
112
+ const silentLogins = [];
109
113
  for (const kind of this.kinds()) {
110
- const home = this.providers.get(kind).home;
114
+ const provider = this.providers.get(kind);
115
+ const home = provider.home;
111
116
  if (home?.loginCommand !== undefined) {
112
117
  loginCommands[kind] = home.loginCommand;
118
+ if (provider.askAccount === undefined && !this.asksEveryKind && !ASKED_KINDS.includes(kind)) {
119
+ silentLogins.push(kind);
120
+ }
113
121
  }
114
122
  else if (home !== undefined) {
115
123
  chatLogins.push(kind);
@@ -120,7 +128,8 @@ export class ProviderAccountsService {
120
128
  statuses: Object.entries(this.accounts).map(([id, account]) => this.statusOf(id, account)),
121
129
  secretsAvailable: this.secrets !== null,
122
130
  loginCommands,
123
- ...(chatLogins.length > 0 ? { chatLogins } : {})
131
+ ...(chatLogins.length > 0 ? { chatLogins } : {}),
132
+ ...(silentLogins.length > 0 ? { silentLogins } : {})
124
133
  };
125
134
  }
126
135
  /* The account under an id, for a launch; null for an id this machine does not have. */
@@ -510,7 +519,8 @@ export class ProviderAccountsService {
510
519
  if (provider.home.loginCommand === undefined) {
511
520
  return this.status(id, account, 'ready', { message });
512
521
  }
513
- const reading = await this.ask(kind, provider.command, accountEnv(id, account, provider, this.env, values));
522
+ const env = accountEnv(id, account, provider, this.env, values);
523
+ const reading = await (provider.askAccount ? provider.askAccount(provider.command, env) : this.ask(kind, provider.command, env));
514
524
  return this.status(id, account, reading.signedIn ? 'ready' : 'signed-out', {
515
525
  email: reading.email,
516
526
  plan: reading.plan,
@@ -1,5 +1,6 @@
1
1
  import type { AgentKind } from '@adecore/agent-contracts';
2
2
  import { type CodexClientInfo } from '../../chat/codex-transport.ts';
3
+ export declare const ASK_TIMEOUT_MS = 20000;
3
4
  export interface AccountReading {
4
5
  signedIn: boolean;
5
6
  email: string | null;
@@ -9,4 +10,5 @@ export interface AccountReading {
9
10
  export type AskAccount = (kind: AgentKind, command: readonly string[], env: Record<string, string | undefined>) => Promise<AccountReading>;
10
11
  export declare function readClaudeAuthStatus(output: string): AccountReading;
11
12
  export declare function readCodexAccount(answer: unknown): AccountReading;
13
+ export declare const ASKED_KINDS: readonly AgentKind[];
12
14
  export declare function askAccountAs(client: CodexClientInfo): AskAccount;
@@ -3,7 +3,7 @@ import { withTimeout } from '../../async.js';
3
3
  import { CodexTransport } from '../../chat/codex-transport.js';
4
4
  import { runProcess } from '../../run-process.js';
5
5
  /* A CLI that has not said who is signed in by now is not going to; the next pass asks again. */
6
- const ASK_TIMEOUT_MS = 20_000;
6
+ export const ASK_TIMEOUT_MS = 20_000;
7
7
  function text(value) {
8
8
  return typeof value === 'string' && value !== '' ? value : null;
9
9
  }
@@ -69,6 +69,8 @@ async function askCodex(command, env, client) {
69
69
  transport.kill();
70
70
  }
71
71
  }
72
+ /* The kinds `askAccountAs` can ask; a provider with a reader of its own answers for itself. */
73
+ export const ASKED_KINDS = ['claude', 'codex'];
72
74
  /* Starts the CLI in the account's environment and asks it; only Claude Code and Codex can say. */
73
75
  export function askAccountAs(client) {
74
76
  return (kind, command, env) => {
@@ -19,5 +19,8 @@ export interface AcpProviderSpec extends AcpAgentSpec {
19
19
  firstPromptArgs?(prompt: string): string[];
20
20
  detect?(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
21
21
  enabled?(): boolean;
22
+ accountProbe?: boolean;
23
+ adoptModels?: boolean;
24
+ usageLog?: string;
22
25
  }
23
26
  export declare function createAcpProvider(spec: AcpProviderSpec): ChatProvider;
@@ -1,4 +1,6 @@
1
1
  import { createAcpBackend } from '../chat/acp-backend.js';
2
+ import { askAcpAccount } from './accounts/acp-account.js';
3
+ import { writeAcpUsage } from './acp-usage-log.js';
2
4
  import { detectCli } from './detect.js';
3
5
  /*
4
6
  * What every ACP agent can do through this backend. A file change comes as the text before and after;
@@ -21,8 +23,27 @@ export const ACP_CAPABILITIES = {
21
23
  reportsThinking: true,
22
24
  slashCommands: true
23
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']);
28
+ /* An agent's model id may reach a command line, so one that reads as a flag is left out. */
29
+ function adoptableModels(info) {
30
+ return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
31
+ }
24
32
  export function createAcpProvider(spec) {
25
33
  const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
34
+ const onSession = (info) => {
35
+ if (spec.adoptModels !== false && info.models !== null) {
36
+ spec.catalog.adopt(adoptableModels(info), info.models.current);
37
+ }
38
+ spec.onSession?.(info);
39
+ };
40
+ const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
41
+ const onUsage = usageLog === null
42
+ ? spec.onUsage
43
+ : (report) => {
44
+ writeAcpUsage(usageLog, report, Date.now());
45
+ spec.onUsage?.(report);
46
+ };
26
47
  return {
27
48
  kind: spec.kind,
28
49
  name: spec.name,
@@ -41,6 +62,17 @@ export function createAcpProvider(spec) {
41
62
  throw new Error(`${spec.name} is only available as a chat.`);
42
63
  }),
43
64
  detect: spec.detect ?? ((command, env) => detectCli(command, env)),
44
- createBackend: createAcpBackend(spec)
65
+ ...(spec.accountProbe === false
66
+ ? {}
67
+ : {
68
+ askAccount: (command, env) => askAcpAccount({
69
+ name: spec.name,
70
+ command,
71
+ env: { ...env, ...spec.env },
72
+ ...(spec.client ? { client: spec.client } : {}),
73
+ onSession
74
+ })
75
+ }),
76
+ createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
45
77
  };
46
78
  }
@@ -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
+ }
@@ -8,6 +8,10 @@ export declare class ModelCatalog {
8
8
  constructor(data?: ModelCatalogInput);
9
9
  get defaultModel(): string;
10
10
  replace(data: ModelCatalogData): boolean;
11
+ adopt(models: ReadonlyArray<{
12
+ slug: string;
13
+ name: string;
14
+ }>, defaultModel: string): boolean;
11
15
  list(): ModelInfo[];
12
16
  nameOf(slug: string): string;
13
17
  resolveModel(name: string): string | null;
@@ -1,4 +1,5 @@
1
1
  import manifest from './claude-models.json' with { type: 'json' };
2
+ const ADOPTED_PROFILE = 'agent';
2
3
  function timeOf(data) {
3
4
  return data.updatedAt === undefined ? 0 : Date.parse(data.updatedAt);
4
5
  }
@@ -26,6 +27,26 @@ export class ModelCatalog {
26
27
  this.data = data;
27
28
  return changed;
28
29
  }
30
+ /*
31
+ * Takes the models an agent reported for itself in place of the list, the agent's current model as the
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.
34
+ */
35
+ adopt(models, defaultModel) {
36
+ if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
37
+ return false;
38
+ }
39
+ const profile = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
40
+ const data = {
41
+ ...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
42
+ defaultModel,
43
+ profiles: { [ADOPTED_PROFILE]: profile },
44
+ models: models.map((model) => ({ slug: model.slug, name: model.name, profile: ADOPTED_PROFILE }))
45
+ };
46
+ const changed = JSON.stringify(data) !== JSON.stringify(this.data);
47
+ this.data = data;
48
+ return changed;
49
+ }
29
50
  list() {
30
51
  return this.data.models.map((model) => ({
31
52
  slug: model.slug,
@@ -1,4 +1,5 @@
1
1
  export { ACP_CAPABILITIES, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
2
+ export { type AcpAccountProbe, askAcpAccount, type ProbeTimer } from './accounts/acp-account.ts';
2
3
  export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
3
4
  export { ProviderAccountsService, type ProviderAccountsOptions } from './accounts/service.ts';
4
5
  export type { ShadowHomeReport } from './accounts/shadow-home.ts';
@@ -11,5 +12,6 @@ export { type CodexRules, codexRulesText, codexRulesPathIn, defaultCodexHome, in
11
12
  export { codexThreadOptions, codexServiceTier } from './codex.ts';
12
13
  export { type CliDetection, detectCli } from './detect.ts';
13
14
  export type { ChatProvider, ProviderHome } from './provider.ts';
15
+ export { isOnlyCommands } from './shell-commands.ts';
14
16
  export { ProviderRegistry, type ProviderRegistryOptions } from './registry.ts';
15
17
  export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.ts';
@@ -1,5 +1,6 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
2
  export { ACP_CAPABILITIES, createAcpProvider } from './acp-provider.js';
3
+ export { askAcpAccount } from './accounts/acp-account.js';
3
4
  export { AccountError, storedAccount, launchEnv } from './accounts/launch.js';
4
5
  export { ProviderAccountsService } from './accounts/service.js';
5
6
  export { variablesProblem, keychainService } from './accounts/variables.js';
@@ -9,5 +10,6 @@ export { createCodexProvider } from './codex-provider.js';
9
10
  export { codexRulesText, codexRulesPathIn, defaultCodexHome, installCodexRules } from './codex-rules.js';
10
11
  export { codexThreadOptions, codexServiceTier } from './codex.js';
11
12
  export { detectCli } from './detect.js';
13
+ export { isOnlyCommands } from './shell-commands.js';
12
14
  export { ProviderRegistry } from './registry.js';
13
15
  export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.js';
@@ -1,5 +1,6 @@
1
1
  import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
2
2
  import type { BackendHost, BackendLaunch, ChatBackend } from '../chat/backend.ts';
3
+ import type { AccountReading } from './accounts/status.ts';
3
4
  import type { ModelCatalog } from './catalog.ts';
4
5
  import type { CliDetection } from './detect.ts';
5
6
  export interface ProviderHome {
@@ -23,5 +24,6 @@ export interface ChatProvider {
23
24
  firstPromptArgs(prompt: string): string[];
24
25
  detect(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
25
26
  enabled?(): boolean;
27
+ askAccount?(command: readonly string[], env: Record<string, string | undefined>): Promise<AccountReading>;
26
28
  createBackend(launch: BackendLaunch, host: BackendHost): ChatBackend;
27
29
  }
@@ -0,0 +1 @@
1
+ export declare function isOnlyCommands(command: string, allowed: readonly string[]): boolean;