@adecore/agents 0.31.2 → 0.31.4
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 +49 -17
- package/dist/chat/acp-protocol.d.ts +26 -5
- package/dist/chat/acp-protocol.js +53 -5
- package/dist/chat/index.d.ts +2 -2
- package/dist/providers/acp-provider.d.ts +1 -0
- package/dist/providers/acp-provider.js +32 -2
- 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 +2 -2
- package/dist/providers/catalog.js +6 -3
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -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 +23 -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 +67 -17
- package/src/chat/acp-protocol.ts +95 -7
- package/src/chat/index.ts +14 -2
- package/src/providers/acp-provider.ts +39 -4
- package/src/providers/acp-usage-log.ts +26 -0
- package/src/providers/catalog.ts +7 -4
- package/src/providers/index.ts +1 -0
- package/src/providers/shell-commands.ts +122 -0
- package/src/testing/fake-acp.ts +29 -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())
|
|
@@ -65,7 +107,7 @@ const ModeStateSchema = z.looseObject({
|
|
|
65
107
|
|
|
66
108
|
const ModeSchema = z.looseObject({ id: z.string(), name: z.string() });
|
|
67
109
|
|
|
68
|
-
const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string() });
|
|
110
|
+
const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish() });
|
|
69
111
|
|
|
70
112
|
const ConfigOptionSchema = z.looseObject({
|
|
71
113
|
id: z.string(),
|
|
@@ -179,14 +221,27 @@ export interface AcpModeState {
|
|
|
179
221
|
configId: string | null;
|
|
180
222
|
}
|
|
181
223
|
|
|
224
|
+
export interface AcpChoice {
|
|
225
|
+
id: string;
|
|
226
|
+
name: string;
|
|
227
|
+
description: string | null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* The reasoning effort a session offers: the config option that sets it, the value it runs at and the values it takes. */
|
|
231
|
+
export interface AcpThoughtLevel {
|
|
232
|
+
configId: string;
|
|
233
|
+
current: string;
|
|
234
|
+
choices: AcpChoice[];
|
|
235
|
+
}
|
|
236
|
+
|
|
182
237
|
/* What `session/new`, `session/load` or `session/resume` said about the session, for a host that fills its catalog from it. */
|
|
183
238
|
export interface AcpSessionInfo {
|
|
184
239
|
sessionId: string;
|
|
185
240
|
agent: AcpAgentInfo | null;
|
|
186
241
|
models: AcpModelState | null;
|
|
187
242
|
modes: AcpModeState | null;
|
|
188
|
-
//
|
|
189
|
-
thoughtLevel:
|
|
243
|
+
// A config option of the `thought_level` category, or one with the id `reasoning_effort` where an agent sets no category.
|
|
244
|
+
thoughtLevel: AcpThoughtLevel | null;
|
|
190
245
|
}
|
|
191
246
|
|
|
192
247
|
type ConfigOption = z.infer<typeof ConfigOptionSchema>;
|
|
@@ -201,6 +256,22 @@ function selectOption(options: ConfigOption[], category: string): ConfigOption |
|
|
|
201
256
|
return options.find((option) => option.type === 'select' && option.category === category && typeof option.currentValue === 'string') ?? null;
|
|
202
257
|
}
|
|
203
258
|
|
|
259
|
+
function thoughtOption(options: ConfigOption[]): ConfigOption | null {
|
|
260
|
+
return (
|
|
261
|
+
selectOption(options, 'thought_level') ??
|
|
262
|
+
options.find((option) => option.type === 'select' && option.id === 'reasoning_effort' && typeof option.currentValue === 'string') ??
|
|
263
|
+
null
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* The reasoning effort in a response that carries the session's config options; undefined when it carries none. */
|
|
268
|
+
export function thoughtLevelOf(result: unknown): AcpThoughtLevel | null | undefined {
|
|
269
|
+
if (!isRecord(result) || !Array.isArray(result.configOptions)) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
return sessionState(validItems(ConfigOptionSchema, result.configOptions), null, null).thoughtLevel;
|
|
273
|
+
}
|
|
274
|
+
|
|
204
275
|
export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpAgentInfo | null): AcpSessionInfo {
|
|
205
276
|
const record = isRecord(result) ? result : {};
|
|
206
277
|
const options = validItems(ConfigOptionSchema, Array.isArray(record.configOptions) ? record.configOptions : []);
|
|
@@ -210,7 +281,7 @@ export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpA
|
|
|
210
281
|
function sessionState(options: ConfigOption[], modes: unknown, models: unknown): Omit<AcpSessionInfo, 'sessionId' | 'agent'> {
|
|
211
282
|
const modelOption = selectOption(options, 'model');
|
|
212
283
|
const modeOption = selectOption(options, 'mode');
|
|
213
|
-
const
|
|
284
|
+
const thought = thoughtOption(options);
|
|
214
285
|
const modeState = ModeStateSchema.safeParse(modes);
|
|
215
286
|
const modelState = ModelStateSchema.safeParse(models);
|
|
216
287
|
return {
|
|
@@ -244,8 +315,12 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
|
|
|
244
315
|
configId: null
|
|
245
316
|
}
|
|
246
317
|
: null,
|
|
247
|
-
thoughtLevel:
|
|
248
|
-
? {
|
|
318
|
+
thoughtLevel: thought
|
|
319
|
+
? {
|
|
320
|
+
configId: thought.id,
|
|
321
|
+
current: thought.currentValue as string,
|
|
322
|
+
choices: selectValues(thought).map((value) => ({ id: value.value, name: value.name, description: value.description ?? null }))
|
|
323
|
+
}
|
|
249
324
|
: null
|
|
250
325
|
};
|
|
251
326
|
}
|
|
@@ -440,11 +515,22 @@ function toolOutput(tool: ToolState): string | null {
|
|
|
440
515
|
return null;
|
|
441
516
|
}
|
|
442
517
|
|
|
518
|
+
/* What an agent asks permission for, in the plain form a host's `autoAllow` judges it by. */
|
|
519
|
+
export interface AcpPermissionRequest {
|
|
520
|
+
// The ACP tool kind (`execute`, `edit`, `fetch`, ...), or null when the agent names none.
|
|
521
|
+
kind: string | null;
|
|
522
|
+
title: string | null;
|
|
523
|
+
// Every text the call carries that may be what it runs: the command in its raw input and each text in its
|
|
524
|
+
// content. The agent decides which one it runs, so a host allows the call only when every one passes.
|
|
525
|
+
commands: string[];
|
|
526
|
+
}
|
|
527
|
+
|
|
443
528
|
/* A permission request as the backend acts on it: the event it raises, and the tool kind a runtime mode may answer it by. */
|
|
444
529
|
export interface AcpPermission {
|
|
445
530
|
requestId: string;
|
|
446
531
|
rpcId: number | string;
|
|
447
532
|
kind: string | null;
|
|
533
|
+
request: AcpPermissionRequest;
|
|
448
534
|
events: BackendEvent[];
|
|
449
535
|
}
|
|
450
536
|
|
|
@@ -562,7 +648,9 @@ export class AcpProtocol {
|
|
|
562
648
|
description: tool.title === '' ? null : tool.title,
|
|
563
649
|
canAllowAlways: options.some((option) => option.kind === 'allow_always')
|
|
564
650
|
});
|
|
565
|
-
|
|
651
|
+
const command = rawField(tool.rawInput, 'command', 'cmd');
|
|
652
|
+
const asked = { kind: tool.kind, title: tool.title === '' ? null : tool.title, commands: [...(command === null ? [] : [command]), ...tool.text] };
|
|
653
|
+
return { requestId, rpcId, kind: tool.kind, request: asked, events };
|
|
566
654
|
}
|
|
567
655
|
|
|
568
656
|
/*
|
package/src/chat/index.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
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
|
+
AcpChoice,
|
|
13
|
+
AcpThoughtLevel,
|
|
14
|
+
AcpTurnUsage
|
|
15
|
+
} from './acp-protocol.ts';
|
|
4
16
|
export { AttachmentStore } from './attachment-store.ts';
|
|
5
17
|
export type { BackendLaunch, TurnInput, ApprovalDecision, BackendEvent, BackendHost, ChatBackend } from './backend.ts';
|
|
6
18
|
export { runningInBackground, type BackgroundWork } from './background-work.ts';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
|
|
2
|
-
import { createAcpBackend, type AcpAgentSpec } from '../chat/acp-backend.ts';
|
|
1
|
+
import type { AgentKind, ModelOptionDescriptor, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
|
|
2
|
+
import { createAcpBackend, type AcpAgentSpec, type AcpUsageReport } from '../chat/acp-backend.ts';
|
|
3
3
|
import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
|
|
4
4
|
import { askAcpAccount } from './accounts/acp-account.ts';
|
|
5
|
+
import { writeAcpUsage } from './acp-usage-log.ts';
|
|
5
6
|
import type { ModelCatalog } from './catalog.ts';
|
|
6
7
|
import { detectCli, type CliDetection } from './detect.ts';
|
|
7
8
|
import type { ChatProvider, ProviderHome } from './provider.ts';
|
|
@@ -53,21 +54,55 @@ export interface AcpProviderSpec extends AcpAgentSpec {
|
|
|
53
54
|
accountProbe?: boolean;
|
|
54
55
|
// Absent or true lets the models a session reports replace the catalog's; false keeps the catalog as the host built it.
|
|
55
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;
|
|
56
59
|
}
|
|
57
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
|
+
|
|
58
64
|
/* An agent's model id may reach a command line, so one that reads as a flag is left out. */
|
|
59
65
|
function adoptableModels(info: AcpSessionInfo): Array<{ slug: string; name: string }> {
|
|
60
66
|
return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
/* The reasoning effort a session offers, as the `effort` option the composer draws for every provider. */
|
|
70
|
+
function effortOption(info: AcpSessionInfo): ModelOptionDescriptor[] {
|
|
71
|
+
const level = info.thoughtLevel;
|
|
72
|
+
if (level === null || !level.choices.some((choice) => choice.id === level.current)) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
id: 'effort',
|
|
78
|
+
label: 'Reasoning',
|
|
79
|
+
type: 'select',
|
|
80
|
+
defaultChoice: level.current,
|
|
81
|
+
choices: level.choices.map((choice) => ({
|
|
82
|
+
id: choice.id,
|
|
83
|
+
label: choice.name,
|
|
84
|
+
...(choice.description === null ? {} : { description: choice.description })
|
|
85
|
+
}))
|
|
86
|
+
}
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
64
91
|
const capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
|
|
65
92
|
const onSession = (info: AcpSessionInfo): void => {
|
|
66
93
|
if (spec.adoptModels !== false && info.models !== null) {
|
|
67
|
-
spec.catalog.adopt(adoptableModels(info), info.models.current);
|
|
94
|
+
spec.catalog.adopt(adoptableModels(info), info.models.current, effortOption(info));
|
|
68
95
|
}
|
|
69
96
|
spec.onSession?.(info);
|
|
70
97
|
};
|
|
98
|
+
const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
|
|
99
|
+
const onUsage =
|
|
100
|
+
usageLog === null
|
|
101
|
+
? spec.onUsage
|
|
102
|
+
: (report: AcpUsageReport): void => {
|
|
103
|
+
writeAcpUsage(usageLog, report, Date.now());
|
|
104
|
+
spec.onUsage?.(report);
|
|
105
|
+
};
|
|
71
106
|
return {
|
|
72
107
|
kind: spec.kind,
|
|
73
108
|
name: spec.name,
|
|
@@ -99,6 +134,6 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
|
99
134
|
onSession
|
|
100
135
|
})
|
|
101
136
|
}),
|
|
102
|
-
createBackend: createAcpBackend({ ...spec, onSession })
|
|
137
|
+
createBackend: createAcpBackend({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
|
|
103
138
|
};
|
|
104
139
|
}
|
|
@@ -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModelCatalogData, ModelCatalogProfile, ModelInfo, ModelSelection } from '@adecore/agent-contracts';
|
|
1
|
+
import type { ModelCatalogData, ModelCatalogProfile, ModelInfo, ModelOptionDescriptor, ModelSelection } from '@adecore/agent-contracts';
|
|
2
2
|
import manifest from './claude-models.json' with { type: 'json' };
|
|
3
3
|
|
|
4
4
|
// What a host builds a catalog from; one that is never replaced needs no date.
|
|
@@ -41,13 +41,16 @@ export class ModelCatalog {
|
|
|
41
41
|
/*
|
|
42
42
|
* Takes the models an agent reported for itself in place of the list, the agent's current model as the
|
|
43
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
|
|
44
|
+
* default has now, as an agent names its models but not what each can be asked for, with `options`
|
|
45
|
+
* in place of the profile's options of the same id.
|
|
45
46
|
*/
|
|
46
|
-
adopt(models: ReadonlyArray<{ slug: string; name: string }>, defaultModel: string): boolean {
|
|
47
|
+
adopt(models: ReadonlyArray<{ slug: string; name: string }>, defaultModel: string, options: readonly ModelOptionDescriptor[] = []): boolean {
|
|
47
48
|
if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
|
|
48
49
|
return false;
|
|
49
50
|
}
|
|
50
|
-
const
|
|
51
|
+
const base = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
|
|
52
|
+
const replaced = new Set(options.map((option) => option.id));
|
|
53
|
+
const profile = { ...base, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
|
|
51
54
|
const data: ModelCatalogInput = {
|
|
52
55
|
...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
|
|
53
56
|
defaultModel,
|
package/src/providers/index.ts
CHANGED
|
@@ -13,5 +13,6 @@ export { type CodexRules, codexRulesText, codexRulesPathIn, defaultCodexHome, in
|
|
|
13
13
|
export { codexThreadOptions, codexServiceTier } from './codex.ts';
|
|
14
14
|
export { type CliDetection, detectCli } from './detect.ts';
|
|
15
15
|
export type { ChatProvider, ProviderHome } from './provider.ts';
|
|
16
|
+
export { isOnlyCommands } from './shell-commands.ts';
|
|
16
17
|
export { ProviderRegistry, type ProviderRegistryOptions } from './registry.ts';
|
|
17
18
|
export { MODE_ORDER, narrowerMode, ceilingForOpening, modeFlag } from '../modes.ts';
|
|
@@ -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
|
+
}
|
package/src/testing/fake-acp.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface FakeAcpOptions {
|
|
|
22
22
|
legacyModels?: boolean;
|
|
23
23
|
// Advertises and answers `session/close`.
|
|
24
24
|
closeSession?: boolean;
|
|
25
|
+
// The `usage` every `session/prompt` response carries; absent reports none.
|
|
26
|
+
turnUsage?: Record<string, number>;
|
|
27
|
+
// Offers a reasoning effort as the config option `reasoning_effort`, without a category: Low and High, and
|
|
28
|
+
// Extra high on `fake-large`. `reject` refuses every change to it.
|
|
29
|
+
reasoningEffort?: 'accept' | 'reject';
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
@@ -31,6 +36,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
|
31
36
|
let sessionId = '';
|
|
32
37
|
let mode = modes[0] ?? '';
|
|
33
38
|
let model = 'fake-small';
|
|
39
|
+
let effort = 'low';
|
|
34
40
|
let serverRequestId = 0;
|
|
35
41
|
let callCounter = 0;
|
|
36
42
|
let prompt: { rpcId: number | string; cancelled: boolean } | null = null;
|
|
@@ -63,7 +69,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
|
63
69
|
return;
|
|
64
70
|
}
|
|
65
71
|
update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
|
|
66
|
-
respond(prompt.rpcId, { stopReason });
|
|
72
|
+
respond(prompt.rpcId, { stopReason, ...(options.turnUsage === undefined ? {} : { usage: options.turnUsage }) });
|
|
67
73
|
prompt = null;
|
|
68
74
|
};
|
|
69
75
|
|
|
@@ -72,11 +78,25 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
|
72
78
|
{ value: 'fake-small', name: 'Fake small' },
|
|
73
79
|
{ value: 'fake-large', name: 'Fake large' }
|
|
74
80
|
];
|
|
81
|
+
const effortChoices = [
|
|
82
|
+
{ value: 'low', name: 'Low', description: 'Answers quickly' },
|
|
83
|
+
{ value: 'high', name: 'High' },
|
|
84
|
+
...(model === 'fake-large' ? [{ value: 'xhigh', name: 'Extra high' }] : [])
|
|
85
|
+
];
|
|
86
|
+
const effortOption =
|
|
87
|
+
options.reasoningEffort === undefined
|
|
88
|
+
? []
|
|
89
|
+
: [{ id: 'reasoning_effort', name: 'Reasoning effort', type: 'select', currentValue: effort, options: effortChoices }];
|
|
75
90
|
return {
|
|
76
91
|
...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
|
|
77
92
|
...(options.legacyModels
|
|
78
93
|
? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId: choice.value, name: choice.name })) } }
|
|
79
|
-
: {
|
|
94
|
+
: {
|
|
95
|
+
configOptions: [
|
|
96
|
+
{ id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices },
|
|
97
|
+
...effortOption
|
|
98
|
+
]
|
|
99
|
+
})
|
|
80
100
|
};
|
|
81
101
|
};
|
|
82
102
|
|
|
@@ -275,6 +295,13 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
|
275
295
|
if (params.configId === 'model') {
|
|
276
296
|
model = String(params.value);
|
|
277
297
|
}
|
|
298
|
+
if (params.configId === 'reasoning_effort') {
|
|
299
|
+
if (options.reasoningEffort === 'reject') {
|
|
300
|
+
refuse(id, -32602, 'Reasoning effort is locked');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
effort = String(params.value);
|
|
304
|
+
}
|
|
278
305
|
respond(id, { configOptions: sessionState().configOptions ?? [] });
|
|
279
306
|
return;
|
|
280
307
|
case 'session/prompt':
|
package/src/usage/aggregate.ts
CHANGED
|
@@ -112,7 +112,7 @@ function foldBucket(
|
|
|
112
112
|
}
|
|
113
113
|
bucket.totals = addTotals(bucket.totals, record.totals);
|
|
114
114
|
bucket.costUsd += cost;
|
|
115
|
-
bucket.priced ||= price !== null;
|
|
115
|
+
bucket.priced ||= price !== null || record.costUsd !== undefined;
|
|
116
116
|
bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
|
|
117
117
|
if (record.sessionId !== '') {
|
|
118
118
|
bucket.sessions.add(record.sessionId);
|
|
@@ -130,14 +130,15 @@ function foldModel(models: Map<string, ModelRow>, record: UsageRecord, account:
|
|
|
130
130
|
totals: emptyTotals(),
|
|
131
131
|
costUsd: 0,
|
|
132
132
|
priced: false,
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
// A cost the CLI reported is the bill itself, not a price looked up for the model.
|
|
134
|
+
basis: record.costUsd === undefined ? lookup.basis : 'exact',
|
|
135
|
+
pricedAs: record.costUsd === undefined ? lookup.pricedAs : null
|
|
135
136
|
};
|
|
136
137
|
models.set(key, model);
|
|
137
138
|
}
|
|
138
139
|
model.totals = addTotals(model.totals, record.totals);
|
|
139
140
|
model.costUsd += cost;
|
|
140
|
-
model.priced ||= lookup.price !== null;
|
|
141
|
+
model.priced ||= lookup.price !== null || record.costUsd !== undefined;
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
function foldProject(project: ProjectAccumulator, record: UsageRecord, cost: number): void {
|
|
@@ -186,7 +187,7 @@ export async function aggregate(
|
|
|
186
187
|
continue;
|
|
187
188
|
}
|
|
188
189
|
const lookup = prices.look(record.model);
|
|
189
|
-
const cost = lookup.price === null ? 0 : costOf(record.totals, lookup.price);
|
|
190
|
+
const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
|
|
190
191
|
foldBucket(buckets, slot, record, account, lookup.price, cost);
|
|
191
192
|
foldModel(models, record, account, lookup, cost);
|
|
192
193
|
if (record.sessionId !== '') {
|
package/src/usage/index-file.ts
CHANGED
|
@@ -30,8 +30,9 @@ export type UsageIndex = Map<string, IndexedFile>;
|
|
|
30
30
|
* directory and account strings over and over. Interning those and writing a record as a tuple is
|
|
31
31
|
* what keeps the file at a few megabytes instead of tens of them. The account is one past its place
|
|
32
32
|
* in its table, and 0 for the default account, which is also what a version 1 row without it means.
|
|
33
|
+
* A cost the CLI reported itself rides last, only on a row that has one, so every other row keeps its length.
|
|
33
34
|
*/
|
|
34
|
-
type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?];
|
|
35
|
+
type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?];
|
|
35
36
|
|
|
36
37
|
interface FileRow {
|
|
37
38
|
p: UsageProvider;
|
|
@@ -75,21 +76,27 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
75
76
|
const folders = new Interner();
|
|
76
77
|
const accounts = new Interner();
|
|
77
78
|
const files: Record<string, FileRow> = {};
|
|
78
|
-
const row = (record: UsageRecord): RecordRow =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
79
|
+
const row = (record: UsageRecord): RecordRow => {
|
|
80
|
+
const counted: RecordRow = [
|
|
81
|
+
record.timestampMs,
|
|
82
|
+
models.index(record.model),
|
|
83
|
+
sessions.index(record.sessionId),
|
|
84
|
+
folders.index(record.cwd),
|
|
85
|
+
record.totals.calls,
|
|
86
|
+
record.totals.input,
|
|
87
|
+
record.totals.cacheRead,
|
|
88
|
+
record.totals.cacheWrite,
|
|
89
|
+
record.totals.cacheWrite1h,
|
|
90
|
+
record.totals.output,
|
|
91
|
+
record.totals.reasoning,
|
|
92
|
+
record.dedupeKey ?? 0,
|
|
93
|
+
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
94
|
+
];
|
|
95
|
+
if (record.costUsd !== undefined) {
|
|
96
|
+
counted.push(record.costUsd);
|
|
97
|
+
}
|
|
98
|
+
return counted;
|
|
99
|
+
};
|
|
93
100
|
for (const [path, file] of index) {
|
|
94
101
|
files[path] = {
|
|
95
102
|
p: file.provider,
|
|
@@ -116,10 +123,11 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
116
123
|
function isRow(row: unknown, version: number): row is RecordRow {
|
|
117
124
|
return (
|
|
118
125
|
Array.isArray(row) &&
|
|
119
|
-
|
|
126
|
+
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length === 13 || row.length === 14) &&
|
|
120
127
|
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
121
128
|
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
122
|
-
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number')
|
|
129
|
+
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
|
|
130
|
+
(row.length < 14 || typeof row[13] === 'number')
|
|
123
131
|
);
|
|
124
132
|
}
|
|
125
133
|
|
|
@@ -176,6 +184,7 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
176
184
|
cwd,
|
|
177
185
|
...(account === undefined ? {} : { account }),
|
|
178
186
|
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
187
|
+
...(row[13] === undefined ? {} : { costUsd: row[13] }),
|
|
179
188
|
dedupeKey: row[11] === 0 ? null : row[11]
|
|
180
189
|
});
|
|
181
190
|
}
|
|
@@ -15,6 +15,8 @@ const MAX_BACKOFF_MS = 60 * 60_000;
|
|
|
15
15
|
const RECENT_USE_MS = 24 * 60 * 60_000;
|
|
16
16
|
|
|
17
17
|
const PROVIDERS: readonly UsageProvider[] = USAGE_PROVIDERS;
|
|
18
|
+
/* The CLIs the probes know how to ask; any other provider has a plan only through the host's `plans`. */
|
|
19
|
+
const PROBED: readonly UsageProvider[] = ['claude', 'codex'];
|
|
18
20
|
|
|
19
21
|
export type ProbeEnv = Record<string, string>;
|
|
20
22
|
|
|
@@ -167,6 +169,9 @@ export class UsageMonitor {
|
|
|
167
169
|
private listed(): LimitAccount[] {
|
|
168
170
|
const accounts = this.accounts?.list() ?? PROVIDERS.map((kind) => ({ id: kind, kind, label: kind, isDefault: true }));
|
|
169
171
|
return PROVIDERS.flatMap((kind) => {
|
|
172
|
+
if (!PROBED.includes(kind) && this.plans[kind] === undefined) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
170
175
|
const own = accounts.filter((account) => account.kind === kind);
|
|
171
176
|
return [...own.filter((account) => account.isDefault), ...own.filter((account) => !account.isDefault)];
|
|
172
177
|
});
|