@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
@@ -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())
@@ -95,7 +137,8 @@ const InitializeResponseSchema = z.looseObject({
95
137
  sessionCapabilities: z
96
138
  .looseObject({
97
139
  resume: z.unknown().optional(),
98
- additionalDirectories: z.unknown().optional()
140
+ additionalDirectories: z.unknown().optional(),
141
+ close: z.unknown().optional()
99
142
  })
100
143
  .optional()
101
144
  })
@@ -126,6 +169,8 @@ export interface AcpAgentCapabilities {
126
169
  loadSession: boolean;
127
170
  resumeSession: boolean;
128
171
  additionalDirectories: boolean;
172
+ // Whether the agent takes `session/close`, which ends a session it would otherwise keep.
173
+ closeSession: boolean;
129
174
  images: boolean;
130
175
  authMethods: string[];
131
176
  agent: AcpAgentInfo | null;
@@ -144,6 +189,7 @@ export function parseInitialize(result: unknown): AcpAgentCapabilities {
144
189
  loadSession: capabilities?.loadSession === true,
145
190
  resumeSession: sessions?.resume !== undefined && sessions.resume !== null,
146
191
  additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
192
+ closeSession: sessions?.close !== undefined && sessions.close !== null,
147
193
  images: capabilities?.promptCapabilities?.image === true,
148
194
  authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
149
195
  agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
@@ -436,11 +482,22 @@ function toolOutput(tool: ToolState): string | null {
436
482
  return null;
437
483
  }
438
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
+
439
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. */
440
496
  export interface AcpPermission {
441
497
  requestId: string;
442
498
  rpcId: number | string;
443
499
  kind: string | null;
500
+ request: AcpPermissionRequest;
444
501
  events: BackendEvent[];
445
502
  }
446
503
 
@@ -558,7 +615,9 @@ export class AcpProtocol {
558
615
  description: tool.title === '' ? null : tool.title,
559
616
  canAllowAlways: options.some((option) => option.kind === 'allow_always')
560
617
  });
561
- 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 };
562
621
  }
563
622
 
564
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';
@@ -0,0 +1,129 @@
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, type AcpClientInfo } from '../../chat/acp-backend.ts';
5
+ import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, parseInitialize, parseSessionInfo, sessionIdOf, type AcpSessionInfo } from '../../chat/acp-protocol.ts';
6
+ import type { SpawnChatProcess } from '../../chat/chat-process.ts';
7
+ import { JsonRpcError, JsonRpcTransport, type JsonRpcFrame } from '../../chat/json-rpc-transport.ts';
8
+ import { ASK_TIMEOUT_MS, type AccountReading } from './status.ts';
9
+
10
+ /* The clock the probe's deadline runs on; a test hands in one it moves itself. */
11
+ export interface ProbeTimer {
12
+ setTimeout(run: () => void, ms: number): unknown;
13
+ clearTimeout(handle: unknown): void;
14
+ }
15
+
16
+ export interface AcpAccountProbe {
17
+ // The agent as a person reads it, in the error a failed read carries.
18
+ name: string;
19
+ command: readonly string[];
20
+ env: Record<string, string | undefined>;
21
+ client?: AcpClientInfo;
22
+ spawn?: SpawnChatProcess;
23
+ timeoutMs?: number;
24
+ timer?: ProbeTimer;
25
+ // What the probe's session offered, for a host that fills its model catalog from the agent.
26
+ onSession?(info: AcpSessionInfo): void;
27
+ }
28
+
29
+ const SIGNED_OUT: AccountReading = { signedIn: false, email: null, plan: null, organization: null };
30
+ const SIGNED_IN: AccountReading = { signedIn: true, email: null, plan: null, organization: null };
31
+
32
+ const REAL_TIMER: ProbeTimer = {
33
+ setTimeout: (run, ms) => setTimeout(run, ms),
34
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>)
35
+ };
36
+
37
+ function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
38
+ return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined));
39
+ }
40
+
41
+ function withDeadline<T>(work: Promise<T>, ms: number, timer: ProbeTimer, message: string): Promise<T> {
42
+ return new Promise((resolve, reject) => {
43
+ const handle = timer.setTimeout(() => reject(new Error(message)), ms);
44
+ work.then(
45
+ (value) => {
46
+ timer.clearTimeout(handle);
47
+ resolve(value);
48
+ },
49
+ (e: unknown) => {
50
+ timer.clearTimeout(handle);
51
+ reject(e instanceof Error ? e : new Error(String(e)));
52
+ }
53
+ );
54
+ });
55
+ }
56
+
57
+ /*
58
+ * Asks an ACP agent whether the account's environment is signed in: a session it opens means yes, "authentication
59
+ * required" means no. ACP has no request that says who, so a reading carries no email or plan. The session is opened
60
+ * in an empty folder of its own with no MCP servers, and `authenticate` is never sent: a probe must not start a login.
61
+ */
62
+ export async function askAcpAccount(probe: AcpAccountProbe): Promise<AccountReading> {
63
+ const folder = await mkdtemp(join(tmpdir(), 'acp-account-'));
64
+ const client = probe.client ?? DEFAULT_ACP_CLIENT;
65
+ let transport: JsonRpcTransport | null = null;
66
+ try {
67
+ const started = new JsonRpcTransport({
68
+ command: [...probe.command],
69
+ cwd: folder,
70
+ env: definedEnv(probe.env),
71
+ ...(probe.spawn ? { spawn: probe.spawn } : {}),
72
+ label: probe.name,
73
+ errorName: 'AcpRpcError',
74
+ // The probe's own deadline covers every request.
75
+ requestTimeoutMs: null,
76
+ versionField: true,
77
+ onFrame: (frame) => refuseRequest(started, frame),
78
+ onExit: () => undefined
79
+ });
80
+ transport = started;
81
+ const reading = async (): Promise<AccountReading> => {
82
+ const capabilities = parseInitialize(
83
+ await started.request('initialize', {
84
+ protocolVersion: ACP_PROTOCOL_VERSION,
85
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
86
+ clientInfo: { name: client.name, title: client.title ?? client.name, version: client.version }
87
+ })
88
+ );
89
+ if (capabilities.protocolVersion !== ACP_PROTOCOL_VERSION) {
90
+ throw new Error(`${probe.name} speaks ACP version ${capabilities.protocolVersion}; only version ${ACP_PROTOCOL_VERSION} is supported`);
91
+ }
92
+ let result: unknown;
93
+ try {
94
+ result = await started.request('session/new', { cwd: folder, mcpServers: [] });
95
+ } catch (error) {
96
+ if (error instanceof JsonRpcError && error.code === ACP_AUTH_REQUIRED) {
97
+ return SIGNED_OUT;
98
+ }
99
+ throw error;
100
+ }
101
+ const sessionId = sessionIdOf(result);
102
+ if (sessionId === null) {
103
+ throw new Error(`${probe.name} opened a session without an id`);
104
+ }
105
+ probe.onSession?.(parseSessionInfo(result, sessionId, capabilities.agent));
106
+ if (capabilities.closeSession) {
107
+ // The answer is known by now; an agent that cannot close it keeps an empty session, nothing worse.
108
+ await started.request('session/close', { sessionId }).catch(() => undefined);
109
+ }
110
+ return SIGNED_IN;
111
+ };
112
+ return await withDeadline(reading(), probe.timeoutMs ?? ASK_TIMEOUT_MS, probe.timer ?? REAL_TIMER, `${probe.name} did not answer in time`);
113
+ } finally {
114
+ await transport?.dispose();
115
+ await rm(folder, { recursive: true, force: true });
116
+ }
117
+ }
118
+
119
+ /* The probe advertises nothing, so a request from the agent gets a refusal rather than a silence it would wait on. */
120
+ function refuseRequest(transport: JsonRpcTransport, frame: JsonRpcFrame): void {
121
+ if (frame.id === undefined || frame.id === null || !transport.alive) {
122
+ return;
123
+ }
124
+ try {
125
+ transport.respondError(frame.id as number | string, -32601, `Method not found: ${String(frame.method)}`);
126
+ } catch {
127
+ // The agent closed its input; the deadline or the exit settles the probe.
128
+ }
129
+ }
@@ -32,7 +32,7 @@ import {
32
32
  } from './accounts.ts';
33
33
  import { AccountError, isDefaultAccountOf, type AccountLaunches } from './launch.ts';
34
34
  import { prepareShadowHome, type ShadowHomeReport } from './shadow-home.ts';
35
- import { askAccountAs, type AskAccount } from './status.ts';
35
+ import { ASKED_KINDS, askAccountAs, type AskAccount } from './status.ts';
36
36
  import { acceptAccounts, accountsPath, InvalidAccountError, readAccounts, wireAccounts, writeAccounts, type StoredAccounts } from './store.ts';
37
37
  import { DEFAULT_ACCOUNTS_HOST, platformSecrets, secretKey, SecretsUnavailableError, type AccountsHost, type SecretStore } from './variables.ts';
38
38
 
@@ -52,7 +52,7 @@ export interface ProviderAccountsOptions {
52
52
  // `kinds` are the CLIs that get a default account; absent, every kind this version knows.
53
53
  providers: { list(): Promise<ProviderInfo[]>; get(kind: AgentKind): ChatProvider; enabled?(kind: AgentKind): boolean; kinds?(): readonly AgentKind[] };
54
54
  env?: Env;
55
- // How to ask a CLI who is signed in; a test answers without starting one.
55
+ // How to ask a CLI who is signed in, for every kind whose provider has no reader of its own; a test answers without starting one.
56
56
  ask?: AskAccount;
57
57
  prepareShadow?: (home: string, shadow: string) => Promise<ShadowHomeReport>;
58
58
  folderExists?: (path: string) => boolean;
@@ -127,6 +127,8 @@ export class ProviderAccountsService implements AccountLaunches {
127
127
  private readonly providers: ProviderAccountsOptions['providers'];
128
128
  private readonly env: Env;
129
129
  private readonly ask: AskAccount;
130
+ // A host's own `ask` is taken to answer for every kind; the default only for `ASKED_KINDS`.
131
+ private readonly asksEveryKind: boolean;
130
132
  private readonly prepareShadow: NonNullable<ProviderAccountsOptions['prepareShadow']>;
131
133
  private readonly folderExists: NonNullable<ProviderAccountsOptions['folderExists']>;
132
134
  private readonly install: ProviderAccountsOptions['install'];
@@ -154,6 +156,7 @@ export class ProviderAccountsService implements AccountLaunches {
154
156
  this.providers = options.providers;
155
157
  this.env = options.env ?? process.env;
156
158
  this.ask = options.ask ?? askAccountAs(options.client ?? DEFAULT_CODEX_CLIENT);
159
+ this.asksEveryKind = options.ask !== undefined;
157
160
  this.prepareShadow = options.prepareShadow ?? prepareShadowHome;
158
161
  this.folderExists = options.folderExists ?? isFolder;
159
162
  this.install = options.install;
@@ -176,10 +179,15 @@ export class ProviderAccountsService implements AccountLaunches {
176
179
  snapshot(): ProviderAccounts {
177
180
  const loginCommands: Partial<Record<AgentKind, string>> = {};
178
181
  const chatLogins: AgentKind[] = [];
182
+ const silentLogins: AgentKind[] = [];
179
183
  for (const kind of this.kinds()) {
180
- const home = this.providers.get(kind).home;
184
+ const provider = this.providers.get(kind);
185
+ const home = provider.home;
181
186
  if (home?.loginCommand !== undefined) {
182
187
  loginCommands[kind] = home.loginCommand;
188
+ if (provider.askAccount === undefined && !this.asksEveryKind && !ASKED_KINDS.includes(kind)) {
189
+ silentLogins.push(kind);
190
+ }
183
191
  } else if (home !== undefined) {
184
192
  chatLogins.push(kind);
185
193
  }
@@ -189,7 +197,8 @@ export class ProviderAccountsService implements AccountLaunches {
189
197
  statuses: Object.entries(this.accounts).map(([id, account]) => this.statusOf(id, account)),
190
198
  secretsAvailable: this.secrets !== null,
191
199
  loginCommands,
192
- ...(chatLogins.length > 0 ? { chatLogins } : {})
200
+ ...(chatLogins.length > 0 ? { chatLogins } : {}),
201
+ ...(silentLogins.length > 0 ? { silentLogins } : {})
193
202
  };
194
203
  }
195
204
 
@@ -619,7 +628,8 @@ export class ProviderAccountsService implements AccountLaunches {
619
628
  if (provider.home.loginCommand === undefined) {
620
629
  return this.status(id, account, 'ready', { message });
621
630
  }
622
- const reading = await this.ask(kind, provider.command, accountEnv(id, account, provider, this.env, values));
631
+ const env = accountEnv(id, account, provider, this.env, values);
632
+ const reading = await (provider.askAccount ? provider.askAccount(provider.command, env) : this.ask(kind, provider.command, env));
623
633
  return this.status(id, account, reading.signedIn ? 'ready' : 'signed-out', {
624
634
  email: reading.email,
625
635
  plan: reading.plan,
@@ -5,7 +5,7 @@ import { CodexTransport, type CodexClientInfo } from '../../chat/codex-transport
5
5
  import { runProcess } from '../../run-process.ts';
6
6
 
7
7
  /* A CLI that has not said who is signed in by now is not going to; the next pass asks again. */
8
- const ASK_TIMEOUT_MS = 20_000;
8
+ export const ASK_TIMEOUT_MS = 20_000;
9
9
 
10
10
  /* Who a CLI says is signed in, from the CLI itself; the host never opens a credential. */
11
11
  export interface AccountReading {
@@ -89,6 +89,9 @@ async function askCodex(command: readonly string[], env: Record<string, string |
89
89
  }
90
90
  }
91
91
 
92
+ /* The kinds `askAccountAs` can ask; a provider with a reader of its own answers for itself. */
93
+ export const ASKED_KINDS: readonly AgentKind[] = ['claude', 'codex'];
94
+
92
95
  /* Starts the CLI in the account's environment and asks it; only Claude Code and Codex can say. */
93
96
  export function askAccountAs(client: CodexClientInfo): AskAccount {
94
97
  return (kind, command, env) => {
@@ -1,5 +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
+ import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
4
+ import { askAcpAccount } from './accounts/acp-account.ts';
5
+ import { writeAcpUsage } from './acp-usage-log.ts';
3
6
  import type { ModelCatalog } from './catalog.ts';
4
7
  import { detectCli, type CliDetection } from './detect.ts';
5
8
  import type { ChatProvider, ProviderHome } from './provider.ts';
@@ -47,10 +50,38 @@ export interface AcpProviderSpec extends AcpAgentSpec {
47
50
  // Absent asks the executable for `--version`.
48
51
  detect?(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
49
52
  enabled?(): boolean;
53
+ // Absent or true asks the agent over ACP whether an account is signed in; false leaves that to the host.
54
+ accountProbe?: boolean;
55
+ // Absent or true lets the models a session reports replace the catalog's; false keeps the catalog as the host built it.
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;
59
+ }
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
+
64
+ /* An agent's model id may reach a command line, so one that reads as a flag is left out. */
65
+ function adoptableModels(info: AcpSessionInfo): Array<{ slug: string; name: string }> {
66
+ return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
50
67
  }
51
68
 
52
69
  export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
53
70
  const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
71
+ const onSession = (info: AcpSessionInfo): void => {
72
+ if (spec.adoptModels !== false && info.models !== null) {
73
+ spec.catalog.adopt(adoptableModels(info), info.models.current);
74
+ }
75
+ spec.onSession?.(info);
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
+ };
54
85
  return {
55
86
  kind: spec.kind,
56
87
  name: spec.name,
@@ -70,6 +101,18 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
70
101
  throw new Error(`${spec.name} is only available as a chat.`);
71
102
  }),
72
103
  detect: spec.detect ?? ((command, env) => detectCli(command, env)),
73
- createBackend: createAcpBackend(spec)
104
+ ...(spec.accountProbe === false
105
+ ? {}
106
+ : {
107
+ askAccount: (command, env) =>
108
+ askAcpAccount({
109
+ name: spec.name,
110
+ command,
111
+ env: { ...env, ...spec.env },
112
+ ...(spec.client ? { client: spec.client } : {}),
113
+ onSession
114
+ })
115
+ }),
116
+ createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
74
117
  };
75
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
+ }
@@ -4,6 +4,8 @@ import manifest from './claude-models.json' with { type: 'json' };
4
4
  // What a host builds a catalog from; one that is never replaced needs no date.
5
5
  export type ModelCatalogInput = Omit<ModelCatalogData, 'updatedAt'> & { updatedAt?: string };
6
6
 
7
+ const ADOPTED_PROFILE = 'agent';
8
+
7
9
  function timeOf(data: ModelCatalogInput): number {
8
10
  return data.updatedAt === undefined ? 0 : Date.parse(data.updatedAt);
9
11
  }
@@ -36,6 +38,27 @@ export class ModelCatalog {
36
38
  return changed;
37
39
  }
38
40
 
41
+ /*
42
+ * Takes the models an agent reported for itself in place of the list, the agent's current model as the
43
+ * default and its order kept; answers whether the models changed. Every model gets the profile the
44
+ * default has now, as an agent names its models but not what each can be asked for.
45
+ */
46
+ adopt(models: ReadonlyArray<{ slug: string; name: string }>, defaultModel: string): boolean {
47
+ if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
48
+ return false;
49
+ }
50
+ const profile = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
51
+ const data: ModelCatalogInput = {
52
+ ...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
53
+ defaultModel,
54
+ profiles: { [ADOPTED_PROFILE]: profile },
55
+ models: models.map((model) => ({ slug: model.slug, name: model.name, profile: ADOPTED_PROFILE }))
56
+ };
57
+ const changed = JSON.stringify(data) !== JSON.stringify(this.data);
58
+ this.data = data;
59
+ return changed;
60
+ }
61
+
39
62
  list(): ModelInfo[] {
40
63
  return this.data.models.map((model) => ({
41
64
  slug: model.slug,
@@ -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, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
3
+ export { type AcpAccountProbe, askAcpAccount, type ProbeTimer } from './accounts/acp-account.ts';
3
4
  export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
4
5
  export { ProviderAccountsService, type ProviderAccountsOptions } from './accounts/service.ts';
5
6
  export type { ShadowHomeReport } from './accounts/shadow-home.ts';
@@ -12,5 +13,6 @@ export { type CodexRules, codexRulesText, codexRulesPathIn, defaultCodexHome, in
12
13
  export { codexThreadOptions, codexServiceTier } from './codex.ts';
13
14
  export { type CliDetection, detectCli } from './detect.ts';
14
15
  export type { ChatProvider, ProviderHome } from './provider.ts';
16
+ export { isOnlyCommands } from './shell-commands.ts';
15
17
  export { ProviderRegistry, type ProviderRegistryOptions } from './registry.ts';
16
18
  export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.ts';
@@ -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
 
@@ -47,5 +48,7 @@ export interface ChatProvider {
47
48
  detect(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
48
49
  // Whether a person turned this provider on, for one that is off until they do; absent is always on.
49
50
  enabled?(): boolean;
51
+ /* Asks the CLI, started in an account's environment, who is signed in there. Absent leaves the host's own reader. */
52
+ askAccount?(command: readonly string[], env: Record<string, string | undefined>): Promise<AccountReading>;
50
53
  createBackend(launch: BackendLaunch, host: BackendHost): ChatBackend;
51
54
  }
@@ -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
+ }