@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.
Files changed (65) hide show
  1. package/dist/chat/acp-backend.d.ts +59 -0
  2. package/dist/chat/acp-backend.js +407 -0
  3. package/dist/chat/acp-protocol.d.ts +131 -0
  4. package/dist/chat/acp-protocol.js +605 -0
  5. package/dist/chat/codex-transport.d.ts +4 -27
  6. package/dist/chat/codex-transport.js +4 -185
  7. package/dist/chat/fixtures/acp-v1-turn.json +240 -0
  8. package/dist/chat/index.d.ts +2 -0
  9. package/dist/chat/index.js +1 -0
  10. package/dist/chat/json-rpc-transport.d.ts +50 -0
  11. package/dist/chat/json-rpc-transport.js +203 -0
  12. package/dist/providers/accounts/accounts.d.ts +10 -6
  13. package/dist/providers/accounts/accounts.js +19 -19
  14. package/dist/providers/accounts/service.d.ts +3 -0
  15. package/dist/providers/accounts/service.js +33 -17
  16. package/dist/providers/accounts/store.d.ts +4 -5
  17. package/dist/providers/accounts/store.js +12 -12
  18. package/dist/providers/acp-provider.d.ts +22 -0
  19. package/dist/providers/acp-provider.js +45 -0
  20. package/dist/providers/catalog.js +1 -0
  21. package/dist/providers/index.d.ts +1 -0
  22. package/dist/providers/index.js +1 -0
  23. package/dist/providers/provider.d.ts +1 -1
  24. package/dist/providers/registry.d.ts +1 -0
  25. package/dist/providers/registry.js +4 -0
  26. package/dist/testing/fake-acp.d.ts +10 -0
  27. package/dist/testing/fake-acp.js +262 -0
  28. package/dist/testing/index.d.ts +1 -0
  29. package/dist/testing/index.js +1 -0
  30. package/dist/usage/index-file.d.ts +1 -1
  31. package/dist/usage/index-file.js +3 -1
  32. package/dist/usage/index.d.ts +2 -1
  33. package/dist/usage/index.js +2 -0
  34. package/dist/usage/limits/monitor.d.ts +3 -1
  35. package/dist/usage/limits/monitor.js +19 -4
  36. package/dist/usage/limits/normalize.d.ts +1 -0
  37. package/dist/usage/limits/normalize.js +2 -1
  38. package/dist/usage/readers/host-log.d.ts +16 -0
  39. package/dist/usage/readers/host-log.js +49 -0
  40. package/dist/usage/roots.js +6 -2
  41. package/dist/usage/scanner.js +13 -2
  42. package/package.json +3 -3
  43. package/src/chat/acp-backend.ts +476 -0
  44. package/src/chat/acp-protocol.ts +763 -0
  45. package/src/chat/codex-transport.ts +6 -207
  46. package/src/chat/fixtures/acp-v1-turn.json +240 -0
  47. package/src/chat/index.ts +2 -0
  48. package/src/chat/json-rpc-transport.ts +253 -0
  49. package/src/providers/accounts/accounts.ts +28 -25
  50. package/src/providers/accounts/service.ts +49 -18
  51. package/src/providers/accounts/store.ts +14 -17
  52. package/src/providers/acp-provider.ts +73 -0
  53. package/src/providers/catalog.ts +1 -0
  54. package/src/providers/index.ts +1 -0
  55. package/src/providers/provider.ts +2 -1
  56. package/src/providers/registry.ts +5 -0
  57. package/src/testing/fake-acp.ts +296 -0
  58. package/src/testing/index.ts +1 -0
  59. package/src/usage/index-file.ts +3 -2
  60. package/src/usage/index.ts +2 -1
  61. package/src/usage/limits/monitor.ts +24 -5
  62. package/src/usage/limits/normalize.ts +2 -1
  63. package/src/usage/readers/host-log.ts +72 -0
  64. package/src/usage/roots.ts +7 -3
  65. package/src/usage/scanner.ts +16 -5
@@ -1,4 +1,5 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
+ export { ACP_CAPABILITIES, createAcpProvider } from './acp-provider.js';
2
3
  export { AccountError, storedAccount, launchEnv } from './accounts/launch.js';
3
4
  export { ProviderAccountsService } from './accounts/service.js';
4
5
  export { variablesProblem, keychainService } from './accounts/variables.js';
@@ -6,7 +6,7 @@ export interface ProviderHome {
6
6
  readonly env: string;
7
7
  readonly fallback: string;
8
8
  readonly loginEnv: readonly string[];
9
- readonly loginCommand: string;
9
+ readonly loginCommand?: string;
10
10
  }
11
11
  export interface ChatProvider {
12
12
  readonly kind: AgentKind;
@@ -18,6 +18,7 @@ export declare class ProviderRegistry {
18
18
  private readonly cache;
19
19
  constructor(options?: ProviderRegistryOptions);
20
20
  list(): Promise<ProviderInfo[]>;
21
+ kinds(): AgentKind[];
21
22
  get(kind: AgentKind): ChatProvider;
22
23
  oneShotProvider(preferred?: AgentKind): Promise<ChatProvider | null>;
23
24
  catalogFor(kind: AgentKind): ModelCatalog;
@@ -68,6 +68,10 @@ export class ProviderRegistry {
68
68
  resumeCommand: provider.resumeCommand
69
69
  })));
70
70
  }
71
+ /* The kinds this host offers, in the order every menu lists them. */
72
+ kinds() {
73
+ return this.providers.map((provider) => provider.kind);
74
+ }
71
75
  /* The provider of a kind, one this host does not offer included. */
72
76
  get(kind) {
73
77
  return this.providers.find((provider) => provider.kind === kind) ?? this.fallback(kind);
@@ -0,0 +1,10 @@
1
+ import type { FakeCli } from './fake-cli.ts';
2
+ export interface FakeAcpOptions {
3
+ loadSession?: boolean;
4
+ resumeSession?: boolean;
5
+ authMethod?: string;
6
+ modes?: readonly string[];
7
+ legacyModels?: boolean;
8
+ }
9
+ export declare function fakeAcpWith(options: FakeAcpOptions): FakeCli;
10
+ export declare const fakeAcp: FakeCli;
@@ -0,0 +1,262 @@
1
+ // The prompts of every session, outside any one process: a real agent keeps them on disk, so a later process loads them too.
2
+ const sessionHistory = new Map();
3
+ export function fakeAcpWith(options) {
4
+ return (io) => {
5
+ const modes = options.modes ?? ['ask', 'edit', 'yolo'];
6
+ let authenticated = options.authMethod === undefined;
7
+ let sessionId = '';
8
+ let mode = modes[0] ?? '';
9
+ let model = 'fake-small';
10
+ let serverRequestId = 0;
11
+ let callCounter = 0;
12
+ let prompt = null;
13
+ let permission = null;
14
+ const send = (frame) => {
15
+ io.out({ jsonrpc: '2.0', ...frame });
16
+ };
17
+ const respond = (id, result) => {
18
+ send({ id, result });
19
+ };
20
+ const refuse = (id, code, message) => {
21
+ send({ id, error: { code, message } });
22
+ };
23
+ const update = (fields) => {
24
+ send({ method: 'session/update', params: { sessionId, update: fields } });
25
+ };
26
+ const say = (text) => {
27
+ const half = Math.ceil(text.length / 2);
28
+ update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: text.slice(0, half) } });
29
+ update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: text.slice(half) } });
30
+ };
31
+ const endTurn = (stopReason) => {
32
+ if (prompt === null) {
33
+ return;
34
+ }
35
+ update({ sessionUpdate: 'usage_update', used: 1200, size: 200000, cost: { amount: 0.01, currency: 'USD' } });
36
+ respond(prompt.rpcId, { stopReason });
37
+ prompt = null;
38
+ };
39
+ const sessionState = () => {
40
+ const modelChoices = [
41
+ { value: 'fake-small', name: 'Fake small' },
42
+ { value: 'fake-large', name: 'Fake large' }
43
+ ];
44
+ return {
45
+ ...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
46
+ ...(options.legacyModels
47
+ ? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId: choice.value, name: choice.name })) } }
48
+ : { configOptions: [{ id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices }] })
49
+ };
50
+ };
51
+ const askPermission = (toolCall, onAnswer) => {
52
+ const rpcId = serverRequestId++;
53
+ permission = { rpcId, onAnswer };
54
+ send({
55
+ id: rpcId,
56
+ method: 'session/request_permission',
57
+ params: {
58
+ sessionId,
59
+ toolCall,
60
+ options: [
61
+ { optionId: 'once', name: 'Allow once', kind: 'allow_once' },
62
+ { optionId: 'always', name: 'Always allow', kind: 'allow_always' },
63
+ { optionId: 'no', name: 'Reject', kind: 'reject_once' }
64
+ ]
65
+ }
66
+ });
67
+ };
68
+ const handlePrompt = (id, params) => {
69
+ const blocks = Array.isArray(params.prompt) ? params.prompt : [];
70
+ const text = blocks
71
+ .filter((block) => block.type === 'text')
72
+ .map((block) => String(block.text))
73
+ .join('\n');
74
+ const images = blocks.filter((block) => block.type === 'image').length;
75
+ const history = sessionHistory.get(sessionId) ?? [];
76
+ sessionHistory.set(sessionId, [...history, text]);
77
+ prompt = { rpcId: id, cancelled: false };
78
+ const last = text.split('\n\n').at(-1) ?? text;
79
+ if (last === 'history?') {
80
+ say(`prompts before: ${history.length}`);
81
+ endTurn('end_turn');
82
+ return;
83
+ }
84
+ if (last === 'mode?') {
85
+ say(`mode: ${mode}, model: ${model}`);
86
+ endTurn('end_turn');
87
+ return;
88
+ }
89
+ if (last === 'crash') {
90
+ io.exit(1);
91
+ return;
92
+ }
93
+ if (last === 'slow') {
94
+ say('working');
95
+ return;
96
+ }
97
+ if (last === 'plan') {
98
+ update({
99
+ sessionUpdate: 'plan',
100
+ entries: [
101
+ { content: 'Read the code', priority: 'high', status: 'completed' },
102
+ { content: 'Write the fix', priority: 'medium', status: 'in_progress' }
103
+ ]
104
+ });
105
+ endTurn('end_turn');
106
+ return;
107
+ }
108
+ if (last === 'refuse' || last === 'max tokens') {
109
+ say('no');
110
+ endTurn(last === 'refuse' ? 'refusal' : 'max_tokens');
111
+ return;
112
+ }
113
+ if (last.startsWith('edit:')) {
114
+ const path = last.slice(5).trim();
115
+ const toolCallId = `call-${++callCounter}`;
116
+ const content = [{ type: 'diff', path, oldText: 'one\ntwo\n', newText: 'one\nthree\n' }];
117
+ update({ sessionUpdate: 'tool_call', toolCallId, title: `Edit ${path}`, kind: 'edit', status: 'pending', locations: [{ path }] });
118
+ askPermission({ toolCallId, title: `Edit ${path}`, kind: 'edit', content }, (outcome) => {
119
+ const allowed = outcome.outcome === 'selected' && outcome.optionId !== 'no';
120
+ update({ sessionUpdate: 'tool_call_update', toolCallId, status: allowed ? 'completed' : 'failed', content });
121
+ endTurn(outcome.outcome === 'cancelled' ? 'cancelled' : 'end_turn');
122
+ });
123
+ return;
124
+ }
125
+ if (last.startsWith('tool:')) {
126
+ const command = last.slice(5).trim();
127
+ const toolCallId = `call-${++callCounter}`;
128
+ update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'I should run it.' } });
129
+ update({ sessionUpdate: 'tool_call', toolCallId, title: `Run ${command}`, kind: 'execute', status: 'pending', rawInput: { command } });
130
+ askPermission({ toolCallId }, (outcome) => {
131
+ if (outcome.outcome === 'cancelled') {
132
+ update({ sessionUpdate: 'tool_call_update', toolCallId, status: 'failed' });
133
+ endTurn('cancelled');
134
+ return;
135
+ }
136
+ if (outcome.optionId === 'no') {
137
+ update({
138
+ sessionUpdate: 'tool_call_update',
139
+ toolCallId,
140
+ status: 'failed',
141
+ content: [{ type: 'content', content: { type: 'text', text: 'rejected' } }]
142
+ });
143
+ say('not run');
144
+ endTurn('end_turn');
145
+ return;
146
+ }
147
+ update({ sessionUpdate: 'tool_call_update', toolCallId, status: 'in_progress' });
148
+ update({
149
+ sessionUpdate: 'tool_call_update',
150
+ toolCallId,
151
+ status: 'completed',
152
+ content: [{ type: 'content', content: { type: 'text', text: `ran ${command} (${String(outcome.optionId)})` } }]
153
+ });
154
+ say('done');
155
+ endTurn('end_turn');
156
+ });
157
+ return;
158
+ }
159
+ update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking.' } });
160
+ say(`echo: ${text}${images > 0 ? ` (${images} images)` : ''}`);
161
+ endTurn('end_turn');
162
+ };
163
+ const handle = (frame) => {
164
+ const id = frame.id;
165
+ const params = (typeof frame.params === 'object' && frame.params !== null ? frame.params : {});
166
+ if (frame.method === undefined) {
167
+ // The client's answer to a permission request.
168
+ if (permission !== null && id === permission.rpcId) {
169
+ const answered = permission;
170
+ permission = null;
171
+ const result = frame.result;
172
+ answered.onAnswer(result?.outcome ?? { outcome: 'cancelled' });
173
+ }
174
+ return;
175
+ }
176
+ switch (frame.method) {
177
+ case 'initialize':
178
+ respond(id, {
179
+ protocolVersion: 1,
180
+ agentCapabilities: {
181
+ loadSession: options.loadSession ?? true,
182
+ promptCapabilities: { image: true, audio: false, embeddedContext: false },
183
+ sessionCapabilities: options.resumeSession ? { resume: {} } : {}
184
+ },
185
+ authMethods: options.authMethod === undefined ? [] : [{ id: options.authMethod, name: 'Sign in' }],
186
+ agentInfo: { name: 'fake-acp', title: 'Fake ACP', version: '0.0.1' }
187
+ });
188
+ return;
189
+ case 'authenticate':
190
+ authenticated = params.methodId === options.authMethod;
191
+ respond(id, {});
192
+ return;
193
+ case 'session/new':
194
+ if (!authenticated) {
195
+ refuse(id, -32000, 'Authentication required');
196
+ return;
197
+ }
198
+ sessionId = `fake-session-${Math.random().toString(36).slice(2, 8)}`;
199
+ sessionHistory.set(sessionId, []);
200
+ respond(id, { sessionId, ...sessionState() });
201
+ return;
202
+ case 'session/load':
203
+ case 'session/resume': {
204
+ const history = sessionHistory.get(String(params.sessionId));
205
+ if (!authenticated) {
206
+ refuse(id, -32000, 'Authentication required');
207
+ return;
208
+ }
209
+ if (history === undefined) {
210
+ refuse(id, -32002, 'Session not found');
211
+ return;
212
+ }
213
+ sessionId = String(params.sessionId);
214
+ if (frame.method === 'session/load') {
215
+ for (const earlier of history) {
216
+ update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: earlier } });
217
+ update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'replayed' } });
218
+ }
219
+ }
220
+ respond(id, sessionState());
221
+ return;
222
+ }
223
+ case 'session/set_mode':
224
+ mode = String(params.modeId);
225
+ respond(id, {});
226
+ update({ sessionUpdate: 'current_mode_update', currentModeId: mode });
227
+ return;
228
+ case 'session/set_model':
229
+ model = String(params.modelId);
230
+ respond(id, {});
231
+ return;
232
+ case 'session/set_config_option':
233
+ if (params.configId === 'model') {
234
+ model = String(params.value);
235
+ }
236
+ respond(id, { configOptions: sessionState().configOptions ?? [] });
237
+ return;
238
+ case 'session/prompt':
239
+ handlePrompt(id, params);
240
+ return;
241
+ case 'session/cancel':
242
+ if (prompt !== null) {
243
+ prompt.cancelled = true;
244
+ if (permission === null) {
245
+ endTurn('cancelled');
246
+ }
247
+ }
248
+ return;
249
+ default:
250
+ if (id !== undefined) {
251
+ refuse(id, -32601, `Method not found: ${String(frame.method)}`);
252
+ }
253
+ }
254
+ };
255
+ return {
256
+ onLine: (line) => {
257
+ handle(JSON.parse(line));
258
+ }
259
+ };
260
+ };
261
+ }
262
+ export const fakeAcp = fakeAcpWith({});
@@ -1,4 +1,5 @@
1
1
  export { type FakeIo, type FakeProgram, type FakeCli, type StartedFake, type InProcessCli, inProcess, runOverStdio } from './fake-cli.ts';
2
+ export { type FakeAcpOptions, fakeAcpWith, fakeAcp } from './fake-acp.ts';
2
3
  export { fakeClaude, claudeStoppingOnResume } from './fake-claude.ts';
3
4
  export { FAKE_CHILD_STEPS, fakeCodexForks, type FakeCodexOptions, fakeCodexWith, fakeCodex } from './fake-codex.ts';
4
5
  export { testAccounts } from './accounts.ts';
@@ -1,5 +1,6 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
2
  export { inProcess, runOverStdio } from './fake-cli.js';
3
+ export { fakeAcpWith, fakeAcp } from './fake-acp.js';
3
4
  export { fakeClaude, claudeStoppingOnResume } from './fake-claude.js';
4
5
  export { FAKE_CHILD_STEPS, fakeCodexForks, fakeCodexWith, fakeCodex } from './fake-codex.js';
5
6
  export { testAccounts } from './accounts.js';
@@ -1,4 +1,4 @@
1
- import type { UsageProvider } from '@adecore/agent-contracts';
1
+ import { type UsageProvider } from '@adecore/agent-contracts';
2
2
  import type { UsageRecord } from './record.ts';
3
3
  import type { CodexParserState } from './readers/codex.ts';
4
4
  export interface IndexedFile {
@@ -1,3 +1,4 @@
1
+ import { UsageProviderSchema } from '@adecore/agent-contracts';
1
2
  /* Another version is thrown away rather than migrated, since rebuilding costs one cold scan; version 1 is read as it is. */
2
3
  const INDEX_VERSION = 2;
3
4
  /* Before accounts: every record was the default account's, so it reads as that without a scan. */
@@ -89,7 +90,8 @@ export function decodeIndex(text) {
89
90
  const accounts = Array.isArray(document.accounts) ? document.accounts : [];
90
91
  const name = (table, at) => (typeof table[at] === 'string' ? table[at] : null);
91
92
  for (const [path, file] of Object.entries(document.files ?? {})) {
92
- if (file.p !== 'claude' && file.p !== 'codex') {
93
+ // Every provider the schema knows, so a new one is never dropped from the index it was saved in.
94
+ if (!UsageProviderSchema.safeParse(file.p).success) {
93
95
  continue;
94
96
  }
95
97
  if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0 || !Array.isArray(file.r)) {
@@ -1,8 +1,9 @@
1
1
  export { usageAccountsOf, usageRootsOf, limitAccountsOf, chatSessionAccounts } from './accounts.ts';
2
2
  export { UsageMonitor, type ProbeEnv, type LimitAccount, type LimitAccounts, type UsageMonitorOptions } from './limits/monitor.ts';
3
- export type { LimitsUpdate, ProviderReading } from './limits/normalize.ts';
3
+ export { fraction, type LimitsUpdate, type ProviderReading } from './limits/normalize.ts';
4
4
  export type { ProbeResult } from './limits/probe.ts';
5
5
  export { LITELLM_URL } from './pricing.ts';
6
+ export { hostUsageLine, type HostUsageEntry } from './readers/host-log.ts';
6
7
  export type { KnownProject } from './projects.ts';
7
8
  export { type UsageRootPath, usageRoots } from './roots.ts';
8
9
  export type { UsageScannerOptions } from './scanner.ts';
@@ -1,6 +1,8 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
2
  export { usageAccountsOf, usageRootsOf, limitAccountsOf, chatSessionAccounts } from './accounts.js';
3
3
  export { UsageMonitor } from './limits/monitor.js';
4
+ export { fraction } from './limits/normalize.js';
4
5
  export { LITELLM_URL } from './pricing.js';
6
+ export { hostUsageLine } from './readers/host-log.js';
5
7
  export { usageRoots } from './roots.js';
6
8
  export { UsageService } from './usage-service.js';
@@ -1,4 +1,4 @@
1
- import type { UsageLimitsSnapshot, UsageProvider } from '@adecore/agent-contracts';
1
+ import { type UsageLimitsSnapshot, type UsageProvider } from '@adecore/agent-contracts';
2
2
  import type { CodexClientInfo } from '../../chat/codex-transport.ts';
3
3
  import type { AgentSink } from '../../events.ts';
4
4
  import type { ProviderRegistry } from '../../providers/registry.ts';
@@ -21,6 +21,7 @@ export interface UsageMonitorOptions {
21
21
  providers: ProviderRegistry;
22
22
  accounts?: LimitAccounts;
23
23
  probe?: (kind: UsageProvider, command: readonly string[], env: ProbeEnv) => Promise<ProbeResult>;
24
+ plans?: Partial<Record<UsageProvider, () => Promise<ProbeResult>>>;
24
25
  now?: () => number;
25
26
  client?: CodexClientInfo;
26
27
  }
@@ -28,6 +29,7 @@ export declare class UsageMonitor {
28
29
  private readonly registry;
29
30
  private readonly accounts;
30
31
  private readonly probe;
32
+ private readonly plans;
31
33
  private readonly now;
32
34
  private readonly sinks;
33
35
  private readonly states;
@@ -1,3 +1,4 @@
1
+ import { USAGE_PROVIDERS } from '@adecore/agent-contracts';
1
2
  import { ClientSinks } from '../../client-sinks.js';
2
3
  import { errorText } from '../../error-text.js';
3
4
  import { definedEnv } from '../../providers/accounts/launch.js';
@@ -8,7 +9,7 @@ const PROBE_INTERVAL_MS = 5 * 60_000;
8
9
  const MAX_BACKOFF_MS = 60 * 60_000;
9
10
  /* An account other than the default one that nobody started a CLI under for this long is only read when a person asks. */
10
11
  const RECENT_USE_MS = 24 * 60 * 60_000;
11
- const PROVIDERS = ['claude', 'codex'];
12
+ const PROVIDERS = USAGE_PROVIDERS;
12
13
  function blank(kind) {
13
14
  return {
14
15
  kind,
@@ -24,12 +25,14 @@ function blank(kind) {
24
25
  * What is left of each plan, per account. Two sources feed it: a read of our own every five minutes,
25
26
  * which starts the CLI, asks and ends it, and the events a running turn streams in between, which
26
27
  * cost nothing and arrive the moment a number moves. Neither reads a credential: both CLIs already
27
- * hold their own login and answer the question themselves.
28
+ * hold their own login and answer the question themselves. A provider without a CLI is read by the
29
+ * host that holds its key (`plans`), on the same clock.
28
30
  */
29
31
  export class UsageMonitor {
30
32
  registry;
31
33
  accounts;
32
34
  probe;
35
+ plans;
33
36
  now;
34
37
  sinks = new ClientSinks();
35
38
  // By account id, so the default account of a CLI is under the CLI's kind.
@@ -39,6 +42,7 @@ export class UsageMonitor {
39
42
  constructor(options) {
40
43
  this.registry = options.providers;
41
44
  this.accounts = options.accounts ?? null;
45
+ this.plans = options.plans ?? {};
42
46
  this.probe = options.probe ?? ((kind, command, env) => (kind === 'claude' ? probeClaude(command, env) : probeCodex(command, env, options.client)));
43
47
  this.now = options.now ?? Date.now;
44
48
  for (const kind of PROVIDERS) {
@@ -138,16 +142,18 @@ export class UsageMonitor {
138
142
  continue;
139
143
  }
140
144
  const info = installed.find((provider) => provider.kind === kind);
141
- if (info === undefined || !info.installed) {
145
+ if (this.plans[kind] === undefined && (info === undefined || !info.installed)) {
142
146
  state.published = { ...blank(kind), checkedAt: this.now(), unavailable: { reason: 'not-installed', message: null } };
143
147
  state.nextAt = this.now() + PROBE_INTERVAL_MS;
144
148
  changed = true;
145
149
  continue;
146
150
  }
151
+ // The next read is counted from when this one started, so a read that takes a few seconds does not push the tick of the clock past its time.
152
+ const startedAt = this.now();
147
153
  const result = await this.read(account);
148
154
  const failed = 'unavailable' in result && result.unavailable?.reason === 'failed';
149
155
  state.backoffMs = failed ? Math.min(MAX_BACKOFF_MS, state.backoffMs * 2) : PROBE_INTERVAL_MS;
150
- state.nextAt = this.now() + state.backoffMs;
156
+ state.nextAt = startedAt + state.backoffMs;
151
157
  if (failed && state.published.windows.length > 0) {
152
158
  // A pass that could not reach the CLI leaves the last good numbers where they are.
153
159
  continue;
@@ -163,6 +169,15 @@ export class UsageMonitor {
163
169
  }
164
170
  }
165
171
  async read(account) {
172
+ const plan = this.plans[account.kind];
173
+ if (plan !== undefined) {
174
+ try {
175
+ return await plan();
176
+ }
177
+ catch (e) {
178
+ return { unavailable: { reason: 'failed', message: errorText(e) } };
179
+ }
180
+ }
166
181
  let env;
167
182
  try {
168
183
  env = this.accounts === null ? definedEnv(process.env) : this.accounts.envFor(account.kind, account.id);
@@ -12,6 +12,7 @@ export interface LimitsUpdate {
12
12
  plan?: string | null;
13
13
  windows: Partial<UsageWindow>[];
14
14
  }
15
+ export declare function fraction(value: number): number;
15
16
  export declare function kindOfDuration(durationMs: number | null): UsageWindow['kind'];
16
17
  export declare function readClaudeUsage(response: unknown): ProviderReading | {
17
18
  unavailable: UsageLimitsProvider['unavailable'];
@@ -6,7 +6,8 @@ const MONTH_MS = 30 * 24 * 60 * MINUTE_MS;
6
6
  function num(value) {
7
7
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
8
8
  }
9
- function fraction(value) {
9
+ /* A share of a plan used, held to the bar: a provider may report more than all of it. */
10
+ export function fraction(value) {
10
11
  return Math.min(1, Math.max(0, value));
11
12
  }
12
13
  /* A window is named after how long it lasts, never after the position it came in. */
@@ -0,0 +1,16 @@
1
+ import type { UsageProvider } from '@adecore/agent-contracts';
2
+ import { type UsageRecord } from '../record.ts';
3
+ export interface HostUsageEntry {
4
+ at: number;
5
+ model: string;
6
+ session: string;
7
+ cwd: string;
8
+ input: number;
9
+ cacheRead: number;
10
+ cacheWrite: number;
11
+ output: number;
12
+ reasoning: number;
13
+ }
14
+ export declare function hostUsageLine(entry: HostUsageEntry): string;
15
+ export declare function hostLogMightCarryUsage(line: string): boolean;
16
+ export declare function parseHostUsageLine(line: string, provider: UsageProvider): UsageRecord | null;
@@ -0,0 +1,49 @@
1
+ import { asObject, asString, int } from '../record.js';
2
+ export function hostUsageLine(entry) {
3
+ return `${JSON.stringify(entry)}\n`;
4
+ }
5
+ /* A line of this log names its model, which keeps `JSON.parse` off anything else a folder holds. */
6
+ export function hostLogMightCarryUsage(line) {
7
+ return line.includes('"model"');
8
+ }
9
+ /* The provider is the root's, so one reader serves every API a host logs. */
10
+ export function parseHostUsageLine(line, provider) {
11
+ let parsed;
12
+ try {
13
+ parsed = JSON.parse(line);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ const entry = asObject(parsed);
19
+ if (entry === null) {
20
+ return null;
21
+ }
22
+ const model = asString(entry.model);
23
+ const timestampMs = typeof entry.at === 'number' && Number.isFinite(entry.at) ? entry.at : Number.NaN;
24
+ if (model === '' || Number.isNaN(timestampMs)) {
25
+ return null;
26
+ }
27
+ const totals = {
28
+ calls: 1,
29
+ input: int(entry.input),
30
+ cacheRead: int(entry.cacheRead),
31
+ cacheWrite: int(entry.cacheWrite),
32
+ cacheWrite1h: 0,
33
+ output: int(entry.output),
34
+ reasoning: int(entry.reasoning)
35
+ };
36
+ if (totals.input + totals.cacheRead + totals.cacheWrite + totals.output === 0) {
37
+ return null;
38
+ }
39
+ return {
40
+ provider,
41
+ timestampMs,
42
+ model,
43
+ sessionId: asString(entry.session),
44
+ cwd: asString(entry.cwd),
45
+ totals,
46
+ // Every line is a call of its own, so nothing is folded.
47
+ dedupeKey: null
48
+ };
49
+ }
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { homeFolder } from '../home-folder.js';
3
- /* Where in its config folder each CLI leaves its transcripts. */
3
+ /* Where in its config folder each CLI leaves its transcripts. A provider with no CLI has a log its host passes as a root of its own. */
4
4
  const TRANSCRIPTS = { claude: 'projects', codex: 'sessions' };
5
5
  /* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
6
6
  export function usageRoots(env = process.env) {
@@ -14,7 +14,11 @@ export function usageRoots(env = process.env) {
14
14
  export function accountRoots(folders) {
15
15
  const roots = new Map();
16
16
  for (const { id, kind, folder } of folders) {
17
- const path = join(folder, TRANSCRIPTS[kind]);
17
+ const transcripts = TRANSCRIPTS[kind];
18
+ if (transcripts === undefined) {
19
+ continue;
20
+ }
21
+ const path = join(folder, transcripts);
18
22
  const key = `${kind}\0${path}`;
19
23
  const root = roots.get(key);
20
24
  if (root === undefined) {
@@ -6,6 +6,7 @@ import { isNotFound, writeAtomic } from '../fs.js';
6
6
  import { decodeIndex, encodeIndex } from './index-file.js';
7
7
  import { foldByKey } from './record.js';
8
8
  import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.js';
9
+ import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.js';
9
10
  import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
10
11
  import { usageRoots } from './roots.js';
11
12
  const HASH_CHUNK_BYTES = 65536;
@@ -93,8 +94,18 @@ function parseChunk(text, provider, from, state) {
93
94
  }
94
95
  continue;
95
96
  }
96
- if (codex !== null && codexMightCarryUsage(line)) {
97
- const record = parseCodexLine(line, codex);
97
+ if (provider === 'codex') {
98
+ if (codex !== null && codexMightCarryUsage(line)) {
99
+ const record = parseCodexLine(line, codex);
100
+ if (record !== null) {
101
+ records.push(record);
102
+ }
103
+ }
104
+ continue;
105
+ }
106
+ // Claude Code and Codex write transcripts of their own; every other provider is a log its host keeps.
107
+ if (hostLogMightCarryUsage(line)) {
108
+ const record = parseHostUsageLine(line, provider);
98
109
  if (record !== null) {
99
110
  records.push(record);
100
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.29.3",
3
+ "version": "0.31.0",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,8 +81,8 @@
81
81
  "test": "bun --conditions=source test"
82
82
  },
83
83
  "dependencies": {
84
- "@adecore/agent-contracts": "0.29.3",
85
- "@adecore/intelligent-ui": "0.29.3",
84
+ "@adecore/agent-contracts": "0.31.0",
85
+ "@adecore/intelligent-ui": "0.31.0",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {