@adecore/agents 0.31.1 → 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.
- package/dist/chat/acp-protocol.d.ts +1 -0
- package/dist/chat/acp-protocol.js +3 -1
- package/dist/providers/accounts/acp-account.d.ts +19 -0
- package/dist/providers/accounts/acp-account.js +101 -0
- package/dist/providers/accounts/service.d.ts +1 -0
- package/dist/providers/accounts/service.js +14 -4
- package/dist/providers/accounts/status.d.ts +2 -0
- package/dist/providers/accounts/status.js +3 -1
- package/dist/providers/acp-provider.d.ts +2 -0
- package/dist/providers/acp-provider.js +23 -1
- package/dist/providers/catalog.d.ts +4 -0
- package/dist/providers/catalog.js +21 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/provider.d.ts +2 -0
- package/dist/testing/fake-acp.d.ts +1 -0
- package/dist/testing/fake-acp.js +9 -1
- package/package.json +3 -3
- package/src/chat/acp-protocol.ts +5 -1
- package/src/providers/accounts/acp-account.ts +129 -0
- package/src/providers/accounts/service.ts +15 -5
- package/src/providers/accounts/status.ts +4 -1
- package/src/providers/acp-provider.ts +30 -1
- package/src/providers/catalog.ts +23 -0
- package/src/providers/index.ts +1 -0
- package/src/providers/provider.ts +3 -0
- package/src/testing/fake-acp.ts +11 -1
|
@@ -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
|
|
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
|
|
522
|
+
const env = accountEnv(id, account, provider, this.env, values);
|
|
523
|
+
const reading = await (provider.askAccount ? provider.askAccount(provider.command, env) : this.ask(kind, provider.command, env));
|
|
514
524
|
return this.status(id, account, reading.signedIn ? 'ready' : 'signed-out', {
|
|
515
525
|
email: reading.email,
|
|
516
526
|
plan: reading.plan,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentKind } from '@adecore/agent-contracts';
|
|
2
2
|
import { type CodexClientInfo } from '../../chat/codex-transport.ts';
|
|
3
|
+
export declare const ASK_TIMEOUT_MS = 20000;
|
|
3
4
|
export interface AccountReading {
|
|
4
5
|
signedIn: boolean;
|
|
5
6
|
email: string | null;
|
|
@@ -9,4 +10,5 @@ export interface AccountReading {
|
|
|
9
10
|
export type AskAccount = (kind: AgentKind, command: readonly string[], env: Record<string, string | undefined>) => Promise<AccountReading>;
|
|
10
11
|
export declare function readClaudeAuthStatus(output: string): AccountReading;
|
|
11
12
|
export declare function readCodexAccount(answer: unknown): AccountReading;
|
|
13
|
+
export declare const ASKED_KINDS: readonly AgentKind[];
|
|
12
14
|
export declare function askAccountAs(client: CodexClientInfo): AskAccount;
|
|
@@ -3,7 +3,7 @@ import { withTimeout } from '../../async.js';
|
|
|
3
3
|
import { CodexTransport } from '../../chat/codex-transport.js';
|
|
4
4
|
import { runProcess } from '../../run-process.js';
|
|
5
5
|
/* A CLI that has not said who is signed in by now is not going to; the next pass asks again. */
|
|
6
|
-
const ASK_TIMEOUT_MS = 20_000;
|
|
6
|
+
export const ASK_TIMEOUT_MS = 20_000;
|
|
7
7
|
function text(value) {
|
|
8
8
|
return typeof value === 'string' && value !== '' ? value : null;
|
|
9
9
|
}
|
|
@@ -69,6 +69,8 @@ async function askCodex(command, env, client) {
|
|
|
69
69
|
transport.kill();
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
/* The kinds `askAccountAs` can ask; a provider with a reader of its own answers for itself. */
|
|
73
|
+
export const ASKED_KINDS = ['claude', 'codex'];
|
|
72
74
|
/* Starts the CLI in the account's environment and asks it; only Claude Code and Codex can say. */
|
|
73
75
|
export function askAccountAs(client) {
|
|
74
76
|
return (kind, command, env) => {
|
|
@@ -19,5 +19,7 @@ export interface AcpProviderSpec extends AcpAgentSpec {
|
|
|
19
19
|
firstPromptArgs?(prompt: string): string[];
|
|
20
20
|
detect?(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
|
|
21
21
|
enabled?(): boolean;
|
|
22
|
+
accountProbe?: boolean;
|
|
23
|
+
adoptModels?: boolean;
|
|
22
24
|
}
|
|
23
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,
|
|
@@ -41,6 +52,17 @@ export function createAcpProvider(spec) {
|
|
|
41
52
|
throw new Error(`${spec.name} is only available as a chat.`);
|
|
42
53
|
}),
|
|
43
54
|
detect: spec.detect ?? ((command, env) => detectCli(command, env)),
|
|
44
|
-
|
|
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 })
|
|
45
67
|
};
|
|
46
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,
|
|
@@ -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';
|
package/dist/providers/index.js
CHANGED
|
@@ -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
1
|
import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
|
|
2
2
|
import type { BackendHost, BackendLaunch, ChatBackend } from '../chat/backend.ts';
|
|
3
|
+
import type { AccountReading } from './accounts/status.ts';
|
|
3
4
|
import type { ModelCatalog } from './catalog.ts';
|
|
4
5
|
import type { CliDetection } from './detect.ts';
|
|
5
6
|
export interface ProviderHome {
|
|
@@ -23,5 +24,6 @@ export interface ChatProvider {
|
|
|
23
24
|
firstPromptArgs(prompt: string): string[];
|
|
24
25
|
detect(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
|
|
25
26
|
enabled?(): boolean;
|
|
27
|
+
askAccount?(command: readonly string[], env: Record<string, string | undefined>): Promise<AccountReading>;
|
|
26
28
|
createBackend(launch: BackendLaunch, host: BackendHost): ChatBackend;
|
|
27
29
|
}
|
package/dist/testing/fake-acp.js
CHANGED
|
@@ -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.
|
|
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.
|
|
85
|
-
"@adecore/intelligent-ui": "0.31.
|
|
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": {
|
package/src/chat/acp-protocol.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
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';
|
|
@@ -47,10 +49,25 @@ export interface AcpProviderSpec extends AcpAgentSpec {
|
|
|
47
49
|
// Absent asks the executable for `--version`.
|
|
48
50
|
detect?(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
|
|
49
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 }));
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
53
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
|
+
};
|
|
54
71
|
return {
|
|
55
72
|
kind: spec.kind,
|
|
56
73
|
name: spec.name,
|
|
@@ -70,6 +87,18 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
|
70
87
|
throw new Error(`${spec.name} is only available as a chat.`);
|
|
71
88
|
}),
|
|
72
89
|
detect: spec.detect ?? ((command, env) => detectCli(command, env)),
|
|
73
|
-
|
|
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 })
|
|
74
103
|
};
|
|
75
104
|
}
|
package/src/providers/catalog.ts
CHANGED
|
@@ -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,
|
package/src/providers/index.ts
CHANGED
|
@@ -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
1
|
import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
|
|
2
2
|
import type { BackendHost, BackendLaunch, ChatBackend } from '../chat/backend.ts';
|
|
3
|
+
import type { AccountReading } from './accounts/status.ts';
|
|
3
4
|
import type { ModelCatalog } from './catalog.ts';
|
|
4
5
|
import type { CliDetection } from './detect.ts';
|
|
5
6
|
|
|
@@ -47,5 +48,7 @@ export interface ChatProvider {
|
|
|
47
48
|
detect(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
|
|
48
49
|
// Whether a person turned this provider on, for one that is off until they do; absent is always on.
|
|
49
50
|
enabled?(): boolean;
|
|
51
|
+
/* Asks the CLI, started in an account's environment, who is signed in there. Absent leaves the host's own reader. */
|
|
52
|
+
askAccount?(command: readonly string[], env: Record<string, string | undefined>): Promise<AccountReading>;
|
|
50
53
|
createBackend(launch: BackendLaunch, host: BackendHost): ChatBackend;
|
|
51
54
|
}
|
package/src/testing/fake-acp.ts
CHANGED
|
@@ -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, {});
|