@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,203 @@
1
+ import { errorText } from '../error-text.js';
2
+ import { ChatChild, MAX_LINE_LENGTH, readStreamLines } from './chat-process.js';
3
+ /* An error the other side answered a request with. */
4
+ export class JsonRpcError extends Error {
5
+ code;
6
+ data;
7
+ constructor(name, method, error) {
8
+ const detail = typeof error === 'object' && error !== null ? error : {};
9
+ super(`${method} failed: ${typeof detail.message === 'string' ? detail.message : JSON.stringify(error)}`);
10
+ this.name = name;
11
+ this.code = typeof detail.code === 'number' ? detail.code : null;
12
+ this.data = detail.data;
13
+ }
14
+ }
15
+ /*
16
+ * One child process and JSON-RPC on its stdio: newline-delimited JSON, a request carries an id and
17
+ * gets a result or an error back under the same id, a notification has no id. The child also sends
18
+ * requests of its own, answered with `respond` or `respondError`.
19
+ */
20
+ export class JsonRpcTransport {
21
+ child;
22
+ options;
23
+ pending = new Map();
24
+ nextId = 1;
25
+ closed = false;
26
+ // Set once stdin ended or broke; a write after an EPIPE only raises the next one.
27
+ inputClosed = false;
28
+ constructor(options) {
29
+ this.options = options;
30
+ this.child = new ChatChild({
31
+ command: options.command,
32
+ cwd: options.cwd,
33
+ env: options.env,
34
+ ...(options.spawn ? { spawn: options.spawn } : {}),
35
+ onError: (error) => this.broken(error),
36
+ onExit: (exitCode, stderr) => this.handleExit(exitCode, stderr)
37
+ });
38
+ void this.readLines();
39
+ }
40
+ get pid() {
41
+ return this.child.pid;
42
+ }
43
+ get alive() {
44
+ return !this.closed;
45
+ }
46
+ request(method, params, options = {}) {
47
+ const label = this.options.label;
48
+ const timeoutMs = options.timeoutMs === undefined ? this.options.requestTimeoutMs : options.timeoutMs;
49
+ return new Promise((resolve, reject) => {
50
+ if (this.closed) {
51
+ reject(new Error(`${method} failed: ${label} is not running`));
52
+ return;
53
+ }
54
+ const id = this.nextId++;
55
+ let timer = null;
56
+ if (timeoutMs !== null) {
57
+ timer = setTimeout(() => {
58
+ this.pending.delete(id);
59
+ reject(new Error(`${method} failed: ${label} did not answer in ${timeoutMs / 1000} seconds`));
60
+ }, timeoutMs);
61
+ timer.unref?.();
62
+ }
63
+ this.pending.set(id, { resolve, reject, method, timer });
64
+ try {
65
+ this.write({ id, method, params });
66
+ }
67
+ catch (error) {
68
+ if (timer !== null) {
69
+ clearTimeout(timer);
70
+ }
71
+ this.pending.delete(id);
72
+ reject(error);
73
+ }
74
+ });
75
+ }
76
+ notify(method, params) {
77
+ this.write({ method, params });
78
+ }
79
+ respond(id, result) {
80
+ this.write({ id, result });
81
+ }
82
+ respondError(id, code, message) {
83
+ this.write({ id, error: { code, message } });
84
+ }
85
+ /* Closes stdin; the child ends on its own once it has nothing more to say. */
86
+ end() {
87
+ this.inputClosed = true;
88
+ try {
89
+ this.child.process.stdin.end();
90
+ }
91
+ catch {
92
+ // Already closed by the other side.
93
+ }
94
+ }
95
+ kill(signal = 'SIGTERM') {
96
+ this.child.process.kill(signal);
97
+ }
98
+ stop() {
99
+ this.end();
100
+ this.child.endAfterGrace();
101
+ }
102
+ dispose() {
103
+ return this.child.end();
104
+ }
105
+ write(frame) {
106
+ if (this.closed || this.inputClosed) {
107
+ throw new Error(`${this.options.label} stdin is closed`);
108
+ }
109
+ try {
110
+ const framed = this.options.versionField ? { jsonrpc: '2.0', ...frame } : frame;
111
+ this.child.process.stdin.write(`${JSON.stringify(framed)}\n`);
112
+ this.child.process.stdin.flush();
113
+ }
114
+ catch (error) {
115
+ this.inputClosed = true;
116
+ throw error;
117
+ }
118
+ }
119
+ /* A pipe that failed takes no more writes; a frame a consumer choked on leaves it as it was. */
120
+ broken(error) {
121
+ this.inputClosed = true;
122
+ this.failed(error);
123
+ }
124
+ failed(error) {
125
+ const failure = new Error(`${this.options.label} transport failed: ${errorText(error)}`);
126
+ this.rejectPending(() => failure);
127
+ try {
128
+ this.options.onError?.(failure);
129
+ }
130
+ catch (reportError) {
131
+ console.error(`Reporting a ${this.options.label} transport failure failed:`, errorText(reportError));
132
+ }
133
+ }
134
+ readLine(line) {
135
+ try {
136
+ this.handleLine(line);
137
+ }
138
+ catch (error) {
139
+ this.failed(error);
140
+ }
141
+ }
142
+ async readLines() {
143
+ try {
144
+ await readStreamLines(this.child.process.stdout, (line) => this.readLine(line), () => this.failed(new Error(`${this.options.label} wrote a frame of more than ${MAX_LINE_LENGTH / 1_000_000} million characters, which was dropped`)));
145
+ }
146
+ catch (error) {
147
+ if (!this.closed) {
148
+ this.broken(error);
149
+ }
150
+ }
151
+ }
152
+ handleLine(line) {
153
+ if (this.closed || line.trim() === '') {
154
+ return;
155
+ }
156
+ let frame;
157
+ try {
158
+ frame = JSON.parse(line);
159
+ }
160
+ catch {
161
+ return;
162
+ }
163
+ if (typeof frame !== 'object' || frame === null || Array.isArray(frame)) {
164
+ return;
165
+ }
166
+ const message = frame;
167
+ if (typeof message.method === 'string') {
168
+ this.options.onFrame(message);
169
+ return;
170
+ }
171
+ const settle = typeof message.id === 'number' ? this.pending.get(message.id) : undefined;
172
+ if (!settle) {
173
+ return;
174
+ }
175
+ this.pending.delete(message.id);
176
+ if (settle.timer !== null) {
177
+ clearTimeout(settle.timer);
178
+ }
179
+ if (message.error !== undefined) {
180
+ settle.reject(new JsonRpcError(this.options.errorName ?? 'JsonRpcError', settle.method, message.error));
181
+ }
182
+ else {
183
+ settle.resolve(message.result);
184
+ }
185
+ }
186
+ handleExit(exitCode, stderr) {
187
+ if (this.closed) {
188
+ return;
189
+ }
190
+ this.closed = true;
191
+ this.rejectPending((method) => new Error(`${method} failed: ${this.options.label} exited`));
192
+ this.options.onExit(exitCode, stderr);
193
+ }
194
+ rejectPending(reason) {
195
+ for (const settle of this.pending.values()) {
196
+ if (settle.timer !== null) {
197
+ clearTimeout(settle.timer);
198
+ }
199
+ settle.reject(reason(settle.method));
200
+ }
201
+ this.pending.clear();
202
+ }
203
+ }
@@ -2,12 +2,16 @@ import { type AgentKind, type ProviderAccount, type ProviderAccountMap } from '@
2
2
  import type { ChatProvider } from '../provider.ts';
3
3
  import { type AccountsHost, type ReservedVariables } from './variables.ts';
4
4
  export type Env = Record<string, string | undefined>;
5
- export declare function isKnownKind(kind: string): kind is AgentKind;
5
+ export interface AccountProviders {
6
+ kinds: readonly AgentKind[];
7
+ get(kind: AgentKind): ChatProvider;
8
+ }
9
+ export declare function isOfferedKind(kind: string, kinds: readonly AgentKind[]): boolean;
6
10
  export declare function isDefaultAccount(id: string, account: Pick<ProviderAccount, 'kind'>): boolean;
7
11
  export declare function expandHome(path: string, env: Env): string;
8
- export declare function accountProblem(id: string, account: ProviderAccount, provider: ChatProvider, env: Env, reserved?: ReservedVariables): string | null;
9
- export declare function folderVariablesOf(providerOf: (kind: AgentKind) => ChatProvider): string[];
10
- export declare function withDefaults(accounts: ProviderAccountMap): ProviderAccountMap;
12
+ export declare function accountProblem(id: string, account: ProviderAccount, provider: ChatProvider, env: Env, reserved?: ReservedVariables, kinds?: readonly AgentKind[]): string | null;
13
+ export declare function folderVariablesOf(providers: AccountProviders): string[];
14
+ export declare function withDefaults(accounts: ProviderAccountMap, kinds: readonly AgentKind[]): ProviderAccountMap;
11
15
  export declare function defaultFolder(provider: ChatProvider, env: Env): string;
12
16
  export declare function accountFolder(id: string, account: ProviderAccount, provider: ChatProvider, env: Env): string;
13
17
  export declare function transcriptFolder(id: string, account: ProviderAccount, provider: ChatProvider, env: Env): string;
@@ -16,5 +20,5 @@ export interface NamedAccount {
16
20
  id: string;
17
21
  account: ProviderAccount;
18
22
  }
19
- export declare function canContinue(from: NamedAccount, to: NamedAccount, providerOf: (kind: AgentKind) => ChatProvider, env: Env): boolean;
20
- export declare function readAccount(id: string, raw: unknown, providerOf: (kind: AgentKind) => ChatProvider, env: Env, host?: AccountsHost): unknown;
23
+ export declare function canContinue(from: NamedAccount, to: NamedAccount, providers: AccountProviders, env: Env): boolean;
24
+ export declare function readAccount(id: string, raw: unknown, providers: AccountProviders, env: Env, host?: AccountsHost): unknown;
@@ -1,10 +1,9 @@
1
1
  import { isAbsolute, join, resolve } from 'node:path';
2
- import { AgentKindSchema, ProviderAccountSchema } from '@adecore/agent-contracts';
2
+ import { KNOWN_AGENT_KINDS, ProviderAccountSchema } from '@adecore/agent-contracts';
3
3
  import { DEFAULT_ACCOUNTS_HOST, variablesProblem } from './variables.js';
4
4
  import { homeFolder } from '../../home-folder.js';
5
- const KNOWN_KINDS = AgentKindSchema.options;
6
- export function isKnownKind(kind) {
7
- return KNOWN_KINDS.includes(kind);
5
+ export function isOfferedKind(kind, kinds) {
6
+ return kinds.includes(kind);
8
7
  }
9
8
  /* The default account of a CLI is the folder the CLI uses without being told, and its id is the CLI's kind. */
10
9
  export function isDefaultAccount(id, account) {
@@ -28,8 +27,8 @@ function isRootedPath(path) {
28
27
  * What is wrong with an account of a CLI this version knows, or null when nothing is. An account of
29
28
  * an unknown kind is never judged: it is kept the way it was written, for a version that knows it.
30
29
  */
31
- export function accountProblem(id, account, provider, env, reserved = { host: DEFAULT_ACCOUNTS_HOST, folderVariables: [] }) {
32
- if (isKnownKind(id) && id !== account.kind) {
30
+ export function accountProblem(id, account, provider, env, reserved = { host: DEFAULT_ACCOUNTS_HOST, folderVariables: [] }, kinds = KNOWN_AGENT_KINDS) {
31
+ if (isOfferedKind(id, kinds) && id !== account.kind) {
33
32
  return `"${id}" is the id of the default ${id} account`;
34
33
  }
35
34
  const variables = variablesProblem(account.env ?? [], reserved);
@@ -63,16 +62,16 @@ export function accountProblem(id, account, provider, env, reserved = { host: DE
63
62
  return null;
64
63
  }
65
64
  /* The variables that point a CLI at its folder, which no account may set by hand. */
66
- export function folderVariablesOf(providerOf) {
67
- return AgentKindSchema.options.flatMap((kind) => {
68
- const home = providerOf(kind).home;
65
+ export function folderVariablesOf(providers) {
66
+ return providers.kinds.flatMap((kind) => {
67
+ const home = providers.get(kind).home;
69
68
  return home === undefined ? [] : [home.env];
70
69
  });
71
70
  }
72
- /* The map with a default account for every CLI this version knows, those first and in catalog order. */
73
- export function withDefaults(accounts) {
71
+ /* The map with a default account for every CLI the host offers, those first and in catalog order. */
72
+ export function withDefaults(accounts, kinds) {
74
73
  const complete = {};
75
- for (const kind of KNOWN_KINDS) {
74
+ for (const kind of kinds) {
76
75
  complete[kind] = accounts[kind] ?? { kind };
77
76
  }
78
77
  for (const [id, account] of Object.entries(accounts)) {
@@ -124,22 +123,22 @@ export function accountEnv(id, account, provider, baseEnv, variables = {}) {
124
123
  * Whether a conversation of one account can go on under the other: the same CLI, reading the same
125
124
  * transcripts. Two Codex accounts over one `home` can, shadow home or not; two Claude folders never.
126
125
  */
127
- export function canContinue(from, to, providerOf, env) {
126
+ export function canContinue(from, to, providers, env) {
128
127
  if (from.id === to.id) {
129
128
  return true;
130
129
  }
131
130
  const kind = from.account.kind;
132
- if (kind !== to.account.kind || !isKnownKind(kind)) {
131
+ if (kind !== to.account.kind || !isOfferedKind(kind, providers.kinds)) {
133
132
  return false;
134
133
  }
135
- const provider = providerOf(kind);
134
+ const provider = providers.get(kind);
136
135
  return transcriptFolder(from.id, from.account, provider, env) === transcriptFolder(to.id, to.account, provider, env);
137
136
  }
138
137
  /*
139
138
  * An account as the file holds it, or null for one to drop. An unknown kind comes back as it was
140
139
  * read, extra fields included; a known kind has to parse and make sense.
141
140
  */
142
- export function readAccount(id, raw, providerOf, env, host = DEFAULT_ACCOUNTS_HOST) {
141
+ export function readAccount(id, raw, providers, env, host = DEFAULT_ACCOUNTS_HOST) {
143
142
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
144
143
  return null;
145
144
  }
@@ -147,11 +146,12 @@ export function readAccount(id, raw, providerOf, env, host = DEFAULT_ACCOUNTS_HO
147
146
  if (typeof kind !== 'string' || kind === '') {
148
147
  return null;
149
148
  }
150
- if (!isKnownKind(kind)) {
151
- return isKnownKind(id) ? null : raw;
149
+ if (!isOfferedKind(kind, providers.kinds)) {
150
+ return isOfferedKind(id, providers.kinds) ? null : raw;
152
151
  }
153
152
  const parsed = ProviderAccountSchema.safeParse(raw);
154
- if (!parsed.success || accountProblem(id, parsed.data, providerOf(kind), env, { host, folderVariables: folderVariablesOf(providerOf) }) !== null) {
153
+ const reserved = { host, folderVariables: folderVariablesOf(providers) };
154
+ if (!parsed.success || accountProblem(id, parsed.data, providers.get(kind), env, reserved, providers.kinds) !== null) {
155
155
  return null;
156
156
  }
157
157
  return parsed.data;
@@ -15,6 +15,7 @@ export interface ProviderAccountsOptions {
15
15
  list(): Promise<ProviderInfo[]>;
16
16
  get(kind: AgentKind): ChatProvider;
17
17
  enabled?(kind: AgentKind): boolean;
18
+ kinds?(): readonly AgentKind[];
18
19
  };
19
20
  env?: Env;
20
21
  ask?: AskAccount;
@@ -70,6 +71,8 @@ export declare class ProviderAccountsService implements AccountLaunches {
70
71
  refresh(): Promise<ProviderAccounts>;
71
72
  private folderOf;
72
73
  private runWatch;
74
+ private kinds;
75
+ private accountProviders;
73
76
  private serial;
74
77
  private commit;
75
78
  private planSecrets;
@@ -1,11 +1,11 @@
1
1
  import { existsSync, statSync } from 'node:fs';
2
2
  import { mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { AgentKindSchema } from '@adecore/agent-contracts';
4
+ import { KNOWN_AGENT_KINDS } from '@adecore/agent-contracts';
5
5
  import { DEFAULT_CODEX_CLIENT } from '../../chat/codex-transport.js';
6
6
  import { ClientSinks } from '../../client-sinks.js';
7
7
  import { errorText } from '../../error-text.js';
8
- import { accountEnv, accountFolder, canContinue, defaultFolder, expandHome, isDefaultAccount, isKnownKind, transcriptFolder } from './accounts.js';
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
11
  import { askAccountAs } from './status.js';
@@ -77,7 +77,7 @@ export class ProviderAccountsService {
77
77
  launches = new Map();
78
78
  states = new Map();
79
79
  stored = {};
80
- accounts = wireAccounts({});
80
+ accounts;
81
81
  passes = Promise.resolve();
82
82
  timer = null;
83
83
  constructor(options) {
@@ -93,10 +93,11 @@ export class ProviderAccountsService {
93
93
  this.secrets = options.secrets === undefined ? platformSecrets(this.host, options.home) : options.secrets;
94
94
  this.now = options.now ?? Date.now;
95
95
  this.sleep = options.sleep ?? sleep;
96
+ this.accounts = wireAccounts({}, this.kinds());
96
97
  }
97
98
  async load() {
98
- this.stored = await readAccounts(this.path, (kind) => this.providers.get(kind), this.env, this.host);
99
- this.accounts = wireAccounts(this.stored);
99
+ this.stored = await readAccounts(this.path, this.accountProviders(), this.env, this.host);
100
+ this.accounts = wireAccounts(this.stored, this.kinds());
100
101
  await this.readSecrets();
101
102
  }
102
103
  subscribe(clientId, sink) {
@@ -104,7 +105,7 @@ export class ProviderAccountsService {
104
105
  }
105
106
  snapshot() {
106
107
  const loginCommands = {};
107
- for (const kind of AgentKindSchema.options) {
108
+ for (const kind of this.kinds()) {
108
109
  const command = this.providers.get(kind).home?.loginCommand;
109
110
  if (command !== undefined) {
110
111
  loginCommands[kind] = command;
@@ -145,7 +146,7 @@ export class ProviderAccountsService {
145
146
  throw refuse('no account on it has that id');
146
147
  }
147
148
  if (account.kind !== kind) {
148
- throw refuse(`it is an account of ${isKnownKind(account.kind) ? this.providers.get(account.kind).name : account.kind}, not of ${provider.name}`);
149
+ throw refuse(`it is an account of ${isOfferedKind(account.kind, this.kinds()) ? this.providers.get(account.kind).name : account.kind}, not of ${provider.name}`);
149
150
  }
150
151
  if (account.enabled === false) {
151
152
  throw refuse('it is turned off');
@@ -177,7 +178,7 @@ export class ProviderAccountsService {
177
178
  if (fromAccount === undefined || toAccount === undefined || fromAccount.kind !== kind) {
178
179
  return false;
179
180
  }
180
- return canContinue({ id: fromId, account: fromAccount }, { id: toId, account: toAccount }, (each) => this.providers.get(each), this.env);
181
+ return canContinue({ id: fromId, account: fromAccount }, { id: toId, account: toAccount }, this.accountProviders(), this.env);
181
182
  }
182
183
  labelOf(id) {
183
184
  return this.accounts[id]?.label ?? id;
@@ -278,6 +279,12 @@ export class ProviderAccountsService {
278
279
  }
279
280
  }
280
281
  }
282
+ kinds() {
283
+ return this.providers.kinds?.() ?? KNOWN_AGENT_KINDS;
284
+ }
285
+ accountProviders() {
286
+ return { kinds: this.kinds(), get: (kind) => this.providers.get(kind) };
287
+ }
281
288
  /* One change of the file at a time: a create and a save both read what the other just wrote. */
282
289
  serial(work) {
283
290
  const next = this.writes.then(work);
@@ -285,7 +292,7 @@ export class ProviderAccountsService {
285
292
  return next;
286
293
  }
287
294
  async commit(accounts, isDirectory) {
288
- const stored = acceptAccounts(accounts, this.stored, (kind) => this.providers.get(kind), this.env, isDirectory, this.host);
295
+ const stored = acceptAccounts(accounts, this.stored, this.accountProviders(), this.env, isDirectory, this.host);
289
296
  const { writes, removals } = this.planSecrets(stored);
290
297
  for (const [key, value] of writes) {
291
298
  await this.requireSecrets().write(key, value);
@@ -293,7 +300,7 @@ export class ProviderAccountsService {
293
300
  }
294
301
  await writeAccounts(this.path, stored);
295
302
  this.stored = stored;
296
- this.accounts = wireAccounts(stored);
303
+ this.accounts = wireAccounts(stored, this.kinds());
297
304
  for (const key of removals) {
298
305
  this.secretValues.delete(key);
299
306
  await this.secrets?.remove(key).catch((e) => console.error(`Could not remove ${key} from the keychain:`, errorText(e)));
@@ -318,7 +325,7 @@ export class ProviderAccountsService {
318
325
  const kept = new Set();
319
326
  for (const [id, entry] of Object.entries(stored)) {
320
327
  const kind = entry.kind;
321
- if (!isKnownKind(kind)) {
328
+ if (!isOfferedKind(kind, this.kinds())) {
322
329
  for (const name of sensitiveNames(entry)) {
323
330
  kept.add(secretKey(id, name));
324
331
  }
@@ -416,7 +423,7 @@ export class ProviderAccountsService {
416
423
  return this.status(id, account, 'checking', { checkedAt: 0 });
417
424
  }
418
425
  status(id, account, state, fields = {}) {
419
- const provider = isKnownKind(account.kind) ? this.providers.get(account.kind) : null;
426
+ const provider = isOfferedKind(account.kind, this.kinds()) ? this.providers.get(account.kind) : null;
420
427
  const folder = provider === null ? '' : accountFolder(id, account, provider, this.env);
421
428
  return {
422
429
  id,
@@ -461,7 +468,7 @@ export class ProviderAccountsService {
461
468
  }
462
469
  async check(id, account, installed) {
463
470
  const kind = account.kind;
464
- if (!isKnownKind(kind)) {
471
+ if (!isOfferedKind(kind, this.kinds())) {
465
472
  return this.status(id, account, 'unavailable', { message: `This version of ${this.host.name} does not know this CLI` });
466
473
  }
467
474
  const provider = this.providers.get(kind);
@@ -1,14 +1,13 @@
1
1
  import { type AgentKind, type ProviderAccountMap } from '@adecore/agent-contracts';
2
2
  import { CodedError } from '../../coded-error.ts';
3
- import type { ChatProvider } from '../provider.ts';
4
- import { type Env } from './accounts.ts';
3
+ import { type AccountProviders, type Env } from './accounts.ts';
5
4
  import { type AccountsHost } from './variables.ts';
6
5
  export declare function accountsPath(home: string): string;
7
6
  export type StoredAccounts = Record<string, unknown>;
8
- export declare function readAccounts(path: string, providerOf: (kind: AgentKind) => ChatProvider, env: Env, host?: AccountsHost): Promise<StoredAccounts>;
7
+ export declare function readAccounts(path: string, providers: AccountProviders, env: Env, host?: AccountsHost): Promise<StoredAccounts>;
9
8
  export declare function writeAccounts(path: string, stored: StoredAccounts): Promise<void>;
10
- export declare function wireAccounts(stored: StoredAccounts): ProviderAccountMap;
9
+ export declare function wireAccounts(stored: StoredAccounts, kinds: readonly AgentKind[]): ProviderAccountMap;
11
10
  export declare class InvalidAccountError extends CodedError<'invalid-account'> {
12
11
  constructor(message: string);
13
12
  }
14
- export declare function acceptAccounts(saved: ProviderAccountMap, before: StoredAccounts, providerOf: (kind: AgentKind) => ChatProvider, env: Env, isDirectory: (path: string) => boolean, host?: AccountsHost): StoredAccounts;
13
+ export declare function acceptAccounts(saved: ProviderAccountMap, before: StoredAccounts, providers: AccountProviders, env: Env, isDirectory: (path: string) => boolean, host?: AccountsHost): StoredAccounts;
@@ -3,14 +3,14 @@ import { join } from 'node:path';
3
3
  import { ProviderAccountIdSchema, ProviderAccountSchema } from '@adecore/agent-contracts';
4
4
  import { CodedError } from '../../coded-error.js';
5
5
  import { isNotFound, writeAtomic } from '../../fs.js';
6
- import { accountFolder, accountProblem, defaultFolder, expandHome, folderVariablesOf, isDefaultAccount, isKnownKind, readAccount, withDefaults } from './accounts.js';
6
+ import { accountFolder, accountProblem, defaultFolder, expandHome, folderVariablesOf, isDefaultAccount, isOfferedKind, readAccount, withDefaults } from './accounts.js';
7
7
  import { DEFAULT_ACCOUNTS_HOST, redactVariables } from './variables.js';
8
8
  const FILE_VERSION = 1;
9
9
  export function accountsPath(home) {
10
10
  return join(home, 'providers.json');
11
11
  }
12
12
  /* Every entry that does not parse is dropped on its own; a file that is not JSON at all reads as none. */
13
- export async function readAccounts(path, providerOf, env, host = DEFAULT_ACCOUNTS_HOST) {
13
+ export async function readAccounts(path, providers, env, host = DEFAULT_ACCOUNTS_HOST) {
14
14
  let parsed;
15
15
  try {
16
16
  parsed = JSON.parse(await readFile(path, 'utf8'));
@@ -30,7 +30,7 @@ export async function readAccounts(path, providerOf, env, host = DEFAULT_ACCOUNT
30
30
  if (!ProviderAccountIdSchema.safeParse(id).success) {
31
31
  continue;
32
32
  }
33
- const account = readAccount(id, raw, providerOf, env, host);
33
+ const account = readAccount(id, raw, providers, env, host);
34
34
  if (account !== null) {
35
35
  stored[id] = account;
36
36
  }
@@ -48,8 +48,8 @@ function wireAccount(entry) {
48
48
  }
49
49
  return parsed.data.env === undefined ? parsed.data : { ...parsed.data, env: redactVariables(parsed.data.env) };
50
50
  }
51
- export function wireAccounts(stored) {
52
- return withDefaults(Object.fromEntries(Object.entries(stored).map(([id, entry]) => [id, wireAccount(entry)])));
51
+ export function wireAccounts(stored, kinds) {
52
+ return withDefaults(Object.fromEntries(Object.entries(stored).map(([id, entry]) => [id, wireAccount(entry)])), kinds);
53
53
  }
54
54
  export class InvalidAccountError extends CodedError {
55
55
  constructor(message) {
@@ -88,12 +88,12 @@ function linkProblem(id, account, before, provider, env, isDirectory) {
88
88
  * client that knows the kind no better than this host could only have cut short. Sensitive values
89
89
  * are still in it: the caller puts them in the keychain before the file is written.
90
90
  */
91
- export function acceptAccounts(saved, before, providerOf, env, isDirectory, host = DEFAULT_ACCOUNTS_HOST) {
91
+ export function acceptAccounts(saved, before, providers, env, isDirectory, host = DEFAULT_ACCOUNTS_HOST) {
92
92
  const stored = {};
93
- const reserved = { host, folderVariables: folderVariablesOf(providerOf) };
94
- for (const [id, account] of Object.entries(withDefaults(saved))) {
95
- if (!isKnownKind(account.kind)) {
96
- if (isKnownKind(id)) {
93
+ const reserved = { host, folderVariables: folderVariablesOf(providers) };
94
+ for (const [id, account] of Object.entries(withDefaults(saved, providers.kinds))) {
95
+ if (!isOfferedKind(account.kind, providers.kinds)) {
96
+ if (isOfferedKind(id, providers.kinds)) {
97
97
  throw new InvalidAccountError(`"${id}" is the id of the default ${id} account`);
98
98
  }
99
99
  const kept = before[id];
@@ -101,8 +101,8 @@ export function acceptAccounts(saved, before, providerOf, env, isDirectory, host
101
101
  stored[id] = keptKind === account.kind ? kept : account;
102
102
  continue;
103
103
  }
104
- const provider = providerOf(account.kind);
105
- const problem = accountProblem(id, account, provider, env, reserved) ?? linkProblem(id, account, before[id], provider, env, isDirectory);
104
+ const provider = providers.get(account.kind);
105
+ const problem = accountProblem(id, account, provider, env, reserved, providers.kinds) ?? linkProblem(id, account, before[id], provider, env, isDirectory);
106
106
  if (problem !== null) {
107
107
  throw new InvalidAccountError(`${id}: ${problem}`);
108
108
  }
@@ -0,0 +1,22 @@
1
+ import type { AgentKind, ProviderCapabilities } from '@adecore/agent-contracts';
2
+ import { type AcpAgentSpec } from '../chat/acp-backend.ts';
3
+ import type { ModelCatalog } from './catalog.ts';
4
+ import { type CliDetection } from './detect.ts';
5
+ import type { ChatProvider, ProviderHome } from './provider.ts';
6
+ export declare const ACP_CAPABILITIES: ProviderCapabilities;
7
+ export interface AcpProviderSpec extends AcpAgentSpec {
8
+ kind: AgentKind;
9
+ command: string;
10
+ args?: readonly string[];
11
+ catalog: ModelCatalog;
12
+ capabilities?: Partial<ProviderCapabilities>;
13
+ resumeCommand?: string;
14
+ home?: ProviderHome;
15
+ ignoresProjectConfig?: boolean;
16
+ oneShotArgs?(prompt: string): string[];
17
+ oneShotStdinArgs?: readonly string[];
18
+ firstPromptArgs?(prompt: string): string[];
19
+ detect?(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
20
+ enabled?(): boolean;
21
+ }
22
+ export declare function createAcpProvider(spec: AcpProviderSpec): ChatProvider;
@@ -0,0 +1,45 @@
1
+ import { createAcpBackend } from '../chat/acp-backend.js';
2
+ import { detectCli } from './detect.js';
3
+ /*
4
+ * What every ACP agent can do through this backend. A file change comes as the text before and after;
5
+ * a message names its mentions and attachments by path, as text the agent opens with its own tools.
6
+ */
7
+ export const ACP_CAPABILITIES = {
8
+ chat: true,
9
+ terminal: false,
10
+ hooks: false,
11
+ streamsToolOutput: false,
12
+ diffs: 'before-after',
13
+ attachments: true,
14
+ mentions: true,
15
+ denyReason: false,
16
+ allowAlways: true,
17
+ asyncQuestions: false,
18
+ compaction: 'none',
19
+ reportsCost: false,
20
+ reportsContextWindow: false,
21
+ reportsThinking: true,
22
+ slashCommands: true
23
+ };
24
+ export function createAcpProvider(spec) {
25
+ const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
26
+ return {
27
+ kind: spec.kind,
28
+ name: spec.name,
29
+ catalog: spec.catalog,
30
+ capabilities,
31
+ command: [spec.command, ...(spec.args ?? [])],
32
+ resumeCommand: spec.resumeCommand ?? '',
33
+ ...(spec.ignoresProjectConfig === undefined ? {} : { ignoresProjectConfig: spec.ignoresProjectConfig }),
34
+ ...(spec.home ? { home: spec.home } : {}),
35
+ ...(spec.oneShotArgs ? { oneShotArgs: spec.oneShotArgs } : {}),
36
+ ...(spec.oneShotStdinArgs ? { oneShotStdinArgs: spec.oneShotStdinArgs } : {}),
37
+ ...(spec.enabled ? { enabled: spec.enabled } : {}),
38
+ firstPromptArgs: spec.firstPromptArgs ??
39
+ (() => {
40
+ throw new Error(`${spec.name} is only available as a chat.`);
41
+ }),
42
+ detect: spec.detect ?? ((command, env) => detectCli(command, env)),
43
+ createBackend: createAcpBackend(spec)
44
+ };
45
+ }
@@ -31,6 +31,7 @@ export class ModelCatalog {
31
31
  slug: model.slug,
32
32
  name: model.name,
33
33
  badge: model.badge,
34
+ ...(model.vendor === undefined ? {} : { vendor: model.vendor }),
34
35
  legacy: model.legacy === true,
35
36
  isDefault: model.slug === this.data.defaultModel,
36
37
  options: this.profileOf(model.slug)?.options ?? [],
@@ -1,3 +1,4 @@
1
+ export { ACP_CAPABILITIES, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
1
2
  export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
2
3
  export { ProviderAccountsService, type ProviderAccountsOptions } from './accounts/service.ts';
3
4
  export type { ShadowHomeReport } from './accounts/shadow-home.ts';
@@ -1,4 +1,5 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
+ export { ACP_CAPABILITIES, createAcpProvider } from './acp-provider.js';
2
3
  export { AccountError, storedAccount, launchEnv } from './accounts/launch.js';
3
4
  export { ProviderAccountsService } from './accounts/service.js';
4
5
  export { variablesProblem, keychainService } from './accounts/variables.js';
@@ -18,6 +18,7 @@ export declare class ProviderRegistry {
18
18
  private readonly cache;
19
19
  constructor(options?: ProviderRegistryOptions);
20
20
  list(): Promise<ProviderInfo[]>;
21
+ kinds(): AgentKind[];
21
22
  get(kind: AgentKind): ChatProvider;
22
23
  oneShotProvider(preferred?: AgentKind): Promise<ChatProvider | null>;
23
24
  catalogFor(kind: AgentKind): ModelCatalog;
@@ -68,6 +68,10 @@ export class ProviderRegistry {
68
68
  resumeCommand: provider.resumeCommand
69
69
  })));
70
70
  }
71
+ /* The kinds this host offers, in the order every menu lists them. */
72
+ kinds() {
73
+ return this.providers.map((provider) => provider.kind);
74
+ }
71
75
  /* The provider of a kind, one this host does not offer included. */
72
76
  get(kind) {
73
77
  return this.providers.find((provider) => provider.kind === kind) ?? this.fallback(kind);