@adecore/agents 0.31.1 → 0.31.3
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 +11 -1
- package/dist/chat/acp-backend.js +24 -2
- package/dist/chat/acp-protocol.d.ts +15 -0
- package/dist/chat/acp-protocol.js +36 -2
- package/dist/chat/index.d.ts +2 -2
- 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 +3 -0
- package/dist/providers/acp-provider.js +33 -1
- package/dist/providers/acp-usage-log.d.ts +2 -0
- package/dist/providers/acp-usage-log.js +21 -0
- package/dist/providers/catalog.d.ts +4 -0
- package/dist/providers/catalog.js +21 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.js +2 -0
- package/dist/providers/provider.d.ts +2 -0
- package/dist/providers/shell-commands.d.ts +1 -0
- package/dist/providers/shell-commands.js +119 -0
- package/dist/testing/fake-acp.d.ts +2 -0
- package/dist/testing/fake-acp.js +10 -2
- package/dist/usage/aggregate.js +6 -5
- package/dist/usage/index-file.js +25 -17
- package/dist/usage/limits/monitor.js +5 -0
- package/dist/usage/readers/grok.d.ts +3 -0
- package/dist/usage/readers/grok.js +85 -0
- package/dist/usage/record.d.ts +1 -0
- package/dist/usage/record.js +5 -1
- package/dist/usage/roots.js +7 -2
- package/dist/usage/scanner.d.ts +1 -0
- package/dist/usage/scanner.js +39 -5
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +40 -2
- package/src/chat/acp-protocol.ts +61 -2
- package/src/chat/index.ts +12 -2
- 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 +45 -2
- package/src/providers/acp-usage-log.ts +26 -0
- package/src/providers/catalog.ts +23 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/provider.ts +3 -0
- package/src/providers/shell-commands.ts +122 -0
- package/src/testing/fake-acp.ts +14 -2
- package/src/usage/aggregate.ts +6 -5
- package/src/usage/index-file.ts +27 -18
- package/src/usage/limits/monitor.ts +5 -0
- package/src/usage/readers/grok.ts +89 -0
- package/src/usage/record.ts +7 -1
- package/src/usage/roots.ts +7 -2
- package/src/usage/scanner.ts +42 -5
package/src/chat/acp-protocol.ts
CHANGED
|
@@ -58,6 +58,48 @@ const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update:
|
|
|
58
58
|
|
|
59
59
|
const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
|
|
60
60
|
|
|
61
|
+
const tokenCount = z.number().int().nonnegative();
|
|
62
|
+
|
|
63
|
+
// Unstable in the protocol (`PromptResponse.usage`); only the three totals are required.
|
|
64
|
+
const PromptUsageSchema = z.looseObject({
|
|
65
|
+
totalTokens: tokenCount,
|
|
66
|
+
inputTokens: tokenCount,
|
|
67
|
+
outputTokens: tokenCount,
|
|
68
|
+
thoughtTokens: tokenCount.nullish(),
|
|
69
|
+
cachedReadTokens: tokenCount.nullish(),
|
|
70
|
+
cachedWriteTokens: tokenCount.nullish()
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/* The tokens of one turn with their kinds apart, as a usage record keeps them: `reasoning` is a part of `output`. */
|
|
74
|
+
export interface AcpTurnUsage {
|
|
75
|
+
input: number;
|
|
76
|
+
cacheRead: number;
|
|
77
|
+
cacheWrite: number;
|
|
78
|
+
output: number;
|
|
79
|
+
reasoning: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/*
|
|
83
|
+
* The `usage` a `session/prompt` response carries, or null where the agent reports none. The protocol
|
|
84
|
+
* leaves open whether the input holds the cached part, so the total decides: when the kinds add up
|
|
85
|
+
* to more than it, the input counted the cache too and loses it here.
|
|
86
|
+
*/
|
|
87
|
+
export function promptUsageOf(result: unknown): AcpTurnUsage | null {
|
|
88
|
+
const response = z.looseObject({ usage: PromptUsageSchema }).safeParse(result);
|
|
89
|
+
if (!response.success) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const usage = response.data.usage;
|
|
93
|
+
const cacheRead = usage.cachedReadTokens ?? 0;
|
|
94
|
+
const cacheWrite = usage.cachedWriteTokens ?? 0;
|
|
95
|
+
const apart = usage.inputTokens + usage.outputTokens + cacheRead + cacheWrite <= usage.totalTokens;
|
|
96
|
+
const input = apart ? usage.inputTokens : Math.max(0, usage.inputTokens - cacheRead - cacheWrite);
|
|
97
|
+
if (input + cacheRead + cacheWrite + usage.outputTokens === 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return { input, cacheRead, cacheWrite, output: usage.outputTokens, reasoning: usage.thoughtTokens ?? 0 };
|
|
101
|
+
}
|
|
102
|
+
|
|
61
103
|
const ModeStateSchema = z.looseObject({
|
|
62
104
|
currentModeId: z.string(),
|
|
63
105
|
availableModes: z.array(z.unknown())
|
|
@@ -95,7 +137,8 @@ const InitializeResponseSchema = z.looseObject({
|
|
|
95
137
|
sessionCapabilities: z
|
|
96
138
|
.looseObject({
|
|
97
139
|
resume: z.unknown().optional(),
|
|
98
|
-
additionalDirectories: z.unknown().optional()
|
|
140
|
+
additionalDirectories: z.unknown().optional(),
|
|
141
|
+
close: z.unknown().optional()
|
|
99
142
|
})
|
|
100
143
|
.optional()
|
|
101
144
|
})
|
|
@@ -126,6 +169,8 @@ export interface AcpAgentCapabilities {
|
|
|
126
169
|
loadSession: boolean;
|
|
127
170
|
resumeSession: boolean;
|
|
128
171
|
additionalDirectories: boolean;
|
|
172
|
+
// Whether the agent takes `session/close`, which ends a session it would otherwise keep.
|
|
173
|
+
closeSession: boolean;
|
|
129
174
|
images: boolean;
|
|
130
175
|
authMethods: string[];
|
|
131
176
|
agent: AcpAgentInfo | null;
|
|
@@ -144,6 +189,7 @@ export function parseInitialize(result: unknown): AcpAgentCapabilities {
|
|
|
144
189
|
loadSession: capabilities?.loadSession === true,
|
|
145
190
|
resumeSession: sessions?.resume !== undefined && sessions.resume !== null,
|
|
146
191
|
additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
|
|
192
|
+
closeSession: sessions?.close !== undefined && sessions.close !== null,
|
|
147
193
|
images: capabilities?.promptCapabilities?.image === true,
|
|
148
194
|
authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
|
|
149
195
|
agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
|
|
@@ -436,11 +482,22 @@ function toolOutput(tool: ToolState): string | null {
|
|
|
436
482
|
return null;
|
|
437
483
|
}
|
|
438
484
|
|
|
485
|
+
/* What an agent asks permission for, in the plain form a host's `autoAllow` judges it by. */
|
|
486
|
+
export interface AcpPermissionRequest {
|
|
487
|
+
// The ACP tool kind (`execute`, `edit`, `fetch`, ...), or null when the agent names none.
|
|
488
|
+
kind: string | null;
|
|
489
|
+
title: string | null;
|
|
490
|
+
// Every text the call carries that may be what it runs: the command in its raw input and each text in its
|
|
491
|
+
// content. The agent decides which one it runs, so a host allows the call only when every one passes.
|
|
492
|
+
commands: string[];
|
|
493
|
+
}
|
|
494
|
+
|
|
439
495
|
/* A permission request as the backend acts on it: the event it raises, and the tool kind a runtime mode may answer it by. */
|
|
440
496
|
export interface AcpPermission {
|
|
441
497
|
requestId: string;
|
|
442
498
|
rpcId: number | string;
|
|
443
499
|
kind: string | null;
|
|
500
|
+
request: AcpPermissionRequest;
|
|
444
501
|
events: BackendEvent[];
|
|
445
502
|
}
|
|
446
503
|
|
|
@@ -558,7 +615,9 @@ export class AcpProtocol {
|
|
|
558
615
|
description: tool.title === '' ? null : tool.title,
|
|
559
616
|
canAllowAlways: options.some((option) => option.kind === 'allow_always')
|
|
560
617
|
});
|
|
561
|
-
|
|
618
|
+
const command = rawField(tool.rawInput, 'command', 'cmd');
|
|
619
|
+
const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
|
|
620
|
+
return { requestId, rpcId, kind: tool.kind, request: asked, events };
|
|
562
621
|
}
|
|
563
622
|
|
|
564
623
|
/*
|
package/src/chat/index.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
|
|
2
|
-
export { type AcpAgentSpec, type AcpClientInfo, createAcpBackend } from './acp-backend.ts';
|
|
3
|
-
export type {
|
|
2
|
+
export { type AcpAgentSpec, type AcpClientInfo, type AcpUsageReport, createAcpBackend } from './acp-backend.ts';
|
|
3
|
+
export type {
|
|
4
|
+
AcpAgentInfo,
|
|
5
|
+
AcpModel,
|
|
6
|
+
AcpModelState,
|
|
7
|
+
AcpMode,
|
|
8
|
+
AcpModeState,
|
|
9
|
+
AcpPermissionRequest,
|
|
10
|
+
AcpSessionInfo,
|
|
11
|
+
AcpModeMap,
|
|
12
|
+
AcpTurnUsage
|
|
13
|
+
} from './acp-protocol.ts';
|
|
4
14
|
export { AttachmentStore } from './attachment-store.ts';
|
|
5
15
|
export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
|
|
6
16
|
export { runningInBackground, type BackgroundWork } from './background-work.ts';
|
|
@@ -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,8 @@
|
|
|
1
1
|
import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
|
|
2
|
-
import { createAcpBackend, type AcpAgentSpec } from '../chat/acp-backend.ts';
|
|
2
|
+
import { createAcpBackend, type AcpAgentSpec, type AcpUsageReport } from '../chat/acp-backend.ts';
|
|
3
|
+
import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
|
|
4
|
+
import { askAcpAccount } from './accounts/acp-account.ts';
|
|
5
|
+
import { writeAcpUsage } from './acp-usage-log.ts';
|
|
3
6
|
import type { ModelCatalog } from './catalog.ts';
|
|
4
7
|
import { detectCli, type CliDetection } from './detect.ts';
|
|
5
8
|
import type { ChatProvider, ProviderHome } from './provider.ts';
|
|
@@ -47,10 +50,38 @@ export interface AcpProviderSpec extends AcpAgentSpec {
|
|
|
47
50
|
// Absent asks the executable for `--version`.
|
|
48
51
|
detect?(command: string, env: Record<string, string | undefined>): Promise<CliDetection>;
|
|
49
52
|
enabled?(): boolean;
|
|
53
|
+
// Absent or true asks the agent over ACP whether an account is signed in; false leaves that to the host.
|
|
54
|
+
accountProbe?: boolean;
|
|
55
|
+
// Absent or true lets the models a session reports replace the catalog's; false keeps the catalog as the host built it.
|
|
56
|
+
adoptModels?: boolean;
|
|
57
|
+
// The folder the usage of every turn the agent reports is logged to, for a usage root of the agent's kind; absent logs nothing.
|
|
58
|
+
usageLog?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Agents whose own usage files the scanner reads; a log of their turns would count every one twice. */
|
|
62
|
+
const OWN_USAGE_FILES: ReadonlySet<AgentKind> = new Set(['grok']);
|
|
63
|
+
|
|
64
|
+
/* An agent's model id may reach a command line, so one that reads as a flag is left out. */
|
|
65
|
+
function adoptableModels(info: AcpSessionInfo): Array<{ slug: string; name: string }> {
|
|
66
|
+
return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
53
70
|
const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
|
|
71
|
+
const onSession = (info: AcpSessionInfo): void => {
|
|
72
|
+
if (spec.adoptModels !== false && info.models !== null) {
|
|
73
|
+
spec.catalog.adopt(adoptableModels(info), info.models.current);
|
|
74
|
+
}
|
|
75
|
+
spec.onSession?.(info);
|
|
76
|
+
};
|
|
77
|
+
const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
|
|
78
|
+
const onUsage =
|
|
79
|
+
usageLog === null
|
|
80
|
+
? spec.onUsage
|
|
81
|
+
: (report: AcpUsageReport): void => {
|
|
82
|
+
writeAcpUsage(usageLog, report, Date.now());
|
|
83
|
+
spec.onUsage?.(report);
|
|
84
|
+
};
|
|
54
85
|
return {
|
|
55
86
|
kind: spec.kind,
|
|
56
87
|
name: spec.name,
|
|
@@ -70,6 +101,18 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
|
70
101
|
throw new Error(`${spec.name} is only available as a chat.`);
|
|
71
102
|
}),
|
|
72
103
|
detect: spec.detect ?? ((command, env) => detectCli(command, env)),
|
|
73
|
-
|
|
104
|
+
...(spec.accountProbe === false
|
|
105
|
+
? {}
|
|
106
|
+
: {
|
|
107
|
+
askAccount: (command, env) =>
|
|
108
|
+
askAcpAccount({
|
|
109
|
+
name: spec.name,
|
|
110
|
+
command,
|
|
111
|
+
env: { ...env, ...spec.env },
|
|
112
|
+
...(spec.client ? { client: spec.client } : {}),
|
|
113
|
+
onSession
|
|
114
|
+
})
|
|
115
|
+
}),
|
|
116
|
+
createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
|
|
74
117
|
};
|
|
75
118
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { AcpUsageReport } from '../chat/acp-backend.ts';
|
|
4
|
+
import { errorText } from '../error-text.ts';
|
|
5
|
+
import { hostUsageLine } from '../usage/readers/host-log.ts';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Appends one turn to the session's own file in the folder, so a scan that hashes what it already read
|
|
9
|
+
* of a file never rereads the log of every other session. Written at once, since it is one line per turn.
|
|
10
|
+
*/
|
|
11
|
+
export function writeAcpUsage(folder: string, report: AcpUsageReport, at: number): void {
|
|
12
|
+
const { sessionId, cwd, model, ...tokens } = report;
|
|
13
|
+
try {
|
|
14
|
+
mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
15
|
+
appendFileSync(
|
|
16
|
+
join(folder, `${encodeURIComponent(sessionId === '' ? 'session' : sessionId)}.jsonl`),
|
|
17
|
+
hostUsageLine({ at, model, session: sessionId, cwd, ...tokens }),
|
|
18
|
+
{
|
|
19
|
+
mode: 0o600
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
// A turn whose line could not be written is missing from the usage page; the chat itself goes on.
|
|
24
|
+
console.warn('Could not write the usage of an ACP turn:', errorText(e));
|
|
25
|
+
}
|
|
26
|
+
}
|
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';
|
|
@@ -12,5 +13,6 @@ export { type CodexRules, codexRulesText, codexRulesPathIn, defaultCodexHome, in
|
|
|
12
13
|
export { codexThreadOptions, codexServiceTier } from './codex.ts';
|
|
13
14
|
export { type CliDetection, detectCli } from './detect.ts';
|
|
14
15
|
export type { ChatProvider, ProviderHome } from './provider.ts';
|
|
16
|
+
export { isOnlyCommands } from './shell-commands.ts';
|
|
15
17
|
export { ProviderRegistry, type ProviderRegistryOptions } from './registry.ts';
|
|
16
18
|
export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.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
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Unquoted, each of these makes the shell do more than run a word list: a pipe, a redirect, an expansion,
|
|
2
|
+
// a subshell or group, a glob, a comment or a history or negation mark.
|
|
3
|
+
const REFUSED = new Set(['|', '>', '<', '$', '`', '(', ')', '{', '}', '*', '?', '[', '~', '#', '!']);
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Whether a shell command line is only simple commands, joined by `&&`, `||`, `;` or newlines, each of
|
|
7
|
+
* which starts with one of `allowed`. Anything the tokenizer cannot prove simple answers false, so a
|
|
8
|
+
* true is safe to act on without a person: a pipe, a redirect, an expansion, a subshell, a background
|
|
9
|
+
* `&`, an unquoted glob or an unbalanced quote all refuse the whole line.
|
|
10
|
+
*/
|
|
11
|
+
export function isOnlyCommands(command: string, allowed: readonly string[]): boolean {
|
|
12
|
+
let word = '';
|
|
13
|
+
let inWord = false;
|
|
14
|
+
let words: string[] = [];
|
|
15
|
+
let commands = 0;
|
|
16
|
+
// A separator that still needs a command after it; a trailing `;` or newline needs none.
|
|
17
|
+
let dangling = false;
|
|
18
|
+
|
|
19
|
+
const endWord = (): void => {
|
|
20
|
+
if (inWord) {
|
|
21
|
+
words.push(word);
|
|
22
|
+
}
|
|
23
|
+
word = '';
|
|
24
|
+
inWord = false;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const endCommand = (separator: string): boolean => {
|
|
28
|
+
endWord();
|
|
29
|
+
if (words.length === 0) {
|
|
30
|
+
// Blank lines are fine, and so is a newline after `&&` or `||`; an empty command before any other separator is not.
|
|
31
|
+
return separator === '\n';
|
|
32
|
+
}
|
|
33
|
+
if (!allowed.includes(words[0]!)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
commands++;
|
|
37
|
+
words = [];
|
|
38
|
+
dangling = separator === '&&' || separator === '||';
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < command.length; i++) {
|
|
43
|
+
const char = command[i]!;
|
|
44
|
+
const next = command[i + 1];
|
|
45
|
+
if (char === ' ' || char === '\t') {
|
|
46
|
+
endWord();
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (char === '\n' || char === ';') {
|
|
50
|
+
if (!endCommand(char)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if ((char === '&' || char === '|') && next === char) {
|
|
56
|
+
if (!endCommand(char + next)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
i++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (char === '&' || REFUSED.has(char)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (char === '\\') {
|
|
66
|
+
if (next === undefined) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
i++;
|
|
70
|
+
if (next !== '\n') {
|
|
71
|
+
word += next;
|
|
72
|
+
inWord = true;
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (char === "'") {
|
|
77
|
+
const close = command.indexOf("'", i + 1);
|
|
78
|
+
if (close === -1) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
word += command.slice(i + 1, close);
|
|
82
|
+
inWord = true;
|
|
83
|
+
i = close;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (char === '"') {
|
|
87
|
+
inWord = true;
|
|
88
|
+
let closed = false;
|
|
89
|
+
for (i++; i < command.length; i++) {
|
|
90
|
+
const quoted = command[i]!;
|
|
91
|
+
if (quoted === '"') {
|
|
92
|
+
closed = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
if (quoted === '$' || quoted === '`') {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (quoted === '\\') {
|
|
99
|
+
const escaped = command[i + 1];
|
|
100
|
+
if (escaped === undefined) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
i++;
|
|
104
|
+
if (escaped === '"' || escaped === '\\' || escaped === '$' || escaped === '`') {
|
|
105
|
+
word += escaped;
|
|
106
|
+
} else if (escaped !== '\n') {
|
|
107
|
+
word += quoted + escaped;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
word += quoted;
|
|
112
|
+
}
|
|
113
|
+
if (!closed) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
word += char;
|
|
119
|
+
inWord = true;
|
|
120
|
+
}
|
|
121
|
+
return endCommand('\n') && commands > 0 && !dangling;
|
|
122
|
+
}
|