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