@adecore/agents 0.38.0 → 0.40.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 (170) hide show
  1. package/dist/async.d.ts +5 -0
  2. package/dist/async.js +13 -5
  3. package/dist/chat/acp-protocol.js +3 -5
  4. package/dist/chat/chat-core.d.ts +1 -0
  5. package/dist/chat/chat-core.js +9 -2
  6. package/dist/chat/chat-process.d.ts +1 -0
  7. package/dist/chat/chat-process.js +1 -1
  8. package/dist/chat/chat-session.d.ts +2 -0
  9. package/dist/chat/chat-session.js +9 -10
  10. package/dist/chat/claude-protocol.js +1 -6
  11. package/dist/chat/claude-transcript.js +1 -3
  12. package/dist/chat/codex-protocol.js +1 -6
  13. package/dist/chat/projector.js +1 -3
  14. package/dist/chat/request-summary.js +1 -3
  15. package/dist/coded-error.js +1 -5
  16. package/dist/context/argv.js +1 -2
  17. package/dist/context/verb.js +1 -3
  18. package/dist/error-text.d.ts +1 -0
  19. package/dist/error-text.js +4 -0
  20. package/dist/fields.d.ts +3 -0
  21. package/dist/fields.js +12 -0
  22. package/dist/fs.d.ts +1 -0
  23. package/dist/fs.js +8 -4
  24. package/dist/lineage.js +11 -5
  25. package/dist/messages/notice-store.js +3 -4
  26. package/dist/providers/accounts/acp-account.js +3 -20
  27. package/dist/providers/accounts/service.d.ts +1 -2
  28. package/dist/providers/accounts/service.js +5 -9
  29. package/dist/providers/accounts/status.js +1 -3
  30. package/dist/providers/acp-agent-state.js +1 -3
  31. package/dist/providers/claude.js +0 -1
  32. package/dist/providers/codex.js +0 -1
  33. package/dist/providers/detect.js +1 -1
  34. package/dist/providers/shell-commands.js +1 -2
  35. package/dist/run-process.js +1 -3
  36. package/dist/skills.d.ts +1 -1
  37. package/dist/skills.js +5 -8
  38. package/dist/tasks/background-limit.js +3 -2
  39. package/dist/tasks/task-coordinator.js +11 -17
  40. package/dist/testing/watch.js +0 -1
  41. package/dist/usage/limits/monitor.js +1 -2
  42. package/dist/usage/readers/claude.js +3 -10
  43. package/dist/usage/readers/codex.js +2 -9
  44. package/dist/usage/readers/grok.js +3 -10
  45. package/dist/usage/readers/host-log.js +3 -10
  46. package/dist/usage/record.d.ts +2 -0
  47. package/dist/usage/record.js +13 -0
  48. package/package.json +6 -6
  49. package/src/async.ts +23 -5
  50. package/src/chat/acp-backend.ts +121 -0
  51. package/src/chat/acp-protocol.ts +172 -6
  52. package/src/chat/attachment-store.ts +21 -0
  53. package/src/chat/backend.ts +0 -2
  54. package/src/chat/bookmark-store.ts +23 -0
  55. package/src/chat/chat-core.ts +218 -7
  56. package/src/chat/chat-log.ts +19 -0
  57. package/src/chat/chat-process.ts +52 -1
  58. package/src/chat/chat-session.ts +414 -13
  59. package/src/chat/chat-store.ts +50 -0
  60. package/src/chat/chat-title.ts +9 -0
  61. package/src/chat/claude-backend.ts +50 -0
  62. package/src/chat/claude-protocol.ts +155 -8
  63. package/src/chat/claude-title.ts +10 -0
  64. package/src/chat/claude-transcript.ts +47 -4
  65. package/src/chat/codex-backend.ts +131 -0
  66. package/src/chat/codex-protocol.ts +180 -8
  67. package/src/chat/codex-thread.ts +23 -0
  68. package/src/chat/composer-preferences.ts +6 -0
  69. package/src/chat/context-breakdown.ts +11 -0
  70. package/src/chat/delta-coalescer.ts +8 -0
  71. package/src/chat/external-sessions.ts +28 -0
  72. package/src/chat/generated-image.ts +25 -0
  73. package/src/chat/index.ts +5 -0
  74. package/src/chat/input.ts +13 -0
  75. package/src/chat/json-rpc-transport.ts +28 -0
  76. package/src/chat/limit-resume.ts +13 -0
  77. package/src/chat/projector.ts +160 -4
  78. package/src/chat/readable-text.ts +3 -0
  79. package/src/chat/request-summary.ts +18 -4
  80. package/src/chat/subagent-projection.ts +3 -0
  81. package/src/chat/subagent-reader.ts +76 -0
  82. package/src/chat/subagent-settlement.ts +24 -0
  83. package/src/chat/thread-search.ts +13 -0
  84. package/src/chat/thread.ts +31 -0
  85. package/src/chat/ui-queries.ts +122 -0
  86. package/src/chat/ui-replies.ts +34 -0
  87. package/src/chat/visual-store.ts +49 -0
  88. package/src/chat/wake-chat.ts +3 -0
  89. package/src/client-sinks.ts +2 -0
  90. package/src/coded-error.ts +1 -5
  91. package/src/context/argv.ts +18 -2
  92. package/src/context/index.ts +1 -0
  93. package/src/context/refusal.ts +3 -0
  94. package/src/context/verb.ts +32 -4
  95. package/src/coordination/index.ts +10 -0
  96. package/src/error-text.ts +7 -0
  97. package/src/fields.ts +14 -0
  98. package/src/fs.ts +20 -4
  99. package/src/host/agent-host.ts +20 -0
  100. package/src/host/environment.ts +6 -0
  101. package/src/host/handlers.ts +18 -0
  102. package/src/host/memory-port.ts +4 -0
  103. package/src/host/wiring.ts +10 -0
  104. package/src/index.ts +1 -0
  105. package/src/lineage.ts +24 -5
  106. package/src/messages/deliver-message.ts +6 -0
  107. package/src/messages/deliver-notice.ts +13 -0
  108. package/src/messages/notice-store.ts +16 -4
  109. package/src/modes.ts +1 -0
  110. package/src/outbox/outbox-worker.ts +31 -0
  111. package/src/outbox/outbox.ts +5 -0
  112. package/src/providers/accounts/accounts.ts +36 -0
  113. package/src/providers/accounts/acp-account.ts +22 -27
  114. package/src/providers/accounts/launch.ts +4 -0
  115. package/src/providers/accounts/service.ts +110 -12
  116. package/src/providers/accounts/shadow-home.ts +10 -0
  117. package/src/providers/accounts/status.ts +13 -4
  118. package/src/providers/accounts/store.ts +25 -0
  119. package/src/providers/accounts/variables.ts +11 -1
  120. package/src/providers/acp-agent-state.ts +24 -3
  121. package/src/providers/acp-provider.ts +5 -0
  122. package/src/providers/acp-usage-log.ts +2 -0
  123. package/src/providers/catalog.ts +17 -0
  124. package/src/providers/claude.ts +10 -1
  125. package/src/providers/codex-rules.ts +3 -0
  126. package/src/providers/codex.ts +2 -1
  127. package/src/providers/detect.ts +6 -1
  128. package/src/providers/provider.ts +1 -1
  129. package/src/providers/registry.ts +18 -2
  130. package/src/providers/shell-commands.ts +37 -2
  131. package/src/record-directory.ts +19 -0
  132. package/src/run-process.ts +24 -3
  133. package/src/serializer.ts +3 -0
  134. package/src/skills.ts +73 -10
  135. package/src/tasks/background-limit.ts +9 -2
  136. package/src/tasks/end-children.ts +9 -0
  137. package/src/tasks/give-task.ts +8 -0
  138. package/src/tasks/task-coordinator.ts +81 -17
  139. package/src/tasks/task-store.ts +28 -0
  140. package/src/tasks/waiting-child.ts +21 -0
  141. package/src/tasks/wake-parent.ts +26 -0
  142. package/src/tasks/wiring.ts +22 -0
  143. package/src/testing/accounts.ts +2 -0
  144. package/src/testing/fake-acp-capture.ts +16 -0
  145. package/src/testing/fake-acp.ts +66 -0
  146. package/src/testing/fake-claude.ts +177 -0
  147. package/src/testing/fake-cli.ts +29 -0
  148. package/src/testing/fake-codex.ts +122 -0
  149. package/src/testing/manual-clock.ts +2 -0
  150. package/src/testing/watch.ts +7 -1
  151. package/src/title-file.ts +12 -0
  152. package/src/usage/accounts.ts +6 -0
  153. package/src/usage/aggregate.ts +19 -0
  154. package/src/usage/exchange.ts +12 -0
  155. package/src/usage/index-file.ts +27 -0
  156. package/src/usage/limits/monitor.ts +31 -3
  157. package/src/usage/limits/normalize.ts +30 -0
  158. package/src/usage/limits/probe.ts +17 -0
  159. package/src/usage/pricing.ts +35 -0
  160. package/src/usage/projects.ts +8 -0
  161. package/src/usage/readers/claude.ts +12 -9
  162. package/src/usage/readers/codex.ts +40 -8
  163. package/src/usage/readers/grok.ts +17 -9
  164. package/src/usage/readers/host-log.ts +9 -9
  165. package/src/usage/record.ts +22 -0
  166. package/src/usage/roots.ts +6 -0
  167. package/src/usage/scanner.ts +61 -0
  168. package/src/usage/usage-service.ts +14 -0
  169. package/src/watch-seam.ts +8 -0
  170. package/src/windows-command.ts +12 -0
@@ -20,8 +20,10 @@ export function machineAccounts(snapshot: ProviderAccounts, nameOf: (kind: Usage
20
20
  if (!isUsageProvider(account.kind)) {
21
21
  return [];
22
22
  }
23
+
23
24
  const isDefault = id === account.kind;
24
25
  const state = snapshot.statuses.find((status) => status.id === id)?.state;
26
+
25
27
  return [
26
28
  {
27
29
  id,
@@ -52,7 +54,9 @@ export function usageRootsOf(service: ProviderAccountsService): UsageRootPath[]
52
54
  if (!isUsageProvider(kind)) {
53
55
  return [];
54
56
  }
57
+
55
58
  const folder = service.transcriptFolder(kind, id);
59
+
56
60
  return folder === null || folder === '' ? [] : [{ id, kind, folder }];
57
61
  })
58
62
  );
@@ -78,10 +82,12 @@ export function limitAccountsOf(
78
82
  /* The account every CLI session a chat here ran was under, by `<provider>\0<sessionId>`, for the transcripts accounts share. */
79
83
  export function chatSessionAccounts(chats: ReadonlyArray<Pick<ChatInfo, 'provider' | 'agentSessionId' | 'account'>>): Map<string, string> {
80
84
  const known = new Map<string, string>();
85
+
81
86
  for (const chat of chats) {
82
87
  if (chat.agentSessionId !== null) {
83
88
  known.set(`${chat.provider}\0${chat.agentSessionId}`, chat.account ?? chat.provider);
84
89
  }
85
90
  }
91
+
86
92
  return known;
87
93
  }
@@ -30,6 +30,7 @@ function slotFormatter(timeZone: string, resolution: UsageResolution): Intl.Date
30
30
  resolution === 'hour'
31
31
  ? { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hourCycle: 'h23' }
32
32
  : { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' };
33
+
33
34
  try {
34
35
  return new Intl.DateTimeFormat('en-CA', options);
35
36
  } catch {
@@ -41,8 +42,11 @@ function slotFormatter(timeZone: string, resolution: UsageResolution): Intl.Date
41
42
  /* `2026-09-10` for a day, `2026-09-10T14` for an hour: the day is always the first ten characters. */
42
43
  function slotOf(format: Intl.DateTimeFormat, timestampMs: number, resolution: UsageResolution): string {
43
44
  const parts = format.formatToParts(new Date(timestampMs));
45
+
44
46
  const at = (type: Intl.DateTimeFormatPartTypes): string => parts.find((part) => part.type === type)?.value ?? '';
47
+
45
48
  const day = `${at('year')}-${at('month')}-${at('day')}`;
49
+
46
50
  return resolution === 'hour' ? `${day}T${at('hour')}` : day;
47
51
  }
48
52
 
@@ -96,6 +100,7 @@ function foldBucket(
96
100
  ): void {
97
101
  const key = `${slot}\0${record.provider}\0${record.model}\0${account ?? ''}`;
98
102
  let bucket = buckets.get(key);
103
+
99
104
  if (bucket === undefined) {
100
105
  bucket = {
101
106
  slot,
@@ -108,12 +113,15 @@ function foldBucket(
108
113
  cacheSavingsUsd: 0,
109
114
  sessions: new Set()
110
115
  };
116
+
111
117
  buckets.set(key, bucket);
112
118
  }
119
+
113
120
  bucket.totals = addTotals(bucket.totals, record.totals);
114
121
  bucket.costUsd += cost;
115
122
  bucket.priced ||= price !== null || record.costUsd !== undefined;
116
123
  bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
124
+
117
125
  if (record.sessionId !== '') {
118
126
  bucket.sessions.add(record.sessionId);
119
127
  }
@@ -122,6 +130,7 @@ function foldBucket(
122
130
  function foldModel(models: Map<string, ModelRow>, record: UsageRecord, account: string | null, lookup: PriceLookup, cost: number): void {
123
131
  const key = `${record.provider}\0${record.model}\0${account ?? ''}`;
124
132
  let model = models.get(key);
133
+
125
134
  if (model === undefined) {
126
135
  model = {
127
136
  provider: record.provider,
@@ -134,8 +143,10 @@ function foldModel(models: Map<string, ModelRow>, record: UsageRecord, account:
134
143
  basis: record.costUsd === undefined ? lookup.basis : 'exact',
135
144
  pricedAs: record.costUsd === undefined ? lookup.pricedAs : null
136
145
  };
146
+
137
147
  models.set(key, model);
138
148
  }
149
+
139
150
  model.totals = addTotals(model.totals, record.totals);
140
151
  model.costUsd += cost;
141
152
  model.priced ||= lookup.price !== null || record.costUsd !== undefined;
@@ -178,31 +189,39 @@ export async function aggregate(
178
189
 
179
190
  for (const record of records) {
180
191
  const account = only === null ? null : accountOfRecord(record);
192
+
181
193
  if (only !== null && !only.has(account!)) {
182
194
  continue;
183
195
  }
196
+
184
197
  const slot = slotOf(format, record.timestampMs, payload.resolution);
185
198
  const day = slot.slice(0, 10);
199
+
186
200
  if (day < payload.from || day > payload.to) {
187
201
  continue;
188
202
  }
203
+
189
204
  const lookup = prices.look(record.model, record.serviceTier, record.timestampMs);
190
205
  const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
191
206
  foldBucket(buckets, slot, record, account, lookup.price, cost);
192
207
  foldModel(models, record, account, lookup, cost);
208
+
193
209
  if (record.sessionId !== '') {
194
210
  sessions.add(`${record.provider}\0${record.sessionId}`);
195
211
  }
196
212
 
197
213
  let folder = folders.get(record.cwd);
214
+
198
215
  if (folder === undefined) {
199
216
  const resolved = await resolver.resolve(record.cwd);
200
217
  folder = resolved.folder;
201
218
  folders.set(record.cwd, folder);
219
+
202
220
  if (!projects.has(folder)) {
203
221
  projects.set(folder, { folder, name: resolved.name, projectId: resolved.projectId, byProvider: new Map(), totals: emptyTotals(), costUsd: 0 });
204
222
  }
205
223
  }
224
+
206
225
  foldProject(projects.get(folder)!, record, cost);
207
226
  }
208
227
 
@@ -17,9 +17,11 @@ export function parseRate(document: unknown, currency: string): Omit<UsageRate,
17
17
  const body = typeof document === 'object' && document !== null ? (document as Record<string, unknown>) : null;
18
18
  const rates = body === null || typeof body.rates !== 'object' || body.rates === null ? null : (body.rates as Record<string, unknown>);
19
19
  const rate = rates === null ? undefined : rates[currency];
20
+
20
21
  if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0 || typeof body?.date !== 'string') {
21
22
  return null;
22
23
  }
24
+
23
25
  return { currency, rate, date: body.date };
24
26
  }
25
27
 
@@ -47,6 +49,7 @@ export class ExchangeRates {
47
49
  /* Reads the rate an earlier run stored, once; asking for a new one is left to `ensure`. */
48
50
  load(): Promise<void> {
49
51
  this.stored ??= this.readStored();
52
+
50
53
  return this.stored;
51
54
  }
52
55
 
@@ -57,6 +60,7 @@ export class ExchangeRates {
57
60
  .finally(() => {
58
61
  this.inFlight = null;
59
62
  });
63
+
60
64
  return this.inFlight;
61
65
  }
62
66
 
@@ -67,6 +71,7 @@ export class ExchangeRates {
67
71
  private async readStored(): Promise<void> {
68
72
  try {
69
73
  const stored = JSON.parse(await readFile(this.file, 'utf8')) as UsageRate;
74
+
70
75
  if (stored?.currency === TARGET_CURRENCY && typeof stored.rate === 'number' && stored.rate > 0) {
71
76
  this.rate = stored;
72
77
  }
@@ -81,16 +86,22 @@ export class ExchangeRates {
81
86
  if (!this.allowFetch || (this.rate !== null && Date.now() - this.rate.fetchedAt < TTL_MS)) {
82
87
  return false;
83
88
  }
89
+
84
90
  let changed = false;
91
+
85
92
  try {
86
93
  const response = await fetch(`${FRANKFURTER_URL}?from=USD&to=${TARGET_CURRENCY}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
94
+
87
95
  if (!response.ok) {
88
96
  throw new Error(`frankfurter.app answered ${response.status}`);
89
97
  }
98
+
90
99
  const parsed = parseRate(await response.json(), TARGET_CURRENCY);
100
+
91
101
  if (parsed === null) {
92
102
  throw new Error(`The answer carried no ${TARGET_CURRENCY} rate`);
93
103
  }
104
+
94
105
  this.rate = { ...parsed, fetchedAt: Date.now() };
95
106
  changed = true;
96
107
  await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
@@ -99,6 +110,7 @@ export class ExchangeRates {
99
110
  // Yesterday's rate still converts; a rate that could not be refreshed is not a failure.
100
111
  console.warn('Could not refresh the exchange rate:', errorText(e));
101
112
  }
113
+
102
114
  return changed;
103
115
  }
104
116
  }
@@ -58,12 +58,15 @@ class Interner {
58
58
 
59
59
  index(value: string): number {
60
60
  const seen = this.at.get(value);
61
+
61
62
  if (seen !== undefined) {
62
63
  return seen;
63
64
  }
65
+
64
66
  const next = this.values.length;
65
67
  this.values.push(value);
66
68
  this.at.set(value, next);
69
+
67
70
  return next;
68
71
  }
69
72
  }
@@ -74,6 +77,7 @@ export function encodeIndex(index: UsageIndex): string {
74
77
  const folders = new Interner();
75
78
  const accounts = new Interner();
76
79
  const files: Record<string, FileRow> = {};
80
+
77
81
  const row = (record: UsageRecord): RecordRow => {
78
82
  const counted: RecordRow = [
79
83
  record.timestampMs,
@@ -90,14 +94,18 @@ export function encodeIndex(index: UsageIndex): string {
90
94
  record.dedupeKey ?? 0,
91
95
  record.account === undefined ? 0 : accounts.index(record.account) + 1
92
96
  ];
97
+
93
98
  if (record.costUsd !== undefined || record.serviceTier !== undefined) {
94
99
  counted[13] = record.costUsd;
95
100
  }
101
+
96
102
  if (record.serviceTier !== undefined) {
97
103
  counted[14] = record.serviceTier;
98
104
  }
105
+
99
106
  return counted;
100
107
  };
108
+
101
109
  for (const [path, file] of index) {
102
110
  files[path] = {
103
111
  p: file.provider,
@@ -112,6 +120,7 @@ export function encodeIndex(index: UsageIndex): string {
112
120
  ...(file.compressed === undefined ? {} : { z: file.compressed })
113
121
  };
114
122
  }
123
+
115
124
  const document: IndexFile = {
116
125
  version: INDEX_VERSION,
117
126
  models: models.values,
@@ -120,6 +129,7 @@ export function encodeIndex(index: UsageIndex): string {
120
129
  accounts: accounts.values,
121
130
  files
122
131
  };
132
+
123
133
  return JSON.stringify(document);
124
134
  }
125
135
 
@@ -142,12 +152,15 @@ function isRow(row: unknown, version: number): row is RecordRow {
142
152
  export function decodeIndex(text: string): UsageIndex {
143
153
  const index: UsageIndex = new Map();
144
154
  let document: IndexFile;
155
+
145
156
  try {
146
157
  document = JSON.parse(text) as IndexFile;
147
158
  } catch {
148
159
  return index;
149
160
  }
161
+
150
162
  const version = document?.version;
163
+
151
164
  if (
152
165
  (version !== INDEX_VERSION && version !== 2 && version !== ACCOUNTLESS_VERSION) ||
153
166
  !Array.isArray(document.models) ||
@@ -156,19 +169,26 @@ export function decodeIndex(text: string): UsageIndex {
156
169
  ) {
157
170
  return index;
158
171
  }
172
+
159
173
  const accounts = Array.isArray(document.accounts) ? document.accounts : [];
174
+
160
175
  const name = (table: string[], at: number): string | null => (typeof table[at] === 'string' ? table[at] : null);
176
+
161
177
  for (const [path, file] of Object.entries(document.files ?? {})) {
162
178
  // Every provider the schema knows, so a new one is never dropped from the index it was saved in.
163
179
  if (!UsageProviderSchema.safeParse(file.p).success) {
164
180
  continue;
165
181
  }
182
+
166
183
  if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0 || !Array.isArray(file.r)) {
167
184
  continue;
168
185
  }
186
+
169
187
  let broken = false;
188
+
170
189
  const decode = (rows: RecordRow[]): UsageRecord[] => {
171
190
  const records: UsageRecord[] = [];
191
+
172
192
  for (const row of rows) {
173
193
  const valid = isRow(row, version);
174
194
  const model = valid ? name(document.models, row[1]) : null;
@@ -176,10 +196,13 @@ export function decodeIndex(text: string): UsageIndex {
176
196
  const cwd = valid ? name(document.folders, row[3]) : null;
177
197
  const accountAt = valid ? (row[12] ?? 0) : 0;
178
198
  const account = accountAt === 0 ? undefined : name(accounts, accountAt - 1);
199
+
179
200
  if (!valid || model === null || sessionId === null || cwd === null || account === null) {
180
201
  broken = true;
202
+
181
203
  return records;
182
204
  }
205
+
183
206
  records.push({
184
207
  provider: file.p,
185
208
  timestampMs: row[0],
@@ -193,10 +216,13 @@ export function decodeIndex(text: string): UsageIndex {
193
216
  dedupeKey: row[11] === 0 ? null : row[11]
194
217
  });
195
218
  }
219
+
196
220
  return records;
197
221
  };
222
+
198
223
  const records = decode(file.r);
199
224
  const tail = decode(Array.isArray(file.t) ? file.t : []);
225
+
200
226
  if (!broken) {
201
227
  index.set(path, {
202
228
  provider: file.p,
@@ -212,5 +238,6 @@ export function decodeIndex(text: string): UsageIndex {
212
238
  });
213
239
  }
214
240
  }
241
+
215
242
  return index;
216
243
  }
@@ -62,7 +62,6 @@ export interface UsageMonitorOptions {
62
62
  providers: ProviderRegistry;
63
63
  /* The accounts to read; absent reads the default account of every CLI and names none. */
64
64
  accounts?: LimitAccounts;
65
- /* How to ask; a test answers without starting a CLI. */
66
65
  probe?: (kind: UsageProvider, command: readonly string[], env: ProbeEnv) => Promise<ProbeResult>;
67
66
  /*
68
67
  * A plan the host reads itself, for a provider whose CLI cannot be asked (a subscription API the host calls,
@@ -100,6 +99,7 @@ export class UsageMonitor {
100
99
  this.plans = options.plans ?? {};
101
100
  this.probe = options.probe ?? ((kind, command, env) => (kind === 'claude' ? probeClaude(command, env) : probeCodex(command, env, options.client)));
102
101
  this.now = options.now ?? Date.now;
102
+
103
103
  for (const kind of PROVIDERS) {
104
104
  this.stateOf(kind, kind);
105
105
  }
@@ -113,8 +113,10 @@ export class UsageMonitor {
113
113
  snapshot(): UsageLimitsSnapshot {
114
114
  const providers = this.listed().map(({ id, kind, label, color }) => {
115
115
  const published = this.stateOf(kind, id).published;
116
+
116
117
  return this.accounts === null ? published : { ...published, account: { id, label, ...(color === undefined ? {} : { color }) } };
117
118
  });
119
+
118
120
  return { providers };
119
121
  }
120
122
 
@@ -136,6 +138,7 @@ export class UsageMonitor {
136
138
  this.inFlight ??= this.runAll(force).finally(() => {
137
139
  this.inFlight = null;
138
140
  });
141
+
139
142
  return this.inFlight;
140
143
  }
141
144
 
@@ -153,7 +156,9 @@ export class UsageMonitor {
153
156
  if (update.windows.length === 0) {
154
157
  return;
155
158
  }
159
+
156
160
  const state = this.stateOf(update.kind, update.account ?? update.kind);
161
+
157
162
  state.published = {
158
163
  ...state.published,
159
164
  plan: update.plan ?? state.published.plan,
@@ -162,27 +167,33 @@ export class UsageMonitor {
162
167
  windows: mergeWindows(state.published.windows, update.windows),
163
168
  unavailable: null
164
169
  };
170
+
165
171
  this.emit();
166
172
  }
167
173
 
168
174
  /* Every account of a CLI whose plan is read, the default one first. */
169
175
  private listed(): LimitAccount[] {
170
176
  const accounts = this.accounts?.list() ?? PROVIDERS.map((kind) => ({ id: kind, kind, label: kind, isDefault: true }));
177
+
171
178
  return PROVIDERS.flatMap((kind) => {
172
179
  if (!PROBED.includes(kind) && this.plans[kind] === undefined) {
173
180
  return [];
174
181
  }
182
+
175
183
  const own = accounts.filter((account) => account.kind === kind);
184
+
176
185
  return [...own.filter((account) => account.isDefault), ...own.filter((account) => !account.isDefault)];
177
186
  });
178
187
  }
179
188
 
180
189
  private stateOf(kind: UsageProvider, id: string): ProviderState {
181
190
  let state = this.states.get(id);
191
+
182
192
  if (state === undefined || state.kind !== kind) {
183
193
  state = { kind, published: blank(kind), backoffMs: PROBE_INTERVAL_MS, nextAt: 0 };
184
194
  this.states.set(id, state);
185
195
  }
196
+
186
197
  return state;
187
198
  }
188
199
 
@@ -191,48 +202,62 @@ export class UsageMonitor {
191
202
  if (force) {
192
203
  return true;
193
204
  }
205
+
194
206
  if (this.now() < state.nextAt) {
195
207
  return false;
196
208
  }
209
+
197
210
  if (account.isDefault) {
198
211
  return true;
199
212
  }
213
+
200
214
  const used = this.accounts?.lastUsedAt(account.id) ?? null;
215
+
201
216
  return used !== null && this.now() - used < RECENT_USE_MS;
202
217
  }
203
218
 
204
219
  private async runAll(force: boolean): Promise<void> {
205
220
  const installed = await this.registry.list();
206
221
  let changed = false;
222
+
207
223
  for (const account of this.listed()) {
208
224
  const { kind } = account;
209
225
  const state = this.stateOf(kind, account.id);
226
+
210
227
  if (!this.due(account, state, force)) {
211
228
  continue;
212
229
  }
230
+
213
231
  const info = installed.find((provider) => provider.kind === kind);
232
+
214
233
  if (this.plans[kind] === undefined && (info === undefined || !info.installed)) {
215
234
  state.published = { ...blank(kind), checkedAt: this.now(), unavailable: { reason: 'not-installed', message: null } };
216
235
  state.nextAt = this.now() + PROBE_INTERVAL_MS;
217
236
  changed = true;
237
+
218
238
  continue;
219
239
  }
240
+
220
241
  // 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.
221
242
  const startedAt = this.now();
222
243
  const result = await this.read(account);
223
244
  const failed = 'unavailable' in result && result.unavailable?.reason === 'failed';
224
245
  state.backoffMs = failed ? Math.min(MAX_BACKOFF_MS, state.backoffMs * 2) : PROBE_INTERVAL_MS;
225
246
  state.nextAt = startedAt + state.backoffMs;
247
+
226
248
  if (failed && state.published.windows.length > 0) {
227
249
  // A pass that could not reach the CLI leaves the last good numbers where they are.
228
250
  continue;
229
251
  }
252
+
230
253
  state.published =
231
254
  'unavailable' in result
232
255
  ? { ...blank(kind), checkedAt: this.now(), unavailable: result.unavailable }
233
256
  : { kind, plan: result.plan, checkedAt: this.now(), source: 'probe', windows: result.windows, cost: result.cost, unavailable: null };
257
+
234
258
  changed = true;
235
259
  }
260
+
236
261
  if (changed) {
237
262
  this.emit();
238
263
  }
@@ -240,6 +265,7 @@ export class UsageMonitor {
240
265
 
241
266
  private async read(account: LimitAccount): Promise<ProbeResult> {
242
267
  const plan = this.plans[account.kind];
268
+
243
269
  if (plan !== undefined) {
244
270
  try {
245
271
  return await plan(account);
@@ -247,17 +273,19 @@ export class UsageMonitor {
247
273
  return { unavailable: { reason: 'failed', message: errorText(e) } };
248
274
  }
249
275
  }
276
+
250
277
  let env: ProbeEnv;
278
+
251
279
  try {
252
280
  env = this.accounts === null ? definedEnv(process.env) : this.accounts.envFor(account.kind, account.id);
253
281
  } catch (e) {
254
282
  return { unavailable: { reason: 'failed', message: errorText(e) } };
255
283
  }
284
+
256
285
  return this.probe(account.kind, this.registry.get(account.kind).command, env);
257
286
  }
258
287
 
259
288
  private emit(): void {
260
- const event: AgentEvent = { event: 'usage.limitsChanged', payload: this.snapshot() };
261
- this.sinks.emit(event);
289
+ this.sinks.emit({ event: 'usage.limitsChanged', payload: this.snapshot() });
262
290
  }
263
291
  }
@@ -36,12 +36,15 @@ export function kindOfDuration(durationMs: number | null): UsageWindow['kind'] {
36
36
  if (durationMs === null) {
37
37
  return 'other';
38
38
  }
39
+
39
40
  if (durationMs >= MONTH_MS) {
40
41
  return 'monthly';
41
42
  }
43
+
42
44
  if (durationMs >= WEEK_MS) {
43
45
  return 'weekly';
44
46
  }
47
+
45
48
  return 'session';
46
49
  }
47
50
 
@@ -68,33 +71,44 @@ export function readClaudeUsage(response: unknown): ProviderReading | { unavaila
68
71
  if (!isRecord(response)) {
69
72
  return { unavailable: { reason: 'failed', message: 'Claude Code returned no readable usage' } };
70
73
  }
74
+
71
75
  const plan = typeof response.subscription_type === 'string' ? response.subscription_type : null;
72
76
  const limits = isRecord(response.rate_limits) ? response.rate_limits : null;
77
+
73
78
  if (response.rate_limits_available === false || limits === null) {
74
79
  // An API key, Bedrock or Vertex account has no plan windows at all, which is not a failure.
75
80
  return { unavailable: { reason: 'no-subscription', message: null } };
76
81
  }
82
+
77
83
  const windows: UsageWindow[] = [];
84
+
78
85
  const push = (id: string, label: string, kind: UsageWindow['kind'], durationMs: number, entry: unknown): void => {
79
86
  const window = isRecord(entry) ? entry : null;
80
87
  const utilization = window === null ? null : num(window.utilization);
88
+
81
89
  if (utilization === null) {
82
90
  return;
83
91
  }
92
+
84
93
  const resetsAt = typeof window?.resets_at === 'string' ? Date.parse(window.resets_at) : Number.NaN;
85
94
  windows.push({ id, label, kind, used: fraction(utilization / 100), resetsAt: Number.isNaN(resetsAt) ? null : resetsAt, durationMs });
86
95
  };
96
+
87
97
  for (const known of CLAUDE_WINDOWS) {
88
98
  push(known.id, known.label, known.kind, known.durationMs, limits[known.key]);
89
99
  }
100
+
90
101
  for (const entry of Array.isArray(limits.model_scoped) ? limits.model_scoped : []) {
91
102
  const name = isRecord(entry) && typeof entry.display_name === 'string' ? entry.display_name : null;
103
+
92
104
  if (name !== null) {
93
105
  push(`seven_day_${slug(name)}`, `Weekly · ${name}`, 'weekly', WEEK_MS, entry);
94
106
  }
95
107
  }
108
+
96
109
  const session = isRecord(response.session) ? response.session : null;
97
110
  const sessionUsd = session === null ? null : num(session.total_cost_usd);
111
+
98
112
  return { plan, windows, cost: sessionUsd === null ? null : { sessionUsd } };
99
113
  }
100
114
 
@@ -103,11 +117,14 @@ export function readClaudeEvent(info: unknown): LimitsUpdate | null {
103
117
  const event = isRecord(info) ? info : null;
104
118
  const type = event !== null && typeof event.rateLimitType === 'string' ? event.rateLimitType : null;
105
119
  const utilization = event === null ? null : num(event.utilization);
120
+
106
121
  if (type === null || utilization === null) {
107
122
  return null;
108
123
  }
124
+
109
125
  const known = CLAUDE_WINDOWS.find((window) => window.key === type);
110
126
  const resetsAt = num(event?.resetsAt);
127
+
111
128
  return {
112
129
  kind: 'claude',
113
130
  windows: [
@@ -127,24 +144,30 @@ export function readClaudeEvent(info: unknown): LimitsUpdate | null {
127
144
  */
128
145
  export function readCodexLimits(snapshot: unknown): ProviderReading | null {
129
146
  const limits = isRecord(snapshot) ? snapshot : null;
147
+
130
148
  if (limits === null || (typeof limits.limitId === 'string' && limits.limitId !== 'codex')) {
131
149
  return null;
132
150
  }
151
+
133
152
  const plan = typeof limits.planType === 'string' ? limits.planType : null;
134
153
  const windows: UsageWindow[] = [];
154
+
135
155
  for (const [position, entry] of [
136
156
  ['primary', limits.primary],
137
157
  ['secondary', limits.secondary]
138
158
  ] as const) {
139
159
  const window = isRecord(entry) ? entry : null;
140
160
  const usedPercent = window === null ? null : num(window.usedPercent);
161
+
141
162
  if (usedPercent === null) {
142
163
  continue;
143
164
  }
165
+
144
166
  const minutes = num(window?.windowDurationMins);
145
167
  const durationMs = minutes === null ? null : Math.round(minutes * MINUTE_MS);
146
168
  const kind = kindOfDuration(durationMs);
147
169
  const resetsAt = num(window?.resetsAt);
170
+
148
171
  windows.push({
149
172
  id: position,
150
173
  kind,
@@ -154,6 +177,7 @@ export function readCodexLimits(snapshot: unknown): ProviderReading | null {
154
177
  durationMs
155
178
  });
156
179
  }
180
+
157
181
  return { plan, windows, cost: null };
158
182
  }
159
183
 
@@ -164,11 +188,14 @@ export function readCodexLimits(snapshot: unknown): ProviderReading | null {
164
188
  */
165
189
  export function mergeWindows(known: readonly UsageWindow[], updates: readonly Partial<UsageWindow>[]): UsageWindow[] {
166
190
  const merged = known.map((window) => ({ ...window }));
191
+
167
192
  for (const update of updates) {
168
193
  if (update.id === undefined || update.used === undefined) {
169
194
  continue;
170
195
  }
196
+
171
197
  const at = merged.findIndex((window) => window.id === update.id);
198
+
172
199
  if (at === -1) {
173
200
  merged.push({
174
201
  id: update.id,
@@ -178,9 +205,12 @@ export function mergeWindows(known: readonly UsageWindow[], updates: readonly Pa
178
205
  resetsAt: update.resetsAt ?? null,
179
206
  durationMs: update.durationMs ?? null
180
207
  });
208
+
181
209
  continue;
182
210
  }
211
+
183
212
  merged[at] = { ...merged[at]!, ...update } as UsageWindow;
184
213
  }
214
+
185
215
  return merged;
186
216
  }