@adecore/agents 0.29.2 → 0.30.0

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 (47) hide show
  1. package/dist/chat/acp-backend.d.ts +59 -0
  2. package/dist/chat/acp-backend.js +407 -0
  3. package/dist/chat/acp-protocol.d.ts +131 -0
  4. package/dist/chat/acp-protocol.js +605 -0
  5. package/dist/chat/chat-session.d.ts +2 -0
  6. package/dist/chat/chat-session.js +14 -10
  7. package/dist/chat/codex-transport.d.ts +4 -27
  8. package/dist/chat/codex-transport.js +4 -185
  9. package/dist/chat/fixtures/acp-v1-turn.json +240 -0
  10. package/dist/chat/index.d.ts +2 -0
  11. package/dist/chat/index.js +1 -0
  12. package/dist/chat/json-rpc-transport.d.ts +50 -0
  13. package/dist/chat/json-rpc-transport.js +203 -0
  14. package/dist/providers/accounts/accounts.d.ts +10 -6
  15. package/dist/providers/accounts/accounts.js +19 -19
  16. package/dist/providers/accounts/service.d.ts +3 -0
  17. package/dist/providers/accounts/service.js +20 -13
  18. package/dist/providers/accounts/store.d.ts +4 -5
  19. package/dist/providers/accounts/store.js +12 -12
  20. package/dist/providers/acp-provider.d.ts +22 -0
  21. package/dist/providers/acp-provider.js +45 -0
  22. package/dist/providers/catalog.js +1 -0
  23. package/dist/providers/index.d.ts +1 -0
  24. package/dist/providers/index.js +1 -0
  25. package/dist/providers/registry.d.ts +1 -0
  26. package/dist/providers/registry.js +4 -0
  27. package/dist/testing/fake-acp.d.ts +10 -0
  28. package/dist/testing/fake-acp.js +262 -0
  29. package/dist/testing/index.d.ts +1 -0
  30. package/dist/testing/index.js +1 -0
  31. package/package.json +3 -3
  32. package/src/chat/acp-backend.ts +476 -0
  33. package/src/chat/acp-protocol.ts +763 -0
  34. package/src/chat/chat-session.ts +17 -8
  35. package/src/chat/codex-transport.ts +6 -207
  36. package/src/chat/fixtures/acp-v1-turn.json +240 -0
  37. package/src/chat/index.ts +2 -0
  38. package/src/chat/json-rpc-transport.ts +253 -0
  39. package/src/providers/accounts/accounts.ts +28 -25
  40. package/src/providers/accounts/service.ts +37 -14
  41. package/src/providers/accounts/store.ts +14 -17
  42. package/src/providers/acp-provider.ts +73 -0
  43. package/src/providers/catalog.ts +1 -0
  44. package/src/providers/index.ts +1 -0
  45. package/src/providers/registry.ts +5 -0
  46. package/src/testing/fake-acp.ts +296 -0
  47. package/src/testing/index.ts +1 -0
@@ -0,0 +1,253 @@
1
+ import { errorText } from '../error-text.ts';
2
+ import { ChatChild, MAX_LINE_LENGTH, readStreamLines, type SpawnChatProcess } from './chat-process.ts';
3
+
4
+ export type JsonRpcFrame = Record<string, unknown>;
5
+
6
+ export interface JsonRpcTransportOptions {
7
+ command: string[];
8
+ cwd: string;
9
+ env: Record<string, string>;
10
+ spawn?: SpawnChatProcess;
11
+ // How the agent is named in the errors this transport raises.
12
+ label: string;
13
+ // The `name` of an error the agent answered with, which callers tell apart from a silence or an exit.
14
+ errorName?: string;
15
+ // How long a request may go unanswered; null waits for as long as the process runs.
16
+ requestTimeoutMs: number | null;
17
+ // Whether every frame carries `"jsonrpc": "2.0"`; a protocol that leaves it out reads it as noise.
18
+ versionField?: boolean;
19
+ // Notifications and server requests; responses to our own requests settle their promise instead.
20
+ onFrame(frame: JsonRpcFrame): void;
21
+ onError?(error: Error): void;
22
+ // `stderr` is the tail of what the process wrote there, for an exit with an error code only.
23
+ onExit(exitCode: number | null, stderr: string | null): void;
24
+ }
25
+
26
+ export interface JsonRpcRequestOptions {
27
+ // Overrides the transport's timeout for this request; null waits for as long as the process runs.
28
+ timeoutMs?: number | null;
29
+ }
30
+
31
+ /* An error the other side answered a request with. */
32
+ export class JsonRpcError extends Error {
33
+ readonly code: number | null;
34
+ readonly data: unknown;
35
+
36
+ constructor(name: string, method: string, error: unknown) {
37
+ const detail = typeof error === 'object' && error !== null ? (error as { message?: unknown; code?: unknown; data?: unknown }) : {};
38
+ super(`${method} failed: ${typeof detail.message === 'string' ? detail.message : JSON.stringify(error)}`);
39
+ this.name = name;
40
+ this.code = typeof detail.code === 'number' ? detail.code : null;
41
+ this.data = detail.data;
42
+ }
43
+ }
44
+
45
+ type Settle = { resolve(value: unknown): void; reject(reason: Error): void; method: string; timer: ReturnType<typeof setTimeout> | null };
46
+
47
+ /*
48
+ * One child process and JSON-RPC on its stdio: newline-delimited JSON, a request carries an id and
49
+ * gets a result or an error back under the same id, a notification has no id. The child also sends
50
+ * requests of its own, answered with `respond` or `respondError`.
51
+ */
52
+ export class JsonRpcTransport {
53
+ private readonly child: ChatChild;
54
+ private readonly options: JsonRpcTransportOptions;
55
+ private readonly pending = new Map<number, Settle>();
56
+ private nextId = 1;
57
+ private closed = false;
58
+ // Set once stdin ended or broke; a write after an EPIPE only raises the next one.
59
+ private inputClosed = false;
60
+
61
+ constructor(options: JsonRpcTransportOptions) {
62
+ this.options = options;
63
+ this.child = new ChatChild({
64
+ command: options.command,
65
+ cwd: options.cwd,
66
+ env: options.env,
67
+ ...(options.spawn ? { spawn: options.spawn } : {}),
68
+ onError: (error) => this.broken(error),
69
+ onExit: (exitCode, stderr) => this.handleExit(exitCode, stderr)
70
+ });
71
+ void this.readLines();
72
+ }
73
+
74
+ get pid(): number {
75
+ return this.child.pid;
76
+ }
77
+
78
+ get alive(): boolean {
79
+ return !this.closed;
80
+ }
81
+
82
+ request(method: string, params: unknown, options: JsonRpcRequestOptions = {}): Promise<unknown> {
83
+ const label = this.options.label;
84
+ const timeoutMs = options.timeoutMs === undefined ? this.options.requestTimeoutMs : options.timeoutMs;
85
+ return new Promise((resolve, reject) => {
86
+ if (this.closed) {
87
+ reject(new Error(`${method} failed: ${label} is not running`));
88
+ return;
89
+ }
90
+ const id = this.nextId++;
91
+ let timer: ReturnType<typeof setTimeout> | null = null;
92
+ if (timeoutMs !== null) {
93
+ timer = setTimeout(() => {
94
+ this.pending.delete(id);
95
+ reject(new Error(`${method} failed: ${label} did not answer in ${timeoutMs / 1000} seconds`));
96
+ }, timeoutMs);
97
+ timer.unref?.();
98
+ }
99
+ this.pending.set(id, { resolve, reject, method, timer });
100
+ try {
101
+ this.write({ id, method, params });
102
+ } catch (error) {
103
+ if (timer !== null) {
104
+ clearTimeout(timer);
105
+ }
106
+ this.pending.delete(id);
107
+ reject(error);
108
+ }
109
+ });
110
+ }
111
+
112
+ notify(method: string, params: unknown): void {
113
+ this.write({ method, params });
114
+ }
115
+
116
+ respond(id: number | string, result: unknown): void {
117
+ this.write({ id, result });
118
+ }
119
+
120
+ respondError(id: number | string, code: number, message: string): void {
121
+ this.write({ id, error: { code, message } });
122
+ }
123
+
124
+ /* Closes stdin; the child ends on its own once it has nothing more to say. */
125
+ end(): void {
126
+ this.inputClosed = true;
127
+ try {
128
+ this.child.process.stdin.end();
129
+ } catch {
130
+ // Already closed by the other side.
131
+ }
132
+ }
133
+
134
+ kill(signal: 'SIGTERM' | 'SIGKILL' = 'SIGTERM'): void {
135
+ this.child.process.kill(signal);
136
+ }
137
+
138
+ stop(): void {
139
+ this.end();
140
+ this.child.endAfterGrace();
141
+ }
142
+
143
+ dispose(): Promise<void> {
144
+ return this.child.end();
145
+ }
146
+
147
+ private write(frame: JsonRpcFrame): void {
148
+ if (this.closed || this.inputClosed) {
149
+ throw new Error(`${this.options.label} stdin is closed`);
150
+ }
151
+ try {
152
+ const framed = this.options.versionField ? { jsonrpc: '2.0', ...frame } : frame;
153
+ this.child.process.stdin.write(`${JSON.stringify(framed)}\n`);
154
+ this.child.process.stdin.flush();
155
+ } catch (error) {
156
+ this.inputClosed = true;
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ /* A pipe that failed takes no more writes; a frame a consumer choked on leaves it as it was. */
162
+ private broken(error: unknown): void {
163
+ this.inputClosed = true;
164
+ this.failed(error);
165
+ }
166
+
167
+ private failed(error: unknown): void {
168
+ const failure = new Error(`${this.options.label} transport failed: ${errorText(error)}`);
169
+ this.rejectPending(() => failure);
170
+ try {
171
+ this.options.onError?.(failure);
172
+ } catch (reportError) {
173
+ console.error(`Reporting a ${this.options.label} transport failure failed:`, errorText(reportError));
174
+ }
175
+ }
176
+
177
+ private readLine(line: string): void {
178
+ try {
179
+ this.handleLine(line);
180
+ } catch (error) {
181
+ this.failed(error);
182
+ }
183
+ }
184
+
185
+ private async readLines(): Promise<void> {
186
+ try {
187
+ await readStreamLines(
188
+ this.child.process.stdout,
189
+ (line) => this.readLine(line),
190
+ () =>
191
+ this.failed(
192
+ new Error(`${this.options.label} wrote a frame of more than ${MAX_LINE_LENGTH / 1_000_000} million characters, which was dropped`)
193
+ )
194
+ );
195
+ } catch (error) {
196
+ if (!this.closed) {
197
+ this.broken(error);
198
+ }
199
+ }
200
+ }
201
+
202
+ private handleLine(line: string): void {
203
+ if (this.closed || line.trim() === '') {
204
+ return;
205
+ }
206
+ let frame: unknown;
207
+ try {
208
+ frame = JSON.parse(line);
209
+ } catch {
210
+ return;
211
+ }
212
+ if (typeof frame !== 'object' || frame === null || Array.isArray(frame)) {
213
+ return;
214
+ }
215
+ const message = frame as JsonRpcFrame;
216
+ if (typeof message.method === 'string') {
217
+ this.options.onFrame(message);
218
+ return;
219
+ }
220
+ const settle = typeof message.id === 'number' ? this.pending.get(message.id) : undefined;
221
+ if (!settle) {
222
+ return;
223
+ }
224
+ this.pending.delete(message.id as number);
225
+ if (settle.timer !== null) {
226
+ clearTimeout(settle.timer);
227
+ }
228
+ if (message.error !== undefined) {
229
+ settle.reject(new JsonRpcError(this.options.errorName ?? 'JsonRpcError', settle.method, message.error));
230
+ } else {
231
+ settle.resolve(message.result);
232
+ }
233
+ }
234
+
235
+ private handleExit(exitCode: number | null, stderr: string | null): void {
236
+ if (this.closed) {
237
+ return;
238
+ }
239
+ this.closed = true;
240
+ this.rejectPending((method) => new Error(`${method} failed: ${this.options.label} exited`));
241
+ this.options.onExit(exitCode, stderr);
242
+ }
243
+
244
+ private rejectPending(reason: (method: string) => Error): void {
245
+ for (const settle of this.pending.values()) {
246
+ if (settle.timer !== null) {
247
+ clearTimeout(settle.timer);
248
+ }
249
+ settle.reject(reason(settle.method));
250
+ }
251
+ this.pending.clear();
252
+ }
253
+ }
@@ -1,15 +1,22 @@
1
1
  import { isAbsolute, join, resolve } from 'node:path';
2
- import { AgentKindSchema, ProviderAccountSchema, type AgentKind, type ProviderAccount, type ProviderAccountMap } from '@adecore/agent-contracts';
2
+ import { KNOWN_AGENT_KINDS, ProviderAccountSchema, type AgentKind, type ProviderAccount, type ProviderAccountMap } from '@adecore/agent-contracts';
3
3
  import type { ChatProvider } from '../provider.ts';
4
4
  import { DEFAULT_ACCOUNTS_HOST, variablesProblem, type AccountsHost, type ReservedVariables } from './variables.ts';
5
5
  import { homeFolder } from '../../home-folder.ts';
6
6
 
7
7
  export type Env = Record<string, string | undefined>;
8
8
 
9
- const KNOWN_KINDS: readonly string[] = AgentKindSchema.options;
9
+ /*
10
+ * The CLIs a host keeps accounts for, in catalog order, and the provider of any kind. An account of
11
+ * a kind outside `kinds` is kept as it was written and never judged, for a host that offers it.
12
+ */
13
+ export interface AccountProviders {
14
+ kinds: readonly AgentKind[];
15
+ get(kind: AgentKind): ChatProvider;
16
+ }
10
17
 
11
- export function isKnownKind(kind: string): kind is AgentKind {
12
- return KNOWN_KINDS.includes(kind);
18
+ export function isOfferedKind(kind: string, kinds: readonly AgentKind[]): boolean {
19
+ return kinds.includes(kind);
13
20
  }
14
21
 
15
22
  /* The default account of a CLI is the folder the CLI uses without being told, and its id is the CLI's kind. */
@@ -42,9 +49,10 @@ export function accountProblem(
42
49
  account: ProviderAccount,
43
50
  provider: ChatProvider,
44
51
  env: Env,
45
- reserved: ReservedVariables = { host: DEFAULT_ACCOUNTS_HOST, folderVariables: [] }
52
+ reserved: ReservedVariables = { host: DEFAULT_ACCOUNTS_HOST, folderVariables: [] },
53
+ kinds: readonly AgentKind[] = KNOWN_AGENT_KINDS
46
54
  ): string | null {
47
- if (isKnownKind(id) && id !== account.kind) {
55
+ if (isOfferedKind(id, kinds) && id !== account.kind) {
48
56
  return `"${id}" is the id of the default ${id} account`;
49
57
  }
50
58
  const variables = variablesProblem(account.env ?? [], reserved);
@@ -79,17 +87,17 @@ export function accountProblem(
79
87
  }
80
88
 
81
89
  /* The variables that point a CLI at its folder, which no account may set by hand. */
82
- export function folderVariablesOf(providerOf: (kind: AgentKind) => ChatProvider): string[] {
83
- return AgentKindSchema.options.flatMap((kind) => {
84
- const home = providerOf(kind).home;
90
+ export function folderVariablesOf(providers: AccountProviders): string[] {
91
+ return providers.kinds.flatMap((kind) => {
92
+ const home = providers.get(kind).home;
85
93
  return home === undefined ? [] : [home.env];
86
94
  });
87
95
  }
88
96
 
89
- /* The map with a default account for every CLI this version knows, those first and in catalog order. */
90
- export function withDefaults(accounts: ProviderAccountMap): ProviderAccountMap {
97
+ /* The map with a default account for every CLI the host offers, those first and in catalog order. */
98
+ export function withDefaults(accounts: ProviderAccountMap, kinds: readonly AgentKind[]): ProviderAccountMap {
91
99
  const complete: ProviderAccountMap = {};
92
- for (const kind of KNOWN_KINDS) {
100
+ for (const kind of kinds) {
93
101
  complete[kind] = accounts[kind] ?? { kind };
94
102
  }
95
103
  for (const [id, account] of Object.entries(accounts)) {
@@ -151,15 +159,15 @@ export interface NamedAccount {
151
159
  * Whether a conversation of one account can go on under the other: the same CLI, reading the same
152
160
  * transcripts. Two Codex accounts over one `home` can, shadow home or not; two Claude folders never.
153
161
  */
154
- export function canContinue(from: NamedAccount, to: NamedAccount, providerOf: (kind: AgentKind) => ChatProvider, env: Env): boolean {
162
+ export function canContinue(from: NamedAccount, to: NamedAccount, providers: AccountProviders, env: Env): boolean {
155
163
  if (from.id === to.id) {
156
164
  return true;
157
165
  }
158
166
  const kind = from.account.kind;
159
- if (kind !== to.account.kind || !isKnownKind(kind)) {
167
+ if (kind !== to.account.kind || !isOfferedKind(kind, providers.kinds)) {
160
168
  return false;
161
169
  }
162
- const provider = providerOf(kind);
170
+ const provider = providers.get(kind);
163
171
  return transcriptFolder(from.id, from.account, provider, env) === transcriptFolder(to.id, to.account, provider, env);
164
172
  }
165
173
 
@@ -167,13 +175,7 @@ export function canContinue(from: NamedAccount, to: NamedAccount, providerOf: (k
167
175
  * An account as the file holds it, or null for one to drop. An unknown kind comes back as it was
168
176
  * read, extra fields included; a known kind has to parse and make sense.
169
177
  */
170
- export function readAccount(
171
- id: string,
172
- raw: unknown,
173
- providerOf: (kind: AgentKind) => ChatProvider,
174
- env: Env,
175
- host: AccountsHost = DEFAULT_ACCOUNTS_HOST
176
- ): unknown {
178
+ export function readAccount(id: string, raw: unknown, providers: AccountProviders, env: Env, host: AccountsHost = DEFAULT_ACCOUNTS_HOST): unknown {
177
179
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
178
180
  return null;
179
181
  }
@@ -181,11 +183,12 @@ export function readAccount(
181
183
  if (typeof kind !== 'string' || kind === '') {
182
184
  return null;
183
185
  }
184
- if (!isKnownKind(kind)) {
185
- return isKnownKind(id) ? null : raw;
186
+ if (!isOfferedKind(kind, providers.kinds)) {
187
+ return isOfferedKind(id, providers.kinds) ? null : raw;
186
188
  }
187
189
  const parsed = ProviderAccountSchema.safeParse(raw);
188
- if (!parsed.success || accountProblem(id, parsed.data, providerOf(kind), env, { host, folderVariables: folderVariablesOf(providerOf) }) !== null) {
190
+ const reserved = { host, folderVariables: folderVariablesOf(providers) };
191
+ if (!parsed.success || accountProblem(id, parsed.data, providers.get(kind), env, reserved, providers.kinds) !== null) {
189
192
  return null;
190
193
  }
191
194
  return parsed.data;
@@ -2,7 +2,7 @@ import { existsSync, statSync } from 'node:fs';
2
2
  import { mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import {
5
- AgentKindSchema,
5
+ KNOWN_AGENT_KINDS,
6
6
  type AgentKind,
7
7
  type ProviderAccount,
8
8
  type ProviderAccountCreatePayload,
@@ -18,7 +18,18 @@ import { ClientSinks } from '../../client-sinks.ts';
18
18
  import { errorText } from '../../error-text.ts';
19
19
  import type { AgentEvent, AgentSink } from '../../events.ts';
20
20
  import type { ChatProvider } from '../provider.ts';
21
- import { accountEnv, accountFolder, canContinue, defaultFolder, expandHome, isDefaultAccount, isKnownKind, transcriptFolder, type Env } from './accounts.ts';
21
+ import {
22
+ accountEnv,
23
+ accountFolder,
24
+ canContinue,
25
+ defaultFolder,
26
+ expandHome,
27
+ isDefaultAccount,
28
+ isOfferedKind,
29
+ transcriptFolder,
30
+ type AccountProviders,
31
+ type Env
32
+ } from './accounts.ts';
22
33
  import { AccountError, isDefaultAccountOf, type AccountLaunches } from './launch.ts';
23
34
  import { prepareShadowHome, type ShadowHomeReport } from './shadow-home.ts';
24
35
  import { askAccountAs, type AskAccount } from './status.ts';
@@ -38,7 +49,8 @@ export interface ProviderAccountsOptions {
38
49
  host?: AccountsHost;
39
50
  // How the host names itself to an app-server it asks who is signed in.
40
51
  client?: CodexClientInfo;
41
- providers: { list(): Promise<ProviderInfo[]>; get(kind: AgentKind): ChatProvider; enabled?(kind: AgentKind): boolean };
52
+ // `kinds` are the CLIs that get a default account; absent, every kind this version knows.
53
+ providers: { list(): Promise<ProviderInfo[]>; get(kind: AgentKind): ChatProvider; enabled?(kind: AgentKind): boolean; kinds?(): readonly AgentKind[] };
42
54
  env?: Env;
43
55
  // How to ask a CLI who is signed in; a test answers without starting one.
44
56
  ask?: AskAccount;
@@ -131,7 +143,7 @@ export class ProviderAccountsService implements AccountLaunches {
131
143
  private readonly launches = new Map<string, number>();
132
144
  private readonly states = new Map<string, AccountState>();
133
145
  private stored: StoredAccounts = {};
134
- private accounts: ProviderAccountMap = wireAccounts({});
146
+ private accounts: ProviderAccountMap;
135
147
  private passes: Promise<void> = Promise.resolve();
136
148
  private timer: ReturnType<typeof setInterval> | null = null;
137
149
 
@@ -148,11 +160,12 @@ export class ProviderAccountsService implements AccountLaunches {
148
160
  this.secrets = options.secrets === undefined ? platformSecrets(this.host, options.home) : options.secrets;
149
161
  this.now = options.now ?? Date.now;
150
162
  this.sleep = options.sleep ?? sleep;
163
+ this.accounts = wireAccounts({}, this.kinds());
151
164
  }
152
165
 
153
166
  async load(): Promise<void> {
154
- this.stored = await readAccounts(this.path, (kind) => this.providers.get(kind), this.env, this.host);
155
- this.accounts = wireAccounts(this.stored);
167
+ this.stored = await readAccounts(this.path, this.accountProviders(), this.env, this.host);
168
+ this.accounts = wireAccounts(this.stored, this.kinds());
156
169
  await this.readSecrets();
157
170
  }
158
171
 
@@ -162,7 +175,7 @@ export class ProviderAccountsService implements AccountLaunches {
162
175
 
163
176
  snapshot(): ProviderAccounts {
164
177
  const loginCommands: Partial<Record<AgentKind, string>> = {};
165
- for (const kind of AgentKindSchema.options) {
178
+ for (const kind of this.kinds()) {
166
179
  const command = this.providers.get(kind).home?.loginCommand;
167
180
  if (command !== undefined) {
168
181
  loginCommands[kind] = command;
@@ -206,7 +219,9 @@ export class ProviderAccountsService implements AccountLaunches {
206
219
  throw refuse('no account on it has that id');
207
220
  }
208
221
  if (account.kind !== kind) {
209
- throw refuse(`it is an account of ${isKnownKind(account.kind) ? this.providers.get(account.kind).name : account.kind}, not of ${provider.name}`);
222
+ throw refuse(
223
+ `it is an account of ${isOfferedKind(account.kind, this.kinds()) ? this.providers.get(account.kind).name : account.kind}, not of ${provider.name}`
224
+ );
210
225
  }
211
226
  if (account.enabled === false) {
212
227
  throw refuse('it is turned off');
@@ -241,7 +256,7 @@ export class ProviderAccountsService implements AccountLaunches {
241
256
  if (fromAccount === undefined || toAccount === undefined || fromAccount.kind !== kind) {
242
257
  return false;
243
258
  }
244
- return canContinue({ id: fromId, account: fromAccount }, { id: toId, account: toAccount }, (each) => this.providers.get(each), this.env);
259
+ return canContinue({ id: fromId, account: fromAccount }, { id: toId, account: toAccount }, this.accountProviders(), this.env);
245
260
  }
246
261
 
247
262
  labelOf(id: string): string {
@@ -355,6 +370,14 @@ export class ProviderAccountsService implements AccountLaunches {
355
370
  }
356
371
  }
357
372
 
373
+ private kinds(): readonly AgentKind[] {
374
+ return this.providers.kinds?.() ?? KNOWN_AGENT_KINDS;
375
+ }
376
+
377
+ private accountProviders(): AccountProviders {
378
+ return { kinds: this.kinds(), get: (kind) => this.providers.get(kind) };
379
+ }
380
+
358
381
  /* One change of the file at a time: a create and a save both read what the other just wrote. */
359
382
  private serial<T>(work: () => Promise<T>): Promise<T> {
360
383
  const next = this.writes.then(work);
@@ -363,7 +386,7 @@ export class ProviderAccountsService implements AccountLaunches {
363
386
  }
364
387
 
365
388
  private async commit(accounts: ProviderAccountMap, isDirectory: (path: string) => boolean): Promise<ProviderAccounts> {
366
- const stored = acceptAccounts(accounts, this.stored, (kind) => this.providers.get(kind), this.env, isDirectory, this.host);
389
+ const stored = acceptAccounts(accounts, this.stored, this.accountProviders(), this.env, isDirectory, this.host);
367
390
  const { writes, removals } = this.planSecrets(stored);
368
391
  for (const [key, value] of writes) {
369
392
  await this.requireSecrets().write(key, value);
@@ -371,7 +394,7 @@ export class ProviderAccountsService implements AccountLaunches {
371
394
  }
372
395
  await writeAccounts(this.path, stored);
373
396
  this.stored = stored;
374
- this.accounts = wireAccounts(stored);
397
+ this.accounts = wireAccounts(stored, this.kinds());
375
398
  for (const key of removals) {
376
399
  this.secretValues.delete(key);
377
400
  await this.secrets?.remove(key).catch((e: unknown) => console.error(`Could not remove ${key} from the keychain:`, errorText(e)));
@@ -397,7 +420,7 @@ export class ProviderAccountsService implements AccountLaunches {
397
420
  const kept = new Set<string>();
398
421
  for (const [id, entry] of Object.entries(stored)) {
399
422
  const kind = (entry as { kind: string }).kind;
400
- if (!isKnownKind(kind)) {
423
+ if (!isOfferedKind(kind, this.kinds())) {
401
424
  for (const name of sensitiveNames(entry)) {
402
425
  kept.add(secretKey(id, name));
403
426
  }
@@ -508,7 +531,7 @@ export class ProviderAccountsService implements AccountLaunches {
508
531
  state: ProviderAccountStatus['state'],
509
532
  fields: Partial<Omit<ProviderAccountStatus, 'id' | 'kind' | 'state'>> = {}
510
533
  ): ProviderAccountStatus {
511
- const provider = isKnownKind(account.kind) ? this.providers.get(account.kind) : null;
534
+ const provider = isOfferedKind(account.kind, this.kinds()) ? this.providers.get(account.kind) : null;
512
535
  const folder = provider === null ? '' : accountFolder(id, account, provider, this.env);
513
536
  return {
514
537
  id,
@@ -555,7 +578,7 @@ export class ProviderAccountsService implements AccountLaunches {
555
578
 
556
579
  private async check(id: string, account: ProviderAccount, installed: ProviderInfo[]): Promise<ProviderAccountStatus> {
557
580
  const kind = account.kind;
558
- if (!isKnownKind(kind)) {
581
+ if (!isOfferedKind(kind, this.kinds())) {
559
582
  return this.status(id, account, 'unavailable', { message: `This version of ${this.host.name} does not know this CLI` });
560
583
  }
561
584
  const provider = this.providers.get(kind);
@@ -11,9 +11,10 @@ import {
11
11
  expandHome,
12
12
  folderVariablesOf,
13
13
  isDefaultAccount,
14
- isKnownKind,
14
+ isOfferedKind,
15
15
  readAccount,
16
16
  withDefaults,
17
+ type AccountProviders,
17
18
  type Env
18
19
  } from './accounts.ts';
19
20
  import { DEFAULT_ACCOUNTS_HOST, redactVariables, type AccountsHost } from './variables.ts';
@@ -31,12 +32,7 @@ export function accountsPath(home: string): string {
31
32
  export type StoredAccounts = Record<string, unknown>;
32
33
 
33
34
  /* Every entry that does not parse is dropped on its own; a file that is not JSON at all reads as none. */
34
- export async function readAccounts(
35
- path: string,
36
- providerOf: (kind: AgentKind) => ChatProvider,
37
- env: Env,
38
- host: AccountsHost = DEFAULT_ACCOUNTS_HOST
39
- ): Promise<StoredAccounts> {
35
+ export async function readAccounts(path: string, providers: AccountProviders, env: Env, host: AccountsHost = DEFAULT_ACCOUNTS_HOST): Promise<StoredAccounts> {
40
36
  let parsed: unknown;
41
37
  try {
42
38
  parsed = JSON.parse(await readFile(path, 'utf8'));
@@ -55,7 +51,7 @@ export async function readAccounts(
55
51
  if (!ProviderAccountIdSchema.safeParse(id).success) {
56
52
  continue;
57
53
  }
58
- const account = readAccount(id, raw, providerOf, env, host);
54
+ const account = readAccount(id, raw, providers, env, host);
59
55
  if (account !== null) {
60
56
  stored[id] = account;
61
57
  }
@@ -76,8 +72,8 @@ function wireAccount(entry: unknown): ProviderAccount {
76
72
  return parsed.data.env === undefined ? parsed.data : { ...parsed.data, env: redactVariables(parsed.data.env) };
77
73
  }
78
74
 
79
- export function wireAccounts(stored: StoredAccounts): ProviderAccountMap {
80
- return withDefaults(Object.fromEntries(Object.entries(stored).map(([id, entry]) => [id, wireAccount(entry)])));
75
+ export function wireAccounts(stored: StoredAccounts, kinds: readonly AgentKind[]): ProviderAccountMap {
76
+ return withDefaults(Object.fromEntries(Object.entries(stored).map(([id, entry]) => [id, wireAccount(entry)])), kinds);
81
77
  }
82
78
 
83
79
  export class InvalidAccountError extends CodedError<'invalid-account'> {
@@ -130,16 +126,16 @@ function linkProblem(
130
126
  export function acceptAccounts(
131
127
  saved: ProviderAccountMap,
132
128
  before: StoredAccounts,
133
- providerOf: (kind: AgentKind) => ChatProvider,
129
+ providers: AccountProviders,
134
130
  env: Env,
135
131
  isDirectory: (path: string) => boolean,
136
132
  host: AccountsHost = DEFAULT_ACCOUNTS_HOST
137
133
  ): StoredAccounts {
138
134
  const stored: StoredAccounts = {};
139
- const reserved = { host, folderVariables: folderVariablesOf(providerOf) };
140
- for (const [id, account] of Object.entries(withDefaults(saved))) {
141
- if (!isKnownKind(account.kind)) {
142
- if (isKnownKind(id)) {
135
+ const reserved = { host, folderVariables: folderVariablesOf(providers) };
136
+ for (const [id, account] of Object.entries(withDefaults(saved, providers.kinds))) {
137
+ if (!isOfferedKind(account.kind, providers.kinds)) {
138
+ if (isOfferedKind(id, providers.kinds)) {
143
139
  throw new InvalidAccountError(`"${id}" is the id of the default ${id} account`);
144
140
  }
145
141
  const kept = before[id];
@@ -147,8 +143,9 @@ export function acceptAccounts(
147
143
  stored[id] = keptKind === account.kind ? kept : account;
148
144
  continue;
149
145
  }
150
- const provider = providerOf(account.kind);
151
- const problem = accountProblem(id, account, provider, env, reserved) ?? linkProblem(id, account, before[id], provider, env, isDirectory);
146
+ const provider = providers.get(account.kind);
147
+ const problem =
148
+ accountProblem(id, account, provider, env, reserved, providers.kinds) ?? linkProblem(id, account, before[id], provider, env, isDirectory);
152
149
  if (problem !== null) {
153
150
  throw new InvalidAccountError(`${id}: ${problem}`);
154
151
  }
@@ -0,0 +1,73 @@
1
+ import type { AgentKind, ProviderCapabilities } from '@adecore/agent-contracts';
2
+ import { createAcpBackend, type AcpAgentSpec } from '../chat/acp-backend.ts';
3
+ import type { ModelCatalog } from './catalog.ts';
4
+ import { detectCli, type CliDetection } from './detect.ts';
5
+ import type { ChatProvider, ProviderHome } from './provider.ts';
6
+
7
+ /*
8
+ * What every ACP agent can do through this backend. A file change comes as the text before and after;
9
+ * a message names its mentions and attachments by path, as text the agent opens with its own tools.
10
+ */
11
+ export const ACP_CAPABILITIES: ProviderCapabilities = {
12
+ chat: true,
13
+ terminal: false,
14
+ hooks: false,
15
+ streamsToolOutput: false,
16
+ diffs: 'before-after',
17
+ attachments: true,
18
+ mentions: true,
19
+ denyReason: false,
20
+ allowAlways: true,
21
+ asyncQuestions: false,
22
+ compaction: 'none',
23
+ reportsCost: false,
24
+ reportsContextWindow: false,
25
+ reportsThinking: true,
26
+ slashCommands: true
27
+ };
28
+
29
+ /* One ACP agent as a host offers it: the backend's spec, plus what makes it a provider. */
30
+ export interface AcpProviderSpec extends AcpAgentSpec {
31
+ kind: AgentKind;
32
+ // The executable, and the arguments that start it as an ACP agent on its stdio.
33
+ command: string;
34
+ args?: readonly string[];
35
+ catalog: ModelCatalog;
36
+ // Over `ACP_CAPABILITIES`, for an agent that reports its cost or context window, or whose commands fold its context.
37
+ capabilities?: Partial<ProviderCapabilities>;
38
+ // What a terminal runs to continue one of its sessions; empty where it has none.
39
+ resumeCommand?: string;
40
+ home?: ProviderHome;
41
+ ignoresProjectConfig?: boolean;
42
+ oneShotArgs?(prompt: string): string[];
43
+ oneShotStdinArgs?: readonly string[];
44
+ // Absent leaves the agent to chats only.
45
+ firstPromptArgs?(prompt: string): string[];
46
+ // Absent asks the executable for `--version`.
47
+ detect?(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
48
+ enabled?(): boolean;
49
+ }
50
+
51
+ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
52
+ const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
53
+ return {
54
+ kind: spec.kind,
55
+ name: spec.name,
56
+ catalog: spec.catalog,
57
+ capabilities,
58
+ command: [spec.command, ...(spec.args ?? [])],
59
+ resumeCommand: spec.resumeCommand ?? '',
60
+ ...(spec.ignoresProjectConfig === undefined ? {} : { ignoresProjectConfig: spec.ignoresProjectConfig }),
61
+ ...(spec.home ? { home: spec.home } : {}),
62
+ ...(spec.oneShotArgs ? { oneShotArgs: spec.oneShotArgs } : {}),
63
+ ...(spec.oneShotStdinArgs ? { oneShotStdinArgs: spec.oneShotStdinArgs } : {}),
64
+ ...(spec.enabled ? { enabled: spec.enabled } : {}),
65
+ firstPromptArgs:
66
+ spec.firstPromptArgs ??
67
+ (() => {
68
+ throw new Error(`${spec.name} is only available as a chat.`);
69
+ }),
70
+ detect: spec.detect ?? ((command, env) => detectCli(command, env)),
71
+ createBackend: createAcpBackend(spec)
72
+ };
73
+ }
@@ -41,6 +41,7 @@ export class ModelCatalog {
41
41
  slug: model.slug,
42
42
  name: model.name,
43
43
  badge: model.badge,
44
+ ...(model.vendor === undefined ? {} : { vendor: model.vendor }),
44
45
  legacy: model.legacy === true,
45
46
  isDefault: model.slug === this.data.defaultModel,
46
47
  options: this.profileOf(model.slug)?.options ?? [],