@adecore/agents 0.0.1 → 0.17.0-beta.1

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 (348) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +33 -0
  3. package/dist/async.d.ts +2 -0
  4. package/dist/async.js +31 -0
  5. package/dist/chat/attachment-store.d.ts +10 -0
  6. package/dist/chat/attachment-store.js +93 -0
  7. package/dist/chat/backend.d.ts +214 -0
  8. package/dist/chat/backend.js +1 -0
  9. package/dist/chat/background-work.d.ts +8 -0
  10. package/dist/chat/background-work.js +33 -0
  11. package/dist/chat/bookmark-store.d.ts +25 -0
  12. package/dist/chat/bookmark-store.js +141 -0
  13. package/dist/chat/chat-core.d.ts +311 -0
  14. package/dist/chat/chat-core.js +1025 -0
  15. package/dist/chat/chat-log.d.ts +33 -0
  16. package/dist/chat/chat-log.js +163 -0
  17. package/dist/chat/chat-process.d.ts +46 -0
  18. package/dist/chat/chat-process.js +190 -0
  19. package/dist/chat/chat-session.d.ts +209 -0
  20. package/dist/chat/chat-session.js +1511 -0
  21. package/dist/chat/chat-store.d.ts +37 -0
  22. package/dist/chat/chat-store.js +202 -0
  23. package/dist/chat/chat-title.d.ts +10 -0
  24. package/dist/chat/chat-title.js +74 -0
  25. package/dist/chat/claude-backend.d.ts +35 -0
  26. package/dist/chat/claude-backend.js +214 -0
  27. package/dist/chat/claude-protocol.d.ts +41 -0
  28. package/dist/chat/claude-protocol.js +642 -0
  29. package/dist/chat/claude-title.d.ts +11 -0
  30. package/dist/chat/claude-title.js +97 -0
  31. package/dist/chat/claude-transcript.d.ts +28 -0
  32. package/dist/chat/claude-transcript.js +211 -0
  33. package/dist/chat/codex-backend.d.ts +51 -0
  34. package/dist/chat/codex-backend.js +364 -0
  35. package/dist/chat/codex-protocol.d.ts +56 -0
  36. package/dist/chat/codex-protocol.js +767 -0
  37. package/dist/chat/codex-thread.d.ts +32 -0
  38. package/dist/chat/codex-thread.js +131 -0
  39. package/dist/chat/codex-transport.d.ts +42 -0
  40. package/dist/chat/codex-transport.js +188 -0
  41. package/dist/chat/composer-preferences.d.ts +15 -0
  42. package/dist/chat/composer-preferences.js +46 -0
  43. package/dist/chat/context-breakdown.d.ts +2 -0
  44. package/dist/chat/context-breakdown.js +74 -0
  45. package/dist/chat/delta-coalescer.d.ts +12 -0
  46. package/dist/chat/delta-coalescer.js +55 -0
  47. package/dist/chat/errors.d.ts +4 -0
  48. package/dist/chat/errors.js +4 -0
  49. package/dist/chat/fake-claude.d.ts +3 -0
  50. package/dist/chat/fake-claude.js +982 -0
  51. package/dist/chat/fake-cli.d.ts +25 -0
  52. package/dist/chat/fake-cli.js +157 -0
  53. package/dist/chat/fake-codex.d.ts +10 -0
  54. package/dist/chat/fake-codex.js +502 -0
  55. package/dist/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
  56. package/dist/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
  57. package/dist/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
  58. package/dist/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
  59. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
  60. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
  61. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
  62. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
  63. package/dist/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
  64. package/dist/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
  65. package/dist/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
  66. package/dist/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
  67. package/dist/chat/input.d.ts +19 -0
  68. package/dist/chat/input.js +71 -0
  69. package/dist/chat/limit-resume.d.ts +11 -0
  70. package/dist/chat/limit-resume.js +68 -0
  71. package/dist/chat/projector.d.ts +60 -0
  72. package/dist/chat/projector.js +860 -0
  73. package/dist/chat/request-summary.d.ts +3 -0
  74. package/dist/chat/request-summary.js +143 -0
  75. package/dist/chat/subagent-projection.d.ts +8 -0
  76. package/dist/chat/subagent-projection.js +40 -0
  77. package/dist/chat/subagent-reader.d.ts +66 -0
  78. package/dist/chat/subagent-reader.js +396 -0
  79. package/dist/chat/subagent-settlement.d.ts +6 -0
  80. package/dist/chat/subagent-settlement.js +103 -0
  81. package/dist/chat/thread.d.ts +30 -0
  82. package/dist/chat/thread.js +147 -0
  83. package/dist/chat/wake-chat.d.ts +20 -0
  84. package/dist/chat/wake-chat.js +27 -0
  85. package/dist/client-sinks.d.ts +9 -0
  86. package/dist/client-sinks.js +34 -0
  87. package/dist/coded-error.d.ts +5 -0
  88. package/dist/coded-error.js +17 -0
  89. package/dist/context/argv.d.ts +11 -0
  90. package/dist/context/argv.js +58 -0
  91. package/dist/context/refusal.d.ts +9 -0
  92. package/dist/context/refusal.js +38 -0
  93. package/dist/context/verb.d.ts +85 -0
  94. package/dist/context/verb.js +233 -0
  95. package/dist/error-text.d.ts +5 -0
  96. package/dist/error-text.js +24 -0
  97. package/dist/events.d.ts +12 -0
  98. package/dist/events.js +1 -0
  99. package/dist/fs.d.ts +9 -0
  100. package/dist/fs.js +107 -0
  101. package/dist/host/agent-host.d.ts +28 -0
  102. package/dist/host/agent-host.js +105 -0
  103. package/dist/host/environment.d.ts +7 -0
  104. package/dist/host/environment.js +22 -0
  105. package/dist/host/handlers.d.ts +30 -0
  106. package/dist/host/handlers.js +111 -0
  107. package/dist/host/memory-port.d.ts +2 -0
  108. package/dist/host/memory-port.js +25 -0
  109. package/dist/host/wiring.d.ts +40 -0
  110. package/dist/host/wiring.js +120 -0
  111. package/dist/lineage.d.ts +44 -0
  112. package/dist/lineage.js +134 -0
  113. package/dist/messages/deliver-message.d.ts +21 -0
  114. package/dist/messages/deliver-message.js +42 -0
  115. package/dist/messages/deliver-notice.d.ts +25 -0
  116. package/dist/messages/deliver-notice.js +75 -0
  117. package/dist/messages/notice-notes.d.ts +15 -0
  118. package/dist/messages/notice-notes.js +26 -0
  119. package/dist/messages/notice-store.d.ts +30 -0
  120. package/dist/messages/notice-store.js +120 -0
  121. package/dist/modes.d.ts +11 -0
  122. package/dist/modes.js +28 -0
  123. package/dist/outbox/manual-clock.d.ts +10 -0
  124. package/dist/outbox/manual-clock.js +25 -0
  125. package/dist/outbox/outbox-worker.d.ts +44 -0
  126. package/dist/outbox/outbox-worker.js +173 -0
  127. package/dist/outbox/outbox.d.ts +37 -0
  128. package/dist/outbox/outbox.js +76 -0
  129. package/dist/providers/accounts/accounts.d.ts +20 -0
  130. package/dist/providers/accounts/accounts.js +158 -0
  131. package/dist/providers/accounts/launch.d.ts +17 -0
  132. package/dist/providers/accounts/launch.js +30 -0
  133. package/dist/providers/accounts/service.d.ts +87 -0
  134. package/dist/providers/accounts/service.js +538 -0
  135. package/dist/providers/accounts/shadow-home.d.ts +8 -0
  136. package/dist/providers/accounts/shadow-home.js +70 -0
  137. package/dist/providers/accounts/status.d.ts +12 -0
  138. package/dist/providers/accounts/status.js +85 -0
  139. package/dist/providers/accounts/store.d.ts +14 -0
  140. package/dist/providers/accounts/store.js +112 -0
  141. package/dist/providers/accounts/test-accounts.d.ts +12 -0
  142. package/dist/providers/accounts/test-accounts.js +38 -0
  143. package/dist/providers/accounts/variables.d.ts +27 -0
  144. package/dist/providers/accounts/variables.js +94 -0
  145. package/dist/providers/catalog.d.ts +17 -0
  146. package/dist/providers/catalog.js +76 -0
  147. package/dist/providers/claude-models.json +114 -0
  148. package/dist/providers/claude-provider.d.ts +4 -0
  149. package/dist/providers/claude-provider.js +29 -0
  150. package/dist/providers/claude.d.ts +15 -0
  151. package/dist/providers/claude.js +105 -0
  152. package/dist/providers/codex-models.json +135 -0
  153. package/dist/providers/codex-provider.d.ts +4 -0
  154. package/dist/providers/codex-provider.js +27 -0
  155. package/dist/providers/codex-rules.d.ts +9 -0
  156. package/dist/providers/codex-rules.js +35 -0
  157. package/dist/providers/codex.d.ts +14 -0
  158. package/dist/providers/codex.js +52 -0
  159. package/dist/providers/detect.d.ts +6 -0
  160. package/dist/providers/detect.js +30 -0
  161. package/dist/providers/provider.d.ts +25 -0
  162. package/dist/providers/provider.js +1 -0
  163. package/dist/providers/registry.d.ts +28 -0
  164. package/dist/providers/registry.js +118 -0
  165. package/dist/record-directory.d.ts +27 -0
  166. package/dist/record-directory.js +114 -0
  167. package/dist/run-process.d.ts +13 -0
  168. package/dist/run-process.js +42 -0
  169. package/dist/serializer.d.ts +9 -0
  170. package/dist/serializer.js +34 -0
  171. package/dist/skills.d.ts +21 -0
  172. package/dist/skills.js +174 -0
  173. package/dist/tasks/background-limit.d.ts +16 -0
  174. package/dist/tasks/background-limit.js +55 -0
  175. package/dist/tasks/end-children.d.ts +34 -0
  176. package/dist/tasks/end-children.js +53 -0
  177. package/dist/tasks/give-task.d.ts +14 -0
  178. package/dist/tasks/give-task.js +33 -0
  179. package/dist/tasks/task-coordinator.d.ts +52 -0
  180. package/dist/tasks/task-coordinator.js +323 -0
  181. package/dist/tasks/task-store.d.ts +49 -0
  182. package/dist/tasks/task-store.js +197 -0
  183. package/dist/tasks/task-work.d.ts +47 -0
  184. package/dist/tasks/task-work.js +28 -0
  185. package/dist/tasks/waiting-child.d.ts +51 -0
  186. package/dist/tasks/waiting-child.js +143 -0
  187. package/dist/tasks/wake-parent.d.ts +19 -0
  188. package/dist/tasks/wake-parent.js +109 -0
  189. package/dist/tasks/wiring.d.ts +51 -0
  190. package/dist/tasks/wiring.js +151 -0
  191. package/dist/title-file.d.ts +2 -0
  192. package/dist/title-file.js +55 -0
  193. package/dist/usage/accounts.d.ts +15 -0
  194. package/dist/usage/accounts.js +64 -0
  195. package/dist/usage/aggregate.d.ts +12 -0
  196. package/dist/usage/aggregate.js +154 -0
  197. package/dist/usage/exchange.d.ts +16 -0
  198. package/dist/usage/exchange.js +84 -0
  199. package/dist/usage/index-file.d.ts +15 -0
  200. package/dist/usage/index-file.js +123 -0
  201. package/dist/usage/limits/monitor.d.ts +50 -0
  202. package/dist/usage/limits/monitor.js +179 -0
  203. package/dist/usage/limits/normalize.d.ts +21 -0
  204. package/dist/usage/limits/normalize.js +160 -0
  205. package/dist/usage/limits/probe.d.ts +9 -0
  206. package/dist/usage/limits/probe.js +113 -0
  207. package/dist/usage/prices-snapshot.json +1071 -0
  208. package/dist/usage/pricing.d.ts +34 -0
  209. package/dist/usage/pricing.js +186 -0
  210. package/dist/usage/projects.d.ts +17 -0
  211. package/dist/usage/projects.js +49 -0
  212. package/dist/usage/readers/claude.d.ts +3 -0
  213. package/dist/usage/readers/claude.js +59 -0
  214. package/dist/usage/readers/codex.d.ts +23 -0
  215. package/dist/usage/readers/codex.js +155 -0
  216. package/dist/usage/record.d.ts +15 -0
  217. package/dist/usage/record.js +39 -0
  218. package/dist/usage/roots.d.ts +12 -0
  219. package/dist/usage/roots.js +29 -0
  220. package/dist/usage/scanner.d.ts +30 -0
  221. package/dist/usage/scanner.js +234 -0
  222. package/dist/usage/usage-service.d.ts +35 -0
  223. package/dist/usage/usage-service.js +130 -0
  224. package/dist/watch-seam.d.ts +29 -0
  225. package/dist/watch-seam.js +78 -0
  226. package/dist/watch-test-helpers.d.ts +28 -0
  227. package/dist/watch-test-helpers.js +86 -0
  228. package/package.json +42 -3
  229. package/src/async.ts +40 -0
  230. package/src/chat/attachment-store.ts +108 -0
  231. package/src/chat/backend.ts +199 -0
  232. package/src/chat/background-work.ts +42 -0
  233. package/src/chat/bookmark-store.ts +166 -0
  234. package/src/chat/chat-core.ts +1228 -0
  235. package/src/chat/chat-log.ts +187 -0
  236. package/src/chat/chat-process.ts +237 -0
  237. package/src/chat/chat-session.ts +1723 -0
  238. package/src/chat/chat-store.ts +261 -0
  239. package/src/chat/chat-title.ts +90 -0
  240. package/src/chat/claude-backend.ts +235 -0
  241. package/src/chat/claude-protocol.ts +684 -0
  242. package/src/chat/claude-title.ts +106 -0
  243. package/src/chat/claude-transcript.ts +233 -0
  244. package/src/chat/codex-backend.ts +397 -0
  245. package/src/chat/codex-protocol.ts +872 -0
  246. package/src/chat/codex-thread.ts +162 -0
  247. package/src/chat/codex-transport.ts +224 -0
  248. package/src/chat/composer-preferences.ts +67 -0
  249. package/src/chat/context-breakdown.ts +80 -0
  250. package/src/chat/delta-coalescer.ts +62 -0
  251. package/src/chat/errors.ts +25 -0
  252. package/src/chat/fake-claude.ts +1006 -0
  253. package/src/chat/fake-cli.ts +199 -0
  254. package/src/chat/fake-codex.ts +534 -0
  255. package/src/chat/fixtures/claude-2.1.285-auto-fallback.json +24 -0
  256. package/src/chat/fixtures/claude-2.1.285-native-children.json +6600 -0
  257. package/src/chat/fixtures/claude-2.1.285-resume-direct-compact.json +202 -0
  258. package/src/chat/fixtures/claude-2.1.285-resume-task-notification.json +57 -0
  259. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.jsonl +10 -0
  260. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a4c2e8f10b3d5a7e9.meta.json +1 -0
  261. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.jsonl +2 -0
  262. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10/subagents/agent-a9b8c7d6e5f4a3b21.meta.json +1 -0
  263. package/src/chat/fixtures/codex-0.159.2-native-approval.json +119 -0
  264. package/src/chat/fixtures/codex-0.159.2-native-file-approval.json +136 -0
  265. package/src/chat/fixtures/codex-0.159.2-native-interacted.json +220 -0
  266. package/src/chat/fixtures/codex-0.159.2-usage-windows.json +74 -0
  267. package/src/chat/input.ts +89 -0
  268. package/src/chat/limit-resume.ts +74 -0
  269. package/src/chat/projector.ts +969 -0
  270. package/src/chat/request-summary.ts +168 -0
  271. package/src/chat/subagent-projection.ts +45 -0
  272. package/src/chat/subagent-reader.ts +469 -0
  273. package/src/chat/subagent-settlement.ts +111 -0
  274. package/src/chat/thread.ts +167 -0
  275. package/src/chat/wake-chat.ts +45 -0
  276. package/src/client-sinks.ts +39 -0
  277. package/src/coded-error.ts +18 -0
  278. package/src/context/argv.ts +61 -0
  279. package/src/context/refusal.ts +50 -0
  280. package/src/context/verb.ts +392 -0
  281. package/src/error-text.ts +28 -0
  282. package/src/events.ts +9 -0
  283. package/src/fs.ts +118 -0
  284. package/src/host/agent-host.ts +124 -0
  285. package/src/host/environment.ts +31 -0
  286. package/src/host/handlers.ts +142 -0
  287. package/src/host/memory-port.ts +27 -0
  288. package/src/host/wiring.ts +164 -0
  289. package/src/lineage.ts +159 -0
  290. package/src/messages/deliver-message.ts +61 -0
  291. package/src/messages/deliver-notice.ts +120 -0
  292. package/src/messages/notice-notes.ts +34 -0
  293. package/src/messages/notice-store.ts +142 -0
  294. package/src/modes.ts +38 -0
  295. package/src/outbox/manual-clock.ts +31 -0
  296. package/src/outbox/outbox-worker.ts +214 -0
  297. package/src/outbox/outbox.ts +106 -0
  298. package/src/providers/accounts/accounts.ts +192 -0
  299. package/src/providers/accounts/launch.ts +55 -0
  300. package/src/providers/accounts/service.ts +632 -0
  301. package/src/providers/accounts/shadow-home.ts +85 -0
  302. package/src/providers/accounts/status.ts +106 -0
  303. package/src/providers/accounts/store.ts +158 -0
  304. package/src/providers/accounts/test-accounts.ts +48 -0
  305. package/src/providers/accounts/variables.ts +132 -0
  306. package/src/providers/catalog.ts +90 -0
  307. package/src/providers/claude-models.json +114 -0
  308. package/src/providers/claude-provider.ts +32 -0
  309. package/src/providers/claude.ts +123 -0
  310. package/src/providers/codex-models.json +135 -0
  311. package/src/providers/codex-provider.ts +30 -0
  312. package/src/providers/codex-rules.ts +54 -0
  313. package/src/providers/codex.ts +69 -0
  314. package/src/providers/detect.ts +40 -0
  315. package/src/providers/provider.ts +46 -0
  316. package/src/providers/registry.ts +150 -0
  317. package/src/record-directory.ts +136 -0
  318. package/src/run-process.ts +61 -0
  319. package/src/serializer.ts +41 -0
  320. package/src/skills.ts +199 -0
  321. package/src/tasks/background-limit.ts +72 -0
  322. package/src/tasks/end-children.ts +100 -0
  323. package/src/tasks/give-task.ts +52 -0
  324. package/src/tasks/task-coordinator.ts +384 -0
  325. package/src/tasks/task-store.ts +236 -0
  326. package/src/tasks/task-work.ts +62 -0
  327. package/src/tasks/waiting-child.ts +203 -0
  328. package/src/tasks/wake-parent.ts +142 -0
  329. package/src/tasks/wiring.ts +231 -0
  330. package/src/title-file.ts +57 -0
  331. package/src/usage/accounts.ts +86 -0
  332. package/src/usage/aggregate.ts +220 -0
  333. package/src/usage/exchange.ts +93 -0
  334. package/src/usage/index-file.ts +178 -0
  335. package/src/usage/limits/monitor.ts +239 -0
  336. package/src/usage/limits/normalize.ts +188 -0
  337. package/src/usage/limits/probe.ts +120 -0
  338. package/src/usage/prices-snapshot.json +1071 -0
  339. package/src/usage/pricing.ts +236 -0
  340. package/src/usage/projects.ts +65 -0
  341. package/src/usage/readers/claude.ts +60 -0
  342. package/src/usage/readers/codex.ts +196 -0
  343. package/src/usage/record.ts +60 -0
  344. package/src/usage/roots.ts +39 -0
  345. package/src/usage/scanner.ts +265 -0
  346. package/src/usage/usage-service.ts +161 -0
  347. package/src/watch-seam.ts +111 -0
  348. package/src/watch-test-helpers.ts +102 -0
@@ -0,0 +1,93 @@
1
+ import { mkdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { UsageRate } from '@adecore/agent-contracts';
4
+ import { isNotFound, writeAtomic } from '../fs.ts';
5
+ import { errorText } from '../error-text.ts';
6
+
7
+ export const FRANKFURTER_URL = 'https://api.frankfurter.app/latest';
8
+
9
+ /* The bank publishes one rate a day, so a rate a day old is the rate. */
10
+ const TTL_MS = 24 * 60 * 60 * 1000;
11
+ const FETCH_TIMEOUT_MS = 10_000;
12
+
13
+ /* The one currency the page offers next to dollars. Every price the host knows is in dollars. */
14
+ export const TARGET_CURRENCY = 'EUR';
15
+
16
+ export function parseRate(document: unknown, currency: string): Omit<UsageRate, 'fetchedAt'> | null {
17
+ const body = typeof document === 'object' && document !== null ? (document as Record<string, unknown>) : null;
18
+ const rates = body === null || typeof body.rates !== 'object' || body.rates === null ? null : (body.rates as Record<string, unknown>);
19
+ const rate = rates === null ? undefined : rates[currency];
20
+ if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0 || typeof body?.date !== 'string') {
21
+ return null;
22
+ }
23
+ return { currency, rate, date: body.date };
24
+ }
25
+
26
+ /*
27
+ * What a dollar is worth in euros, asked of the ECB through frankfurter.app once a day and kept on
28
+ * disk between runs. It is the same shape as the price table: the page works without it and simply
29
+ * stays in dollars, so nothing here is allowed to hold up a scan or fail one.
30
+ */
31
+ export class ExchangeRates {
32
+ private readonly home: string;
33
+ private readonly allowFetch: boolean;
34
+ private rate: UsageRate | null = null;
35
+ private loadedFromDisk = false;
36
+ private inFlight: Promise<void> | null = null;
37
+
38
+ constructor(home: string, allowFetch = true) {
39
+ this.home = home;
40
+ this.allowFetch = allowFetch;
41
+ }
42
+
43
+ get current(): UsageRate | null {
44
+ return this.rate;
45
+ }
46
+
47
+ /* Refreshes at most one rate at a time; a second caller waits for the first one's answer. */
48
+ ensure(): Promise<void> {
49
+ this.inFlight ??= this.refresh().finally(() => {
50
+ this.inFlight = null;
51
+ });
52
+ return this.inFlight;
53
+ }
54
+
55
+ private get file(): string {
56
+ return join(this.home, 'usage', 'exchange-rate.json');
57
+ }
58
+
59
+ private async refresh(): Promise<void> {
60
+ if (!this.loadedFromDisk) {
61
+ this.loadedFromDisk = true;
62
+ try {
63
+ const stored = JSON.parse(await readFile(this.file, 'utf8')) as UsageRate;
64
+ if (stored?.currency === TARGET_CURRENCY && typeof stored.rate === 'number' && stored.rate > 0) {
65
+ this.rate = stored;
66
+ }
67
+ } catch (e) {
68
+ if (!isNotFound(e)) {
69
+ console.warn('The stored exchange rate would not read; asking for a new one:', errorText(e));
70
+ }
71
+ }
72
+ }
73
+ if (!this.allowFetch || (this.rate !== null && Date.now() - this.rate.fetchedAt < TTL_MS)) {
74
+ return;
75
+ }
76
+ try {
77
+ const response = await fetch(`${FRANKFURTER_URL}?from=USD&to=${TARGET_CURRENCY}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
78
+ if (!response.ok) {
79
+ throw new Error(`frankfurter.app answered ${response.status}`);
80
+ }
81
+ const parsed = parseRate(await response.json(), TARGET_CURRENCY);
82
+ if (parsed === null) {
83
+ throw new Error(`The answer carried no ${TARGET_CURRENCY} rate`);
84
+ }
85
+ this.rate = { ...parsed, fetchedAt: Date.now() };
86
+ await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
87
+ await writeAtomic(this.file, JSON.stringify(this.rate));
88
+ } catch (e) {
89
+ // Yesterday's rate still converts; a rate that could not be refreshed is not a failure.
90
+ console.warn('Could not refresh the exchange rate:', errorText(e));
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,178 @@
1
+ import type { UsageProvider } from '@adecore/agent-contracts';
2
+ import type { UsageRecord } from './record.ts';
3
+ import type { CodexParserState } from './readers/codex.ts';
4
+
5
+ /* Another version is thrown away rather than migrated, since rebuilding costs one cold scan; version 1 is read as it is. */
6
+ const INDEX_VERSION = 2;
7
+ /* Before accounts: every record was the default account's, so it reads as that without a scan. */
8
+ const ACCOUNTLESS_VERSION = 1;
9
+
10
+ export interface IndexedFile {
11
+ provider: UsageProvider;
12
+ size: number;
13
+ mtimeMs: number;
14
+ /* Just past the last newline that was read, so a file that grew is read from there. */
15
+ offset: number;
16
+ records: UsageRecord[];
17
+ /* What the line still being written at the end of the file says; read again on the next scan,
18
+ so it is kept apart from the records the offset has already accounted for. */
19
+ tail: UsageRecord[];
20
+ /* What the reader has to remember to carry on halfway through a Codex file. */
21
+ codex: CodexParserState | null;
22
+ }
23
+
24
+ export type UsageIndex = Map<string, IndexedFile>;
25
+
26
+ /*
27
+ * The index is a few hundred thousand records of seven numbers each, and the same model, session,
28
+ * directory and account strings over and over. Interning those and writing a record as a tuple is
29
+ * what keeps the file at a few megabytes instead of tens of them. The account is one past its place
30
+ * in its table, and 0 for the default account, which is also what a version 1 row without it means.
31
+ */
32
+ type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?];
33
+
34
+ interface FileRow {
35
+ p: UsageProvider;
36
+ s: number;
37
+ m: number;
38
+ o: number;
39
+ r: RecordRow[];
40
+ t: RecordRow[];
41
+ c: CodexParserState | null;
42
+ }
43
+
44
+ interface IndexFile {
45
+ version: number;
46
+ models: string[];
47
+ sessions: string[];
48
+ folders: string[];
49
+ accounts?: string[];
50
+ files: Record<string, FileRow>;
51
+ }
52
+
53
+ class Interner {
54
+ readonly values: string[] = [];
55
+ private readonly at = new Map<string, number>();
56
+
57
+ index(value: string): number {
58
+ const seen = this.at.get(value);
59
+ if (seen !== undefined) {
60
+ return seen;
61
+ }
62
+ const next = this.values.length;
63
+ this.values.push(value);
64
+ this.at.set(value, next);
65
+ return next;
66
+ }
67
+ }
68
+
69
+ export function encodeIndex(index: UsageIndex): string {
70
+ const models = new Interner();
71
+ const sessions = new Interner();
72
+ const folders = new Interner();
73
+ const accounts = new Interner();
74
+ const files: Record<string, FileRow> = {};
75
+ const row = (record: UsageRecord): RecordRow => [
76
+ record.timestampMs,
77
+ models.index(record.model),
78
+ sessions.index(record.sessionId),
79
+ folders.index(record.cwd),
80
+ record.totals.calls,
81
+ record.totals.input,
82
+ record.totals.cacheRead,
83
+ record.totals.cacheWrite,
84
+ record.totals.cacheWrite1h,
85
+ record.totals.output,
86
+ record.totals.reasoning,
87
+ record.dedupeKey ?? 0,
88
+ record.account === undefined ? 0 : accounts.index(record.account) + 1
89
+ ];
90
+ for (const [path, file] of index) {
91
+ files[path] = { p: file.provider, s: file.size, m: file.mtimeMs, o: file.offset, c: file.codex, r: file.records.map(row), t: file.tail.map(row) };
92
+ }
93
+ const document: IndexFile = {
94
+ version: INDEX_VERSION,
95
+ models: models.values,
96
+ sessions: sessions.values,
97
+ folders: folders.values,
98
+ accounts: accounts.values,
99
+ files
100
+ };
101
+ return JSON.stringify(document);
102
+ }
103
+
104
+ function isRow(row: unknown, version: number): row is RecordRow {
105
+ return (
106
+ Array.isArray(row) &&
107
+ row.length === (version === ACCOUNTLESS_VERSION ? 12 : 13) &&
108
+ row.slice(0, 11).every((value) => typeof value === 'number') &&
109
+ (typeof row[11] === 'string' || row[11] === 0) &&
110
+ (version === ACCOUNTLESS_VERSION || typeof row[12] === 'number')
111
+ );
112
+ }
113
+
114
+ /*
115
+ * A row that does not read back costs a cold parse of the file it belonged to, never a wrong total,
116
+ * so anything unexpected drops that file's entry rather than being repaired.
117
+ */
118
+ export function decodeIndex(text: string): UsageIndex {
119
+ const index: UsageIndex = new Map();
120
+ let document: IndexFile;
121
+ try {
122
+ document = JSON.parse(text) as IndexFile;
123
+ } catch {
124
+ return index;
125
+ }
126
+ const version = document?.version;
127
+ if (
128
+ (version !== INDEX_VERSION && version !== ACCOUNTLESS_VERSION) ||
129
+ !Array.isArray(document.models) ||
130
+ !Array.isArray(document.sessions) ||
131
+ !Array.isArray(document.folders)
132
+ ) {
133
+ return index;
134
+ }
135
+ const accounts = Array.isArray(document.accounts) ? document.accounts : [];
136
+ const name = (table: string[], at: number): string | null => (typeof table[at] === 'string' ? table[at] : null);
137
+ for (const [path, file] of Object.entries(document.files ?? {})) {
138
+ if (file.p !== 'claude' && file.p !== 'codex') {
139
+ continue;
140
+ }
141
+ if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0 || !Array.isArray(file.r)) {
142
+ continue;
143
+ }
144
+ let broken = false;
145
+ const decode = (rows: RecordRow[]): UsageRecord[] => {
146
+ const records: UsageRecord[] = [];
147
+ for (const row of rows) {
148
+ const valid = isRow(row, version);
149
+ const model = valid ? name(document.models, row[1]) : null;
150
+ const sessionId = valid ? name(document.sessions, row[2]) : null;
151
+ const cwd = valid ? name(document.folders, row[3]) : null;
152
+ const accountAt = valid ? (row[12] ?? 0) : 0;
153
+ const account = accountAt === 0 ? undefined : name(accounts, accountAt - 1);
154
+ if (!valid || model === null || sessionId === null || cwd === null || account === null) {
155
+ broken = true;
156
+ return records;
157
+ }
158
+ records.push({
159
+ provider: file.p,
160
+ timestampMs: row[0],
161
+ model,
162
+ sessionId,
163
+ cwd,
164
+ ...(account === undefined ? {} : { account }),
165
+ totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
166
+ dedupeKey: row[11] === 0 ? null : row[11]
167
+ });
168
+ }
169
+ return records;
170
+ };
171
+ const records = decode(file.r);
172
+ const tail = decode(Array.isArray(file.t) ? file.t : []);
173
+ if (!broken) {
174
+ index.set(path, { provider: file.p, size: file.s, mtimeMs: file.m, offset: file.o, records, tail, codex: file.c ?? null });
175
+ }
176
+ }
177
+ return index;
178
+ }
@@ -0,0 +1,239 @@
1
+ import type { UsageLimitsProvider, UsageLimitsSnapshot, UsageProvider } from '@adecore/agent-contracts';
2
+ import { definedEnv } from '../../providers/accounts/launch.ts';
3
+ import type { ProviderRegistry } from '../../providers/registry.ts';
4
+ import type { AgentEvent, AgentSink } from '../../events.ts';
5
+ import { mergeWindows, type LimitsUpdate } from './normalize.ts';
6
+ import type { CodexClientInfo } from '../../chat/codex-transport.ts';
7
+ import { probeClaude, probeCodex, type ProbeResult } from './probe.ts';
8
+ import { ClientSinks } from '../../client-sinks.ts';
9
+ import { errorText } from '../../error-text.ts';
10
+
11
+ /* Often enough that a bar is never far behind, rarely enough that two CLIs are not started all day. */
12
+ const PROBE_INTERVAL_MS = 5 * 60_000;
13
+ const MAX_BACKOFF_MS = 60 * 60_000;
14
+ /* An account other than the default one that nobody started a CLI under for this long is only read when a person asks. */
15
+ const RECENT_USE_MS = 24 * 60 * 60_000;
16
+
17
+ const PROVIDERS: readonly UsageProvider[] = ['claude', 'codex'];
18
+
19
+ export type ProbeEnv = Record<string, string>;
20
+
21
+ /* An account whose plan is read. The default account of a CLI has the CLI's kind as its id. */
22
+ export interface LimitAccount {
23
+ id: string;
24
+ kind: UsageProvider;
25
+ label: string;
26
+ color?: string;
27
+ isDefault: boolean;
28
+ }
29
+
30
+ export interface LimitAccounts {
31
+ /* The default account of every CLI, and every other account that is on and signed in. */
32
+ list(): LimitAccount[];
33
+ /* The environment a probe of this account runs in. Throws for an account this machine cannot start. */
34
+ envFor(kind: UsageProvider, id: string): ProbeEnv;
35
+ /* When a chat or a terminal last started under this account; null when none did since the host started. */
36
+ lastUsedAt(id: string): number | null;
37
+ }
38
+
39
+ interface ProviderState {
40
+ kind: UsageProvider;
41
+ published: UsageLimitsProvider;
42
+ /* How long to wait after a failure; it doubles up to an hour and resets on the first answer. */
43
+ backoffMs: number;
44
+ nextAt: number;
45
+ }
46
+
47
+ function blank(kind: UsageProvider): UsageLimitsProvider {
48
+ return {
49
+ kind,
50
+ plan: null,
51
+ checkedAt: 0,
52
+ source: 'probe',
53
+ windows: [],
54
+ cost: null,
55
+ unavailable: null
56
+ };
57
+ }
58
+
59
+ export interface UsageMonitorOptions {
60
+ providers: ProviderRegistry;
61
+ /* The accounts to read; absent reads the default account of every CLI and names none. */
62
+ accounts?: LimitAccounts;
63
+ /* How to ask; a test answers without starting a CLI. */
64
+ probe?: (kind: UsageProvider, command: readonly string[], env: ProbeEnv) => Promise<ProbeResult>;
65
+ now?: () => number;
66
+ // How the host names itself to the app-server a probe opens.
67
+ client?: CodexClientInfo;
68
+ }
69
+
70
+ /*
71
+ * What is left of each plan, per account. Two sources feed it: a read of our own every five minutes,
72
+ * which starts the CLI, asks and ends it, and the events a running turn streams in between, which
73
+ * cost nothing and arrive the moment a number moves. Neither reads a credential: both CLIs already
74
+ * hold their own login and answer the question themselves.
75
+ */
76
+ export class UsageMonitor {
77
+ private readonly registry: ProviderRegistry;
78
+ private readonly accounts: LimitAccounts | null;
79
+ private readonly probe: NonNullable<UsageMonitorOptions['probe']>;
80
+ private readonly now: () => number;
81
+ private readonly sinks = new ClientSinks<AgentEvent>();
82
+ // By account id, so the default account of a CLI is under the CLI's kind.
83
+ private readonly states = new Map<string, ProviderState>();
84
+ private inFlight: Promise<void> | null = null;
85
+ private timer: ReturnType<typeof setInterval> | null = null;
86
+
87
+ constructor(options: UsageMonitorOptions) {
88
+ this.registry = options.providers;
89
+ this.accounts = options.accounts ?? null;
90
+ this.probe = options.probe ?? ((kind, command, env) => (kind === 'claude' ? probeClaude(command, env) : probeCodex(command, env, options.client)));
91
+ this.now = options.now ?? Date.now;
92
+ for (const kind of PROVIDERS) {
93
+ this.stateOf(kind, kind);
94
+ }
95
+ }
96
+
97
+ subscribe(clientId: string, sink: AgentSink): () => void {
98
+ return this.sinks.subscribe(clientId, sink);
99
+ }
100
+
101
+ /* Per CLI its default account first, in the shape an entry had before accounts, then its other accounts. */
102
+ snapshot(): UsageLimitsSnapshot {
103
+ const providers = this.listed().map(({ id, kind, label, color }) => {
104
+ const published = this.stateOf(kind, id).published;
105
+ return this.accounts === null ? published : { ...published, account: { id, label, ...(color === undefined ? {} : { color }) } };
106
+ });
107
+ return { providers };
108
+ }
109
+
110
+ /* Starts the five minute pass. The first read runs at once, so a fresh host has numbers. */
111
+ start(): void {
112
+ this.timer ??= setInterval(() => void this.refresh(false), PROBE_INTERVAL_MS);
113
+ void this.refresh(false);
114
+ }
115
+
116
+ stop(): void {
117
+ if (this.timer !== null) {
118
+ clearInterval(this.timer);
119
+ this.timer = null;
120
+ }
121
+ }
122
+
123
+ /* One pass at a time; a second caller waits for the first one's answer rather than starting a CLI. */
124
+ refresh(force: boolean): Promise<void> {
125
+ this.inFlight ??= this.runAll(force).finally(() => {
126
+ this.inFlight = null;
127
+ });
128
+ return this.inFlight;
129
+ }
130
+
131
+ /* An account came, went, was renamed or signed in: the list a client holds is drawn again. */
132
+ accountsChanged(): void {
133
+ this.emit();
134
+ }
135
+
136
+ /*
137
+ * A number a running turn reported, on the account the turn ran under. It lands on the row a full
138
+ * read drew, keeping the reset and the length the event leaves out, so the bar moves without
139
+ * waiting for the next pass.
140
+ */
141
+ applyLive(update: LimitsUpdate): void {
142
+ if (update.windows.length === 0) {
143
+ return;
144
+ }
145
+ const state = this.stateOf(update.kind, update.account ?? update.kind);
146
+ state.published = {
147
+ ...state.published,
148
+ plan: update.plan ?? state.published.plan,
149
+ checkedAt: this.now(),
150
+ source: 'event',
151
+ windows: mergeWindows(state.published.windows, update.windows),
152
+ unavailable: null
153
+ };
154
+ this.emit();
155
+ }
156
+
157
+ /* Every account of a CLI whose plan is read, the default one first. */
158
+ private listed(): LimitAccount[] {
159
+ const accounts = this.accounts?.list() ?? PROVIDERS.map((kind) => ({ id: kind, kind, label: kind, isDefault: true }));
160
+ return PROVIDERS.flatMap((kind) => {
161
+ const own = accounts.filter((account) => account.kind === kind);
162
+ return [...own.filter((account) => account.isDefault), ...own.filter((account) => !account.isDefault)];
163
+ });
164
+ }
165
+
166
+ private stateOf(kind: UsageProvider, id: string): ProviderState {
167
+ let state = this.states.get(id);
168
+ if (state === undefined || state.kind !== kind) {
169
+ state = { kind, published: blank(kind), backoffMs: PROBE_INTERVAL_MS, nextAt: 0 };
170
+ this.states.set(id, state);
171
+ }
172
+ return state;
173
+ }
174
+
175
+ /* The default account on the clock; another only while it is in use, since every read starts its CLI. */
176
+ private due(account: LimitAccount, state: ProviderState, force: boolean): boolean {
177
+ if (force) {
178
+ return true;
179
+ }
180
+ if (this.now() < state.nextAt) {
181
+ return false;
182
+ }
183
+ if (account.isDefault) {
184
+ return true;
185
+ }
186
+ const used = this.accounts?.lastUsedAt(account.id) ?? null;
187
+ return used !== null && this.now() - used < RECENT_USE_MS;
188
+ }
189
+
190
+ private async runAll(force: boolean): Promise<void> {
191
+ const installed = await this.registry.list();
192
+ let changed = false;
193
+ for (const account of this.listed()) {
194
+ const { kind } = account;
195
+ const state = this.stateOf(kind, account.id);
196
+ if (!this.due(account, state, force)) {
197
+ continue;
198
+ }
199
+ const info = installed.find((provider) => provider.kind === kind);
200
+ if (info === undefined || !info.installed) {
201
+ state.published = { ...blank(kind), checkedAt: this.now(), unavailable: { reason: 'not-installed', message: null } };
202
+ state.nextAt = this.now() + PROBE_INTERVAL_MS;
203
+ changed = true;
204
+ continue;
205
+ }
206
+ const result = await this.read(account);
207
+ const failed = 'unavailable' in result && result.unavailable?.reason === 'failed';
208
+ state.backoffMs = failed ? Math.min(MAX_BACKOFF_MS, state.backoffMs * 2) : PROBE_INTERVAL_MS;
209
+ state.nextAt = this.now() + state.backoffMs;
210
+ if (failed && state.published.windows.length > 0) {
211
+ // A pass that could not reach the CLI leaves the last good numbers where they are.
212
+ continue;
213
+ }
214
+ state.published =
215
+ 'unavailable' in result
216
+ ? { ...blank(kind), checkedAt: this.now(), unavailable: result.unavailable }
217
+ : { kind, plan: result.plan, checkedAt: this.now(), source: 'probe', windows: result.windows, cost: result.cost, unavailable: null };
218
+ changed = true;
219
+ }
220
+ if (changed) {
221
+ this.emit();
222
+ }
223
+ }
224
+
225
+ private async read(account: LimitAccount): Promise<ProbeResult> {
226
+ let env: ProbeEnv;
227
+ try {
228
+ env = this.accounts === null ? definedEnv(process.env) : this.accounts.envFor(account.kind, account.id);
229
+ } catch (e) {
230
+ return { unavailable: { reason: 'failed', message: errorText(e) } };
231
+ }
232
+ return this.probe(account.kind, this.registry.get(account.kind).command, env);
233
+ }
234
+
235
+ private emit(): void {
236
+ const event: AgentEvent = { event: 'usage.limitsChanged', payload: this.snapshot() };
237
+ this.sinks.emit(event);
238
+ }
239
+ }