@adecore/agents 0.31.0 → 0.31.2

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.
@@ -19,6 +19,7 @@ export interface AcpAgentCapabilities {
19
19
  loadSession: boolean;
20
20
  resumeSession: boolean;
21
21
  additionalDirectories: boolean;
22
+ closeSession: boolean;
22
23
  images: boolean;
23
24
  authMethods: string[];
24
25
  agent: AcpAgentInfo | null;
@@ -70,7 +70,8 @@ const InitializeResponseSchema = z.looseObject({
70
70
  sessionCapabilities: z
71
71
  .looseObject({
72
72
  resume: z.unknown().optional(),
73
- additionalDirectories: z.unknown().optional()
73
+ additionalDirectories: z.unknown().optional(),
74
+ close: z.unknown().optional()
74
75
  })
75
76
  .optional()
76
77
  })
@@ -99,6 +100,7 @@ export function parseInitialize(result) {
99
100
  loadSession: capabilities?.loadSession === true,
100
101
  resumeSession: sessions?.resume !== undefined && sessions.resume !== null,
101
102
  additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
103
+ closeSession: sessions?.close !== undefined && sessions.close !== null,
102
104
  images: capabilities?.promptCapabilities?.image === true,
103
105
  authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
104
106
  agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
@@ -0,0 +1,19 @@
1
+ import { type AcpClientInfo } from '../../chat/acp-backend.ts';
2
+ import { type AcpSessionInfo } from '../../chat/acp-protocol.ts';
3
+ import type { SpawnChatProcess } from '../../chat/chat-process.ts';
4
+ import { type AccountReading } from './status.ts';
5
+ export interface ProbeTimer {
6
+ setTimeout(run: () => void, ms: number): unknown;
7
+ clearTimeout(handle: unknown): void;
8
+ }
9
+ export interface AcpAccountProbe {
10
+ name: string;
11
+ command: readonly string[];
12
+ env: Record<string, string | undefined>;
13
+ client?: AcpClientInfo;
14
+ spawn?: SpawnChatProcess;
15
+ timeoutMs?: number;
16
+ timer?: ProbeTimer;
17
+ onSession?(info: AcpSessionInfo): void;
18
+ }
19
+ export declare function askAcpAccount(probe: AcpAccountProbe): Promise<AccountReading>;
@@ -0,0 +1,101 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { DEFAULT_ACP_CLIENT } from '../../chat/acp-backend.js';
5
+ import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, parseInitialize, parseSessionInfo, sessionIdOf } from '../../chat/acp-protocol.js';
6
+ import { JsonRpcError, JsonRpcTransport } from '../../chat/json-rpc-transport.js';
7
+ import { ASK_TIMEOUT_MS } from './status.js';
8
+ const SIGNED_OUT = { signedIn: false, email: null, plan: null, organization: null };
9
+ const SIGNED_IN = { signedIn: true, email: null, plan: null, organization: null };
10
+ const REAL_TIMER = {
11
+ setTimeout: (run, ms) => setTimeout(run, ms),
12
+ clearTimeout: (handle) => clearTimeout(handle)
13
+ };
14
+ function definedEnv(env) {
15
+ return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== undefined));
16
+ }
17
+ function withDeadline(work, ms, timer, message) {
18
+ return new Promise((resolve, reject) => {
19
+ const handle = timer.setTimeout(() => reject(new Error(message)), ms);
20
+ work.then((value) => {
21
+ timer.clearTimeout(handle);
22
+ resolve(value);
23
+ }, (e) => {
24
+ timer.clearTimeout(handle);
25
+ reject(e instanceof Error ? e : new Error(String(e)));
26
+ });
27
+ });
28
+ }
29
+ /*
30
+ * Asks an ACP agent whether the account's environment is signed in: a session it opens means yes, "authentication
31
+ * required" means no. ACP has no request that says who, so a reading carries no email or plan. The session is opened
32
+ * in an empty folder of its own with no MCP servers, and `authenticate` is never sent: a probe must not start a login.
33
+ */
34
+ export async function askAcpAccount(probe) {
35
+ const folder = await mkdtemp(join(tmpdir(), 'acp-account-'));
36
+ const client = probe.client ?? DEFAULT_ACP_CLIENT;
37
+ let transport = null;
38
+ try {
39
+ const started = new JsonRpcTransport({
40
+ command: [...probe.command],
41
+ cwd: folder,
42
+ env: definedEnv(probe.env),
43
+ ...(probe.spawn ? { spawn: probe.spawn } : {}),
44
+ label: probe.name,
45
+ errorName: 'AcpRpcError',
46
+ // The probe's own deadline covers every request.
47
+ requestTimeoutMs: null,
48
+ versionField: true,
49
+ onFrame: (frame) => refuseRequest(started, frame),
50
+ onExit: () => undefined
51
+ });
52
+ transport = started;
53
+ const reading = async () => {
54
+ const capabilities = parseInitialize(await started.request('initialize', {
55
+ protocolVersion: ACP_PROTOCOL_VERSION,
56
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
57
+ clientInfo: { name: client.name, title: client.title ?? client.name, version: client.version }
58
+ }));
59
+ if (capabilities.protocolVersion !== ACP_PROTOCOL_VERSION) {
60
+ throw new Error(`${probe.name} speaks ACP version ${capabilities.protocolVersion}; only version ${ACP_PROTOCOL_VERSION} is supported`);
61
+ }
62
+ let result;
63
+ try {
64
+ result = await started.request('session/new', { cwd: folder, mcpServers: [] });
65
+ }
66
+ catch (error) {
67
+ if (error instanceof JsonRpcError && error.code === ACP_AUTH_REQUIRED) {
68
+ return SIGNED_OUT;
69
+ }
70
+ throw error;
71
+ }
72
+ const sessionId = sessionIdOf(result);
73
+ if (sessionId === null) {
74
+ throw new Error(`${probe.name} opened a session without an id`);
75
+ }
76
+ probe.onSession?.(parseSessionInfo(result, sessionId, capabilities.agent));
77
+ if (capabilities.closeSession) {
78
+ // The answer is known by now; an agent that cannot close it keeps an empty session, nothing worse.
79
+ await started.request('session/close', { sessionId }).catch(() => undefined);
80
+ }
81
+ return SIGNED_IN;
82
+ };
83
+ return await withDeadline(reading(), probe.timeoutMs ?? ASK_TIMEOUT_MS, probe.timer ?? REAL_TIMER, `${probe.name} did not answer in time`);
84
+ }
85
+ finally {
86
+ await transport?.dispose();
87
+ await rm(folder, { recursive: true, force: true });
88
+ }
89
+ }
90
+ /* The probe advertises nothing, so a request from the agent gets a refusal rather than a silence it would wait on. */
91
+ function refuseRequest(transport, frame) {
92
+ if (frame.id === undefined || frame.id === null || !transport.alive) {
93
+ return;
94
+ }
95
+ try {
96
+ transport.respondError(frame.id, -32601, `Method not found: ${String(frame.method)}`);
97
+ }
98
+ catch {
99
+ // The agent closed its input; the deadline or the exit settles the probe.
100
+ }
101
+ }
@@ -33,6 +33,7 @@ export declare class ProviderAccountsService implements AccountLaunches {
33
33
  private readonly providers;
34
34
  private readonly env;
35
35
  private readonly ask;
36
+ private readonly asksEveryKind;
36
37
  private readonly prepareShadow;
37
38
  private readonly folderExists;
38
39
  private readonly install;
@@ -8,7 +8,7 @@ import { errorText } from '../../error-text.js';
8
8
  import { accountEnv, accountFolder, canContinue, defaultFolder, expandHome, isDefaultAccount, isOfferedKind, transcriptFolder } from './accounts.js';
9
9
  import { AccountError, isDefaultAccountOf } from './launch.js';
10
10
  import { prepareShadowHome } from './shadow-home.js';
11
- import { askAccountAs } from './status.js';
11
+ import { ASKED_KINDS, askAccountAs } from './status.js';
12
12
  import { acceptAccounts, accountsPath, InvalidAccountError, readAccounts, wireAccounts, writeAccounts } from './store.js';
13
13
  import { DEFAULT_ACCOUNTS_HOST, platformSecrets, secretKey, SecretsUnavailableError } from './variables.js';
14
14
  /* Who is signed in changes by hand and rarely; often enough to notice, rarely enough not to start CLIs all day. */
@@ -61,6 +61,8 @@ export class ProviderAccountsService {
61
61
  providers;
62
62
  env;
63
63
  ask;
64
+ // A host's own `ask` is taken to answer for every kind; the default only for `ASKED_KINDS`.
65
+ asksEveryKind;
64
66
  prepareShadow;
65
67
  folderExists;
66
68
  install;
@@ -87,6 +89,7 @@ export class ProviderAccountsService {
87
89
  this.providers = options.providers;
88
90
  this.env = options.env ?? process.env;
89
91
  this.ask = options.ask ?? askAccountAs(options.client ?? DEFAULT_CODEX_CLIENT);
92
+ this.asksEveryKind = options.ask !== undefined;
90
93
  this.prepareShadow = options.prepareShadow ?? prepareShadowHome;
91
94
  this.folderExists = options.folderExists ?? isFolder;
92
95
  this.install = options.install;
@@ -106,10 +109,15 @@ export class ProviderAccountsService {
106
109
  snapshot() {
107
110
  const loginCommands = {};
108
111
  const chatLogins = [];
112
+ const silentLogins = [];
109
113
  for (const kind of this.kinds()) {
110
- const home = this.providers.get(kind).home;
114
+ const provider = this.providers.get(kind);
115
+ const home = provider.home;
111
116
  if (home?.loginCommand !== undefined) {
112
117
  loginCommands[kind] = home.loginCommand;
118
+ if (provider.askAccount === undefined && !this.asksEveryKind && !ASKED_KINDS.includes(kind)) {
119
+ silentLogins.push(kind);
120
+ }
113
121
  }
114
122
  else if (home !== undefined) {
115
123
  chatLogins.push(kind);
@@ -120,7 +128,8 @@ export class ProviderAccountsService {
120
128
  statuses: Object.entries(this.accounts).map(([id, account]) => this.statusOf(id, account)),
121
129
  secretsAvailable: this.secrets !== null,
122
130
  loginCommands,
123
- ...(chatLogins.length > 0 ? { chatLogins } : {})
131
+ ...(chatLogins.length > 0 ? { chatLogins } : {}),
132
+ ...(silentLogins.length > 0 ? { silentLogins } : {})
124
133
  };
125
134
  }
126
135
  /* The account under an id, for a launch; null for an id this machine does not have. */
@@ -510,7 +519,8 @@ export class ProviderAccountsService {
510
519
  if (provider.home.loginCommand === undefined) {
511
520
  return this.status(id, account, 'ready', { message });
512
521
  }
513
- const reading = await this.ask(kind, provider.command, accountEnv(id, account, provider, this.env, values));
522
+ const env = accountEnv(id, account, provider, this.env, values);
523
+ const reading = await (provider.askAccount ? provider.askAccount(provider.command, env) : this.ask(kind, provider.command, env));
514
524
  return this.status(id, account, reading.signedIn ? 'ready' : 'signed-out', {
515
525
  email: reading.email,
516
526
  plan: reading.plan,
@@ -1,5 +1,6 @@
1
1
  import type { AgentKind } from '@adecore/agent-contracts';
2
2
  import { type CodexClientInfo } from '../../chat/codex-transport.ts';
3
+ export declare const ASK_TIMEOUT_MS = 20000;
3
4
  export interface AccountReading {
4
5
  signedIn: boolean;
5
6
  email: string | null;
@@ -9,4 +10,5 @@ export interface AccountReading {
9
10
  export type AskAccount = (kind: AgentKind, command: readonly string[], env: Record<string, string | undefined>) => Promise<AccountReading>;
10
11
  export declare function readClaudeAuthStatus(output: string): AccountReading;
11
12
  export declare function readCodexAccount(answer: unknown): AccountReading;
13
+ export declare const ASKED_KINDS: readonly AgentKind[];
12
14
  export declare function askAccountAs(client: CodexClientInfo): AskAccount;
@@ -3,7 +3,7 @@ import { withTimeout } from '../../async.js';
3
3
  import { CodexTransport } from '../../chat/codex-transport.js';
4
4
  import { runProcess } from '../../run-process.js';
5
5
  /* A CLI that has not said who is signed in by now is not going to; the next pass asks again. */
6
- const ASK_TIMEOUT_MS = 20_000;
6
+ export const ASK_TIMEOUT_MS = 20_000;
7
7
  function text(value) {
8
8
  return typeof value === 'string' && value !== '' ? value : null;
9
9
  }
@@ -69,6 +69,8 @@ async function askCodex(command, env, client) {
69
69
  transport.kill();
70
70
  }
71
71
  }
72
+ /* The kinds `askAccountAs` can ask; a provider with a reader of its own answers for itself. */
73
+ export const ASKED_KINDS = ['claude', 'codex'];
72
74
  /* Starts the CLI in the account's environment and asks it; only Claude Code and Codex can say. */
73
75
  export function askAccountAs(client) {
74
76
  return (kind, command, env) => {
@@ -1,4 +1,4 @@
1
- import type { AgentKind, ProviderCapabilities } from '@adecore/agent-contracts';
1
+ import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
2
2
  import { type AcpAgentSpec } from '../chat/acp-backend.ts';
3
3
  import type { ModelCatalog } from './catalog.ts';
4
4
  import { type CliDetection } from './detect.ts';
@@ -12,11 +12,14 @@ export interface AcpProviderSpec extends AcpAgentSpec {
12
12
  capabilities?: Partial<ProviderCapabilities>;
13
13
  resumeCommand?: string;
14
14
  home?: ProviderHome;
15
+ install?: ProviderInstall;
15
16
  ignoresProjectConfig?: boolean;
16
17
  oneShotArgs?(prompt: string): string[];
17
18
  oneShotStdinArgs?: readonly string[];
18
19
  firstPromptArgs?(prompt: string): string[];
19
20
  detect?(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
20
21
  enabled?(): boolean;
22
+ accountProbe?: boolean;
23
+ adoptModels?: boolean;
21
24
  }
22
25
  export declare function createAcpProvider(spec: AcpProviderSpec): ChatProvider;
@@ -1,4 +1,5 @@
1
1
  import { createAcpBackend } from '../chat/acp-backend.js';
2
+ import { askAcpAccount } from './accounts/acp-account.js';
2
3
  import { detectCli } from './detect.js';
3
4
  /*
4
5
  * What every ACP agent can do through this backend. A file change comes as the text before and after;
@@ -21,8 +22,18 @@ export const ACP_CAPABILITIES = {
21
22
  reportsThinking: true,
22
23
  slashCommands: true
23
24
  };
25
+ /* An agent's model id may reach a command line, so one that reads as a flag is left out. */
26
+ function adoptableModels(info) {
27
+ return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
28
+ }
24
29
  export function createAcpProvider(spec) {
25
30
  const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
31
+ const onSession = (info) => {
32
+ if (spec.adoptModels !== false && info.models !== null) {
33
+ spec.catalog.adopt(adoptableModels(info), info.models.current);
34
+ }
35
+ spec.onSession?.(info);
36
+ };
26
37
  return {
27
38
  kind: spec.kind,
28
39
  name: spec.name,
@@ -32,6 +43,7 @@ export function createAcpProvider(spec) {
32
43
  resumeCommand: spec.resumeCommand ?? '',
33
44
  ...(spec.ignoresProjectConfig === undefined ? {} : { ignoresProjectConfig: spec.ignoresProjectConfig }),
34
45
  ...(spec.home ? { home: spec.home } : {}),
46
+ ...(spec.install ? { install: spec.install } : {}),
35
47
  ...(spec.oneShotArgs ? { oneShotArgs: spec.oneShotArgs } : {}),
36
48
  ...(spec.oneShotStdinArgs ? { oneShotStdinArgs: spec.oneShotStdinArgs } : {}),
37
49
  ...(spec.enabled ? { enabled: spec.enabled } : {}),
@@ -40,6 +52,17 @@ export function createAcpProvider(spec) {
40
52
  throw new Error(`${spec.name} is only available as a chat.`);
41
53
  }),
42
54
  detect: spec.detect ?? ((command, env) => detectCli(command, env)),
43
- createBackend: createAcpBackend(spec)
55
+ ...(spec.accountProbe === false
56
+ ? {}
57
+ : {
58
+ askAccount: (command, env) => askAcpAccount({
59
+ name: spec.name,
60
+ command,
61
+ env: { ...env, ...spec.env },
62
+ ...(spec.client ? { client: spec.client } : {}),
63
+ onSession
64
+ })
65
+ }),
66
+ createBackend: createAcpBackend({ ...spec, onSession })
44
67
  };
45
68
  }
@@ -8,6 +8,10 @@ export declare class ModelCatalog {
8
8
  constructor(data?: ModelCatalogInput);
9
9
  get defaultModel(): string;
10
10
  replace(data: ModelCatalogData): boolean;
11
+ adopt(models: ReadonlyArray<{
12
+ slug: string;
13
+ name: string;
14
+ }>, defaultModel: string): boolean;
11
15
  list(): ModelInfo[];
12
16
  nameOf(slug: string): string;
13
17
  resolveModel(name: string): string | null;
@@ -1,4 +1,5 @@
1
1
  import manifest from './claude-models.json' with { type: 'json' };
2
+ const ADOPTED_PROFILE = 'agent';
2
3
  function timeOf(data) {
3
4
  return data.updatedAt === undefined ? 0 : Date.parse(data.updatedAt);
4
5
  }
@@ -26,6 +27,26 @@ export class ModelCatalog {
26
27
  this.data = data;
27
28
  return changed;
28
29
  }
30
+ /*
31
+ * Takes the models an agent reported for itself in place of the list, the agent's current model as the
32
+ * default and its order kept; answers whether the models changed. Every model gets the profile the
33
+ * default has now, as an agent names its models but not what each can be asked for.
34
+ */
35
+ adopt(models, defaultModel) {
36
+ if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
37
+ return false;
38
+ }
39
+ const profile = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
40
+ const data = {
41
+ ...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
42
+ defaultModel,
43
+ profiles: { [ADOPTED_PROFILE]: profile },
44
+ models: models.map((model) => ({ slug: model.slug, name: model.name, profile: ADOPTED_PROFILE }))
45
+ };
46
+ const changed = JSON.stringify(data) !== JSON.stringify(this.data);
47
+ this.data = data;
48
+ return changed;
49
+ }
29
50
  list() {
30
51
  return this.data.models.map((model) => ({
31
52
  slug: model.slug,
@@ -18,6 +18,19 @@ export function createClaudeProvider(options = {}) {
18
18
  loginEnv: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'],
19
19
  loginCommand: 'claude auth login'
20
20
  },
21
+ install: {
22
+ description: "Anthropic's coding agent, in a terminal and in a chat.",
23
+ commands: {
24
+ darwin: 'curl -fsSL https://claude.ai/install.sh | bash',
25
+ linux: 'curl -fsSL https://claude.ai/install.sh | bash',
26
+ win32: 'irm https://claude.ai/install.ps1 | iex'
27
+ },
28
+ guideUrl: 'https://docs.anthropic.com/en/docs/claude-code/setup',
29
+ signIn: [
30
+ { kind: 'command', command: 'claude auth login' },
31
+ { kind: 'env', variable: 'ANTHROPIC_API_KEY' }
32
+ ]
33
+ },
21
34
  detect: detectCli,
22
35
  oneShotArgs: (prompt) => ['-p', prompt, '--output-format', 'text'],
23
36
  // `-p` without a prompt argument reads the prompt from stdin.
@@ -16,6 +16,15 @@ export function createCodexProvider(options = {}) {
16
16
  ignoresProjectConfig: false,
17
17
  // The auth variables Codex 0.157 names in its own "no Codex credentials were found" advice.
18
18
  home: { env: 'CODEX_HOME', fallback: '.codex', loginEnv: ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN'], loginCommand: 'codex login' },
19
+ install: {
20
+ description: "OpenAI's coding agent, in a terminal and in a chat.",
21
+ commands: { darwin: 'npm install -g @openai/codex', linux: 'npm install -g @openai/codex', win32: 'npm install -g @openai/codex' },
22
+ guideUrl: 'https://github.com/openai/codex',
23
+ signIn: [
24
+ { kind: 'command', command: 'codex login' },
25
+ { kind: 'env', variable: 'OPENAI_API_KEY' }
26
+ ]
27
+ },
19
28
  detect: detectCli,
20
29
  // Read-only and without an approval to wait for: the run only has to read what it is handed. A chat's
21
30
  // title is asked in a folder that need not be a repository, and ephemeral keeps the run out of Codex's thread list.
@@ -1,4 +1,5 @@
1
1
  export { ACP_CAPABILITIES, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
2
+ export { type AcpAccountProbe, askAcpAccount, type ProbeTimer } from './accounts/acp-account.ts';
2
3
  export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
3
4
  export { ProviderAccountsService, type ProviderAccountsOptions } from './accounts/service.ts';
4
5
  export type { ShadowHomeReport } from './accounts/shadow-home.ts';
@@ -1,5 +1,6 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
2
  export { ACP_CAPABILITIES, createAcpProvider } from './acp-provider.js';
3
+ export { askAcpAccount } from './accounts/acp-account.js';
3
4
  export { AccountError, storedAccount, launchEnv } from './accounts/launch.js';
4
5
  export { ProviderAccountsService } from './accounts/service.js';
5
6
  export { variablesProblem, keychainService } from './accounts/variables.js';
@@ -1,5 +1,6 @@
1
- import type { AgentKind, ProviderCapabilities } from '@adecore/agent-contracts';
1
+ import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
2
2
  import type { BackendHost, BackendLaunch, ChatBackend } from '../chat/backend.ts';
3
+ import type { AccountReading } from './accounts/status.ts';
3
4
  import type { ModelCatalog } from './catalog.ts';
4
5
  import type { CliDetection } from './detect.ts';
5
6
  export interface ProviderHome {
@@ -17,10 +18,12 @@ export interface ChatProvider {
17
18
  readonly resumeCommand: string;
18
19
  readonly ignoresProjectConfig?: boolean;
19
20
  readonly home?: ProviderHome;
21
+ readonly install?: ProviderInstall;
20
22
  oneShotArgs?(prompt: string): string[];
21
23
  readonly oneShotStdinArgs?: readonly string[];
22
24
  firstPromptArgs(prompt: string): string[];
23
25
  detect(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
24
26
  enabled?(): boolean;
27
+ askAccount?(command: readonly string[], env: Record<string, string | undefined>): Promise<AccountReading>;
25
28
  createBackend(launch: BackendLaunch, host: BackendHost): ChatBackend;
26
29
  }
@@ -65,7 +65,8 @@ export class ProviderRegistry {
65
65
  models: provider.catalog.list(),
66
66
  defaultModel: provider.catalog.defaultModel || null,
67
67
  capabilities: provider.capabilities,
68
- resumeCommand: provider.resumeCommand
68
+ resumeCommand: provider.resumeCommand,
69
+ ...(provider.install ? { install: provider.install } : {})
69
70
  })));
70
71
  }
71
72
  /* The kinds this host offers, in the order every menu lists them. */
@@ -5,6 +5,7 @@ export interface FakeAcpOptions {
5
5
  authMethod?: string;
6
6
  modes?: readonly string[];
7
7
  legacyModels?: boolean;
8
+ closeSession?: boolean;
8
9
  }
9
10
  export declare function fakeAcpWith(options: FakeAcpOptions): FakeCli;
10
11
  export declare const fakeAcp: FakeCli;
@@ -180,7 +180,7 @@ export function fakeAcpWith(options) {
180
180
  agentCapabilities: {
181
181
  loadSession: options.loadSession ?? true,
182
182
  promptCapabilities: { image: true, audio: false, embeddedContext: false },
183
- sessionCapabilities: options.resumeSession ? { resume: {} } : {}
183
+ sessionCapabilities: { ...(options.resumeSession ? { resume: {} } : {}), ...(options.closeSession ? { close: {} } : {}) }
184
184
  },
185
185
  authMethods: options.authMethod === undefined ? [] : [{ id: options.authMethod, name: 'Sign in' }],
186
186
  agentInfo: { name: 'fake-acp', title: 'Fake ACP', version: '0.0.1' }
@@ -220,6 +220,14 @@ export function fakeAcpWith(options) {
220
220
  respond(id, sessionState());
221
221
  return;
222
222
  }
223
+ case 'session/close':
224
+ if (!options.closeSession) {
225
+ refuse(id, -32601, 'Method not found: session/close');
226
+ return;
227
+ }
228
+ sessionHistory.delete(String(params.sessionId));
229
+ respond(id, {});
230
+ return;
223
231
  case 'session/set_mode':
224
232
  mode = String(params.modeId);
225
233
  respond(id, {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,8 +81,8 @@
81
81
  "test": "bun --conditions=source test"
82
82
  },
83
83
  "dependencies": {
84
- "@adecore/agent-contracts": "0.31.0",
85
- "@adecore/intelligent-ui": "0.31.0",
84
+ "@adecore/agent-contracts": "0.31.2",
85
+ "@adecore/intelligent-ui": "0.31.2",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -95,7 +95,8 @@ const InitializeResponseSchema = z.looseObject({
95
95
  sessionCapabilities: z
96
96
  .looseObject({
97
97
  resume: z.unknown().optional(),
98
- additionalDirectories: z.unknown().optional()
98
+ additionalDirectories: z.unknown().optional(),
99
+ close: z.unknown().optional()
99
100
  })
100
101
  .optional()
101
102
  })
@@ -126,6 +127,8 @@ export interface AcpAgentCapabilities {
126
127
  loadSession: boolean;
127
128
  resumeSession: boolean;
128
129
  additionalDirectories: boolean;
130
+ // Whether the agent takes `session/close`, which ends a session it would otherwise keep.
131
+ closeSession: boolean;
129
132
  images: boolean;
130
133
  authMethods: string[];
131
134
  agent: AcpAgentInfo | null;
@@ -144,6 +147,7 @@ export function parseInitialize(result: unknown): AcpAgentCapabilities {
144
147
  loadSession: capabilities?.loadSession === true,
145
148
  resumeSession: sessions?.resume !== undefined && sessions.resume !== null,
146
149
  additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
150
+ closeSession: sessions?.close !== undefined && sessions.close !== null,
147
151
  images: capabilities?.promptCapabilities?.image === true,
148
152
  authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
149
153
  agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
@@ -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,7 @@
1
- import type { AgentKind, ProviderCapabilities } from '@adecore/agent-contracts';
1
+ import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
2
2
  import { createAcpBackend, type AcpAgentSpec } from '../chat/acp-backend.ts';
3
+ import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
4
+ import { askAcpAccount } from './accounts/acp-account.ts';
3
5
  import type { ModelCatalog } from './catalog.ts';
4
6
  import { detectCli, type CliDetection } from './detect.ts';
5
7
  import type { ChatProvider, ProviderHome } from './provider.ts';
@@ -38,6 +40,7 @@ export interface AcpProviderSpec extends AcpAgentSpec {
38
40
  // What a terminal runs to continue one of its sessions; empty where it has none.
39
41
  resumeCommand?: string;
40
42
  home?: ProviderHome;
43
+ install?: ProviderInstall;
41
44
  ignoresProjectConfig?: boolean;
42
45
  oneShotArgs?(prompt: string): string[];
43
46
  oneShotStdinArgs?: readonly string[];
@@ -46,10 +49,25 @@ export interface AcpProviderSpec extends AcpAgentSpec {
46
49
  // Absent asks the executable for `--version`.
47
50
  detect?(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
48
51
  enabled?(): boolean;
52
+ // Absent or true asks the agent over ACP whether an account is signed in; false leaves that to the host.
53
+ accountProbe?: boolean;
54
+ // Absent or true lets the models a session reports replace the catalog's; false keeps the catalog as the host built it.
55
+ adoptModels?: boolean;
56
+ }
57
+
58
+ /* An agent's model id may reach a command line, so one that reads as a flag is left out. */
59
+ function adoptableModels(info: AcpSessionInfo): Array<{ slug: string; name: string }> {
60
+ return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
49
61
  }
50
62
 
51
63
  export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
52
64
  const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
65
+ const onSession = (info: AcpSessionInfo): void => {
66
+ if (spec.adoptModels !== false && info.models !== null) {
67
+ spec.catalog.adopt(adoptableModels(info), info.models.current);
68
+ }
69
+ spec.onSession?.(info);
70
+ };
53
71
  return {
54
72
  kind: spec.kind,
55
73
  name: spec.name,
@@ -59,6 +77,7 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
59
77
  resumeCommand: spec.resumeCommand ?? '',
60
78
  ...(spec.ignoresProjectConfig === undefined ? {} : { ignoresProjectConfig: spec.ignoresProjectConfig }),
61
79
  ...(spec.home ? { home: spec.home } : {}),
80
+ ...(spec.install ? { install: spec.install } : {}),
62
81
  ...(spec.oneShotArgs ? { oneShotArgs: spec.oneShotArgs } : {}),
63
82
  ...(spec.oneShotStdinArgs ? { oneShotStdinArgs: spec.oneShotStdinArgs } : {}),
64
83
  ...(spec.enabled ? { enabled: spec.enabled } : {}),
@@ -68,6 +87,18 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
68
87
  throw new Error(`${spec.name} is only available as a chat.`);
69
88
  }),
70
89
  detect: spec.detect ?? ((command, env) => detectCli(command, env)),
71
- createBackend: createAcpBackend(spec)
90
+ ...(spec.accountProbe === false
91
+ ? {}
92
+ : {
93
+ askAccount: (command, env) =>
94
+ askAcpAccount({
95
+ name: spec.name,
96
+ command,
97
+ env: { ...env, ...spec.env },
98
+ ...(spec.client ? { client: spec.client } : {}),
99
+ onSession
100
+ })
101
+ }),
102
+ createBackend: createAcpBackend({ ...spec, onSession })
72
103
  };
73
104
  }
@@ -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,
@@ -20,6 +20,19 @@ export function createClaudeProvider(options: ClaudeBackendOptions = {}): ChatPr
20
20
  loginEnv: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'],
21
21
  loginCommand: 'claude auth login'
22
22
  },
23
+ install: {
24
+ description: "Anthropic's coding agent, in a terminal and in a chat.",
25
+ commands: {
26
+ darwin: 'curl -fsSL https://claude.ai/install.sh | bash',
27
+ linux: 'curl -fsSL https://claude.ai/install.sh | bash',
28
+ win32: 'irm https://claude.ai/install.ps1 | iex'
29
+ },
30
+ guideUrl: 'https://docs.anthropic.com/en/docs/claude-code/setup',
31
+ signIn: [
32
+ { kind: 'command', command: 'claude auth login' },
33
+ { kind: 'env', variable: 'ANTHROPIC_API_KEY' }
34
+ ]
35
+ },
23
36
  detect: detectCli,
24
37
  oneShotArgs: (prompt) => ['-p', prompt, '--output-format', 'text'],
25
38
  // `-p` without a prompt argument reads the prompt from stdin.
@@ -18,6 +18,15 @@ export function createCodexProvider(options: CodexBackendOptions = {}): ChatProv
18
18
  ignoresProjectConfig: false,
19
19
  // The auth variables Codex 0.157 names in its own "no Codex credentials were found" advice.
20
20
  home: { env: 'CODEX_HOME', fallback: '.codex', loginEnv: ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN'], loginCommand: 'codex login' },
21
+ install: {
22
+ description: "OpenAI's coding agent, in a terminal and in a chat.",
23
+ commands: { darwin: 'npm install -g @openai/codex', linux: 'npm install -g @openai/codex', win32: 'npm install -g @openai/codex' },
24
+ guideUrl: 'https://github.com/openai/codex',
25
+ signIn: [
26
+ { kind: 'command', command: 'codex login' },
27
+ { kind: 'env', variable: 'OPENAI_API_KEY' }
28
+ ]
29
+ },
21
30
  detect: detectCli,
22
31
  // Read-only and without an approval to wait for: the run only has to read what it is handed. A chat's
23
32
  // title is asked in a folder that need not be a repository, and ephemeral keeps the run out of Codex's thread list.
@@ -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';
@@ -1,5 +1,6 @@
1
- import type { AgentKind, ProviderCapabilities } from '@adecore/agent-contracts';
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
 
@@ -34,6 +35,8 @@ export interface ChatProvider {
34
35
  /* Where this CLI keeps its login, settings and transcripts, which is what an account is. Absent on a
35
36
  CLI with no variable to move that folder, which then only has its default account. */
36
37
  readonly home?: ProviderHome;
38
+ /* How a person installs this CLI and signs it in, for a page that shows it while the CLI is missing. */
39
+ readonly install?: ProviderInstall;
37
40
  /* The arguments for one prompt in and one answer out, no session and no chat, which is how a
38
41
  host asks for a commit message or a title. Absent on a CLI that only runs interactively. */
39
42
  oneShotArgs?(prompt: string): string[];
@@ -45,5 +48,7 @@ export interface ChatProvider {
45
48
  detect(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
46
49
  // Whether a person turned this provider on, for one that is off until they do; absent is always on.
47
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>;
48
53
  createBackend(launch: BackendLaunch, host: BackendHost): ChatBackend;
49
54
  }
@@ -89,7 +89,8 @@ export class ProviderRegistry {
89
89
  models: provider.catalog.list(),
90
90
  defaultModel: provider.catalog.defaultModel || null,
91
91
  capabilities: provider.capabilities,
92
- resumeCommand: provider.resumeCommand
92
+ resumeCommand: provider.resumeCommand,
93
+ ...(provider.install ? { install: provider.install } : {})
93
94
  }))
94
95
  );
95
96
  }
@@ -20,6 +20,8 @@ export interface FakeAcpOptions {
20
20
  modes?: readonly string[];
21
21
  // Reports models the way agents did before config options: `models` and `session/set_model`.
22
22
  legacyModels?: boolean;
23
+ // Advertises and answers `session/close`.
24
+ closeSession?: boolean;
23
25
  }
24
26
 
25
27
  export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
@@ -212,7 +214,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
212
214
  agentCapabilities: {
213
215
  loadSession: options.loadSession ?? true,
214
216
  promptCapabilities: { image: true, audio: false, embeddedContext: false },
215
- sessionCapabilities: options.resumeSession ? { resume: {} } : {}
217
+ sessionCapabilities: { ...(options.resumeSession ? { resume: {} } : {}), ...(options.closeSession ? { close: {} } : {}) }
216
218
  },
217
219
  authMethods: options.authMethod === undefined ? [] : [{ id: options.authMethod, name: 'Sign in' }],
218
220
  agentInfo: { name: 'fake-acp', title: 'Fake ACP', version: '0.0.1' }
@@ -252,6 +254,14 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
252
254
  respond(id, sessionState());
253
255
  return;
254
256
  }
257
+ case 'session/close':
258
+ if (!options.closeSession) {
259
+ refuse(id, -32601, 'Method not found: session/close');
260
+ return;
261
+ }
262
+ sessionHistory.delete(String(params.sessionId));
263
+ respond(id, {});
264
+ return;
255
265
  case 'session/set_mode':
256
266
  mode = String(params.modeId);
257
267
  respond(id, {});