@adecore/agents 0.0.1 → 0.17.0-beta.2

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 +48 -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,265 @@
1
+ import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { UsageRoot } from '@adecore/agent-contracts';
4
+ import { isNotFound, writeAtomic } from '../fs.ts';
5
+ import { decodeIndex, encodeIndex, type IndexedFile, type UsageIndex } from './index-file.ts';
6
+ import { foldByKey, type UsageRecord } from './record.ts';
7
+ import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.ts';
8
+ import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine, type CodexParserState } from './readers/codex.ts';
9
+ import { usageRoots, type UsageRootPath } from './roots.ts';
10
+ import { errorText } from '../error-text.ts';
11
+
12
+ /* What a file holds from `from` to its end, as it is at the read. */
13
+ async function readFrom(path: string, from: number): Promise<string> {
14
+ const handle = await open(path, 'r');
15
+ try {
16
+ const { size } = await handle.stat();
17
+ const buffer = Buffer.alloc(Math.max(0, size - from));
18
+ let filled = 0;
19
+ while (filled < buffer.length) {
20
+ const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, from + filled);
21
+ if (bytesRead === 0) {
22
+ break;
23
+ }
24
+ filled += bytesRead;
25
+ }
26
+ return buffer.subarray(0, filled).toString('utf8');
27
+ } finally {
28
+ await handle.close();
29
+ }
30
+ }
31
+
32
+ export interface ScanReport {
33
+ at: number;
34
+ files: number;
35
+ changedFiles: number;
36
+ durationMs: number;
37
+ roots: UsageRoot[];
38
+ }
39
+
40
+ interface ParsedFile {
41
+ records: UsageRecord[];
42
+ tail: UsageRecord[];
43
+ offset: number;
44
+ codex: CodexParserState | null;
45
+ }
46
+
47
+ async function listJsonl(dir: string, out: string[] = []): Promise<string[]> {
48
+ let entries;
49
+ try {
50
+ entries = await readdir(dir, { withFileTypes: true });
51
+ } catch {
52
+ // A directory that cannot be read is one the scan walks past; the root itself reports status.
53
+ return out;
54
+ }
55
+ for (const entry of entries) {
56
+ const path = join(dir, entry.name);
57
+ if (entry.isDirectory()) {
58
+ await listJsonl(path, out);
59
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
60
+ out.push(path);
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /*
67
+ * Reads what a file added since the last scan. Only whole lines count towards the offset; the piece
68
+ * after the last newline is a line still being written, so it is parsed for what it is worth and
69
+ * read again next time. The Codex state is snapshotted before that tail for the same reason.
70
+ */
71
+ function parseChunk(text: string, provider: 'claude' | 'codex', from: number, state: CodexParserState | null): ParsedFile {
72
+ let records: UsageRecord[] = [];
73
+ const end = text.lastIndexOf('\n');
74
+ const whole = end === -1 ? '' : text.slice(0, end);
75
+ const tail = text.slice(end + 1);
76
+ const read = (chunk: string, codex: CodexParserState | null): void => {
77
+ for (const raw of chunk.split('\n')) {
78
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
79
+ if (line === '') {
80
+ continue;
81
+ }
82
+ if (provider === 'claude') {
83
+ if (claudeMightCarryUsage(line)) {
84
+ const record = parseClaudeLine(line);
85
+ if (record !== null) {
86
+ records.push(record);
87
+ }
88
+ }
89
+ continue;
90
+ }
91
+ if (codex !== null && codexMightCarryUsage(line)) {
92
+ const record = parseCodexLine(line, codex);
93
+ if (record !== null) {
94
+ records.push(record);
95
+ }
96
+ }
97
+ }
98
+ };
99
+ const codex = provider === 'codex' ? (state ?? createCodexState()) : null;
100
+ read(whole, codex);
101
+ const committed = codex === null ? null : cloneCodexState(codex);
102
+ const counted = records;
103
+ records = [];
104
+ read(tail, codex);
105
+ return { records: counted, tail: records, offset: from + (end === -1 ? 0 : Buffer.byteLength(whole, 'utf8') + 1), codex: committed };
106
+ }
107
+
108
+ export interface UsageScannerOptions {
109
+ /* The account of every session the host knows ran, by `<provider>\0<sessionId>`, the default one under its kind. */
110
+ sessionAccounts?: () => ReadonlyMap<string, string>;
111
+ }
112
+
113
+ type AccountOf = (record: UsageRecord) => string | undefined;
114
+
115
+ /*
116
+ * The account a record of a root belongs to, as a record keeps it (absent for the default account):
117
+ * the one account that writes there, else the one the host knows ran the session, else the
118
+ * default account of the CLI, which is the only one a session nobody started through the host ran under.
119
+ */
120
+ export function accountResolver(root: UsageRootPath, sessionAccounts: () => ReadonlyMap<string, string>): AccountOf {
121
+ const accounts = root.accounts ?? [root.provider];
122
+ const stored = (id: string): string | undefined => (id === root.provider ? undefined : id);
123
+ if (accounts.length === 1) {
124
+ const only = stored(accounts[0]!);
125
+ return () => only;
126
+ }
127
+ const fallback = accounts.includes(root.provider) ? root.provider : accounts[0]!;
128
+ return (record) => {
129
+ const ran = sessionAccounts().get(`${root.provider}\0${record.sessionId}`);
130
+ return stored(ran !== undefined && accounts.includes(ran) ? ran : fallback);
131
+ };
132
+ }
133
+
134
+ function withAccount(records: UsageRecord[], accountOf: AccountOf): UsageRecord[] {
135
+ return records.map((record) => {
136
+ const account = accountOf(record);
137
+ return account === undefined ? record : { ...record, account };
138
+ });
139
+ }
140
+
141
+ /*
142
+ * Read appended transcript bytes incrementally, but restart after rewrites or truncation. Preserve
143
+ * totals for deleted transcripts because their usage still happened.
144
+ */
145
+ export class UsageScanner {
146
+ private readonly home: string;
147
+ private readonly roots: () => UsageRootPath[];
148
+ private readonly sessionAccounts: () => ReadonlyMap<string, string>;
149
+ private index: UsageIndex = new Map();
150
+ private loaded = false;
151
+ private dirty = false;
152
+
153
+ /* `roots` as a function is asked again on every scan, so an account added since is walked too. */
154
+ constructor(home: string, roots: UsageRootPath[] | (() => UsageRootPath[]) = usageRoots(), options: UsageScannerOptions = {}) {
155
+ this.home = home;
156
+ this.roots = typeof roots === 'function' ? roots : () => roots;
157
+ this.sessionAccounts = options.sessionAccounts ?? (() => new Map());
158
+ }
159
+
160
+ private get file(): string {
161
+ return join(this.home, 'usage', 'index.json');
162
+ }
163
+
164
+ async load(): Promise<void> {
165
+ if (this.loaded) {
166
+ return;
167
+ }
168
+ this.loaded = true;
169
+ try {
170
+ this.index = decodeIndex(await readFile(this.file, 'utf8'));
171
+ } catch (e) {
172
+ if (!isNotFound(e)) {
173
+ console.warn('The usage index would not read; starting from a cold scan:', errorText(e));
174
+ }
175
+ }
176
+ }
177
+
178
+ async scan(): Promise<ScanReport> {
179
+ const started = Date.now();
180
+ await this.load();
181
+ const roots: UsageRoot[] = [];
182
+ let files = 0;
183
+ let changedFiles = 0;
184
+ let sessions: ReadonlyMap<string, string> | null = null;
185
+ const sessionAccounts = (): ReadonlyMap<string, string> => {
186
+ sessions ??= this.sessionAccounts();
187
+ return sessions;
188
+ };
189
+
190
+ for (const root of this.roots()) {
191
+ try {
192
+ const info = await stat(root.path);
193
+ if (!info.isDirectory()) {
194
+ roots.push({ provider: root.provider, path: root.path, status: 'missing', message: null });
195
+ continue;
196
+ }
197
+ } catch (e) {
198
+ const missing = isNotFound(e);
199
+ roots.push({
200
+ provider: root.provider,
201
+ path: root.path,
202
+ status: missing ? 'missing' : 'failed',
203
+ message: missing ? null : errorText(e)
204
+ });
205
+ continue;
206
+ }
207
+ let failure: string | null = null;
208
+ const accountOf = accountResolver(root, sessionAccounts);
209
+ for (const path of await listJsonl(root.path)) {
210
+ files += 1;
211
+ try {
212
+ if (await this.readFileInto(path, root.provider, accountOf)) {
213
+ changedFiles += 1;
214
+ }
215
+ } catch (e) {
216
+ // One unreadable transcript is not the root failing; the count simply misses it.
217
+ failure ??= errorText(e);
218
+ }
219
+ }
220
+ roots.push({ provider: root.provider, path: root.path, status: failure === null ? 'ok' : 'failed', message: failure });
221
+ }
222
+
223
+ if (this.dirty) {
224
+ this.dirty = false;
225
+ await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
226
+ await writeAtomic(this.file, encodeIndex(this.index));
227
+ }
228
+ return { at: Date.now(), files, changedFiles, durationMs: Date.now() - started, roots };
229
+ }
230
+
231
+ /* Every record the index holds, with the copies one message left in several transcripts folded. */
232
+ records(): UsageRecord[] {
233
+ const all: UsageRecord[] = [];
234
+ for (const file of this.index.values()) {
235
+ all.push(...file.records, ...file.tail);
236
+ }
237
+ return foldByKey(all);
238
+ }
239
+
240
+ private async readFileInto(path: string, provider: 'claude' | 'codex', accountOf: AccountOf): Promise<boolean> {
241
+ const info = await stat(path);
242
+ const known = this.index.get(path);
243
+ if (known !== undefined && known.provider === provider && known.size === info.size && known.mtimeMs === info.mtimeMs) {
244
+ return false;
245
+ }
246
+ const grown = known !== undefined && known.provider === provider && info.size > known.size;
247
+ const from = grown ? known.offset : 0;
248
+ const text = await readFrom(path, from);
249
+ const parsed = parseChunk(text, provider, from, grown ? known.codex : null);
250
+ const before = grown ? known.records : [];
251
+ const entry: IndexedFile = {
252
+ provider,
253
+ size: info.size,
254
+ mtimeMs: info.mtimeMs,
255
+ offset: parsed.offset,
256
+ // Folding here keeps the streaming copies of one message out of the index entirely.
257
+ records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
258
+ tail: withAccount(parsed.tail, accountOf),
259
+ codex: parsed.codex
260
+ };
261
+ this.index.set(path, entry);
262
+ this.dirty = true;
263
+ return true;
264
+ }
265
+ }
@@ -0,0 +1,161 @@
1
+ import type { UsageAccount, UsageSummaryPayload, UsageSummaryResult } from '@adecore/agent-contracts';
2
+ import type { AgentEvent, AgentSink } from '../events.ts';
3
+ import { accountOfRecord, aggregate } from './aggregate.ts';
4
+ import { ExchangeRates } from './exchange.ts';
5
+ import { PriceBook } from './pricing.ts';
6
+ import type { KnownProject } from './projects.ts';
7
+ import type { UsageRecord } from './record.ts';
8
+ import { UsageScanner, type ScanReport, type UsageScannerOptions } from './scanner.ts';
9
+ import type { UsageRootPath } from './roots.ts';
10
+ import { errorText } from '../error-text.ts';
11
+ import { ClientSinks } from '../client-sinks.ts';
12
+
13
+ /* A scan this fresh answers the question the page is asking, so nothing is opened for it. */
14
+ const SCAN_TTL_MS = 60_000;
15
+
16
+ export interface UsageServiceOptions {
17
+ home: string;
18
+ /* Off with `--no-price-fetch`: the bundled table then prices everything and no rate is asked for. */
19
+ allowPriceFetch?: boolean;
20
+ /* The projects the host knows, so a folder can wear the name it has in the app. */
21
+ knownProjects(): Promise<KnownProject[]>;
22
+ /* Where the transcripts are, asked again on every scan when a function. */
23
+ roots?: UsageRootPath[] | (() => UsageRootPath[]);
24
+ sessionAccounts?: UsageScannerOptions['sessionAccounts'];
25
+ /* The accounts of the machine, for the page to filter by; absent names none. */
26
+ accounts?: () => UsageAccount[];
27
+ }
28
+
29
+ /* The accounts of the machine, and after them any a record names that the machine no longer has, under its id. */
30
+ function withRecordAccounts(accounts: UsageAccount[], records: readonly UsageRecord[]): UsageAccount[] {
31
+ const listed = new Set(accounts.map((account) => account.id));
32
+ const gone: UsageAccount[] = [];
33
+ for (const record of records) {
34
+ const id = accountOfRecord(record);
35
+ if (!listed.has(id)) {
36
+ listed.add(id);
37
+ gone.push({ id, kind: record.provider, label: id });
38
+ }
39
+ }
40
+ return [...accounts, ...gone];
41
+ }
42
+
43
+ const EMPTY_SCAN: ScanReport = { at: 0, files: 0, changedFiles: 0, durationMs: 0, roots: [] };
44
+
45
+ /*
46
+ * What the usage page asks the host. It owns one scanner and one price table for every client:
47
+ * the transcripts are machine-wide, so a second person looking costs a second aggregation and not a
48
+ * second scan. The scan runs when a request finds the last one stale and, while any client has the
49
+ * page open, once a minute, which is what `usage.changed` announces.
50
+ */
51
+ export class UsageService {
52
+ private readonly scanner: UsageScanner;
53
+ private readonly prices: PriceBook;
54
+ private readonly rates: ExchangeRates;
55
+ private readonly knownProjects: UsageServiceOptions['knownProjects'];
56
+ private readonly accounts: (() => UsageAccount[]) | null;
57
+ private readonly sinks = new ClientSinks<AgentEvent>((clientId) => this.unfollow(clientId));
58
+ private readonly followers = new Set<string>();
59
+ private report: ScanReport = EMPTY_SCAN;
60
+ private failed = false;
61
+ private inFlight: Promise<void> | null = null;
62
+ private timer: ReturnType<typeof setInterval> | null = null;
63
+
64
+ constructor(options: UsageServiceOptions) {
65
+ this.scanner = new UsageScanner(options.home, options.roots, options.sessionAccounts ? { sessionAccounts: options.sessionAccounts } : {});
66
+ this.accounts = options.accounts ?? null;
67
+ this.prices = new PriceBook(options.home, options.allowPriceFetch ?? true);
68
+ this.rates = new ExchangeRates(options.home, options.allowPriceFetch ?? true);
69
+ this.knownProjects = options.knownProjects;
70
+ }
71
+
72
+ subscribe(clientId: string, sink: AgentSink): () => void {
73
+ return this.sinks.subscribe(clientId, sink);
74
+ }
75
+
76
+ /* The page is open here: keep the numbers moving until it closes or the socket does. */
77
+ follow(clientId: string): void {
78
+ this.followers.add(clientId);
79
+ this.timer ??= setInterval(() => {
80
+ void this.rescan();
81
+ }, SCAN_TTL_MS);
82
+ }
83
+
84
+ unfollow(clientId: string): void {
85
+ this.followers.delete(clientId);
86
+ if (this.followers.size === 0 && this.timer !== null) {
87
+ clearInterval(this.timer);
88
+ this.timer = null;
89
+ }
90
+ }
91
+
92
+ async summary(payload: UsageSummaryPayload): Promise<UsageSummaryResult> {
93
+ if (Date.now() - this.report.at > SCAN_TTL_MS) {
94
+ await this.rescan();
95
+ }
96
+ const records = this.scanner.records();
97
+ const { buckets, models, projects, sessions } = await aggregate(records, payload, this.prices, await this.knownProjects());
98
+ return {
99
+ from: payload.from,
100
+ to: payload.to,
101
+ resolution: payload.resolution,
102
+ timeZone: payload.timeZone,
103
+ buckets,
104
+ models,
105
+ projects,
106
+ sessions,
107
+ scan: {
108
+ at: this.report.at,
109
+ files: this.report.files,
110
+ changedFiles: this.report.changedFiles,
111
+ durationMs: this.report.durationMs,
112
+ running: this.inFlight !== null,
113
+ failed: this.failed
114
+ },
115
+ pricing: this.prices.pricing,
116
+ rate: this.rates.current,
117
+ roots: this.report.roots,
118
+ ...(this.accounts === null ? {} : { accounts: withRecordAccounts(this.accounts(), records) })
119
+ };
120
+ }
121
+
122
+ stop(): void {
123
+ if (this.timer !== null) {
124
+ clearInterval(this.timer);
125
+ this.timer = null;
126
+ }
127
+ }
128
+
129
+ /* One pass at a time: a request that arrives while a pass runs waits for that one's answer. */
130
+ private rescan(): Promise<void> {
131
+ this.inFlight ??= this.runScan().finally(() => {
132
+ this.inFlight = null;
133
+ });
134
+ return this.inFlight;
135
+ }
136
+
137
+ private async runScan(): Promise<void> {
138
+ // The table and the rate are fetched beside the walk, so a slow answer never holds up the scan.
139
+ const [report] = await Promise.all([
140
+ this.scanner.scan().catch((e: unknown) => {
141
+ console.error('The usage scan failed:', errorText(e));
142
+ return null;
143
+ }),
144
+ this.prices.ensure(),
145
+ this.rates.ensure()
146
+ ]);
147
+ this.failed = report === null;
148
+ if (report === null) {
149
+ // The last good numbers stay on screen; only the moment of the scan is not moved on.
150
+ return;
151
+ }
152
+ this.report = report;
153
+ if (report.changedFiles > 0) {
154
+ this.emit({ event: 'usage.changed', payload: { scannedAt: report.at } });
155
+ }
156
+ }
157
+
158
+ private emit(event: AgentEvent): void {
159
+ this.sinks.emit(event);
160
+ }
161
+ }
@@ -0,0 +1,111 @@
1
+ import { watch } from 'node:fs';
2
+
3
+ /* The part of an `fs.watch` handle the watchers use. */
4
+ export interface DirectoryWatcher {
5
+ on(event: 'error', listener: (error: Error) => void): unknown;
6
+ close(): void;
7
+ }
8
+
9
+ export type WatchDirectory = (path: string, options: { recursive: boolean }, listener: (event: string, filename: string | null) => void) => DirectoryWatcher;
10
+
11
+ /* Runs `callback` after `ms` and answers with the function that cancels it. */
12
+ export type Schedule = (callback: () => unknown, ms: number) => () => void;
13
+
14
+ /* What every watcher reaches the file system and the clock through, so a test can hand it fakes that it drives by hand. */
15
+ export interface WatchSeams {
16
+ watch: WatchDirectory;
17
+ schedule: Schedule;
18
+ }
19
+
20
+ /*
21
+ * Recursive watching is one call to the platform on macOS and Windows; elsewhere it costs a
22
+ * descriptor per directory, which a folder tree has too many of to be worth it.
23
+ */
24
+ export function supportsRecursive(platform: NodeJS.Platform): boolean {
25
+ return platform === 'darwin' || platform === 'win32';
26
+ }
27
+
28
+ export const SYSTEM_WATCH: WatchSeams = {
29
+ watch: (path, options, listener) => watch(path, options, listener),
30
+ schedule: (callback, ms) => {
31
+ const timer = setTimeout(callback, ms);
32
+ return () => clearTimeout(timer);
33
+ }
34
+ };
35
+
36
+ /* One run after a burst of changes: every nudge puts it off again, and a stop drops the one waiting. */
37
+ export interface Settled {
38
+ nudge(): void;
39
+ stop(): void;
40
+ }
41
+
42
+ /*
43
+ * A save, a formatter and a build all touch a folder in a burst, and one answer per burst is enough.
44
+ * How long a burst is differs per watcher: reading one file again is cheap, a status run over a
45
+ * whole repository is not.
46
+ */
47
+ export function settled(seams: WatchSeams, ms: number, run: () => unknown): Settled {
48
+ let cancel: (() => void) | null = null;
49
+ return {
50
+ nudge: () => {
51
+ cancel?.();
52
+ cancel = seams.schedule(() => {
53
+ cancel = null;
54
+ return run();
55
+ }, ms);
56
+ },
57
+ stop: () => {
58
+ cancel?.();
59
+ cancel = null;
60
+ }
61
+ };
62
+ }
63
+
64
+ /*
65
+ * What each client watches, keyed on the path it asked for. A watch is per client, like a session
66
+ * attach: two windows on the same folder each get their own, and a socket that goes takes only its
67
+ * own with it.
68
+ */
69
+ export class PerClientWatches<TState> {
70
+ private readonly byClient = new Map<string, Map<string, TState>>();
71
+ private readonly stop: (state: TState) => void;
72
+
73
+ constructor(stop: (state: TState) => void) {
74
+ this.stop = stop;
75
+ }
76
+
77
+ get(clientId: string, key: string): TState | undefined {
78
+ return this.byClient.get(clientId)?.get(key);
79
+ }
80
+
81
+ put(clientId: string, key: string, state: TState): void {
82
+ const watches = this.byClient.get(clientId) ?? new Map<string, TState>();
83
+ watches.set(key, state);
84
+ this.byClient.set(clientId, watches);
85
+ }
86
+
87
+ /* Everything this client watches, as a copy, so a caller may close one while it walks them. */
88
+ all(clientId: string): Array<[string, TState]> {
89
+ return [...(this.byClient.get(clientId) ?? [])];
90
+ }
91
+
92
+ remove(clientId: string, key: string): void {
93
+ const watches = this.byClient.get(clientId);
94
+ const state = watches?.get(key);
95
+ if (!watches || state === undefined) {
96
+ return;
97
+ }
98
+ this.stop(state);
99
+ watches.delete(key);
100
+ if (watches.size === 0) {
101
+ this.byClient.delete(clientId);
102
+ }
103
+ }
104
+
105
+ detachAll(clientId: string): void {
106
+ for (const state of this.byClient.get(clientId)?.values() ?? []) {
107
+ this.stop(state);
108
+ }
109
+ this.byClient.delete(clientId);
110
+ }
111
+ }
@@ -0,0 +1,102 @@
1
+ import type { DirectoryWatcher, WatchDirectory, WatchSeams } from './watch-seam.ts';
2
+
3
+ export class FakeDirectoryWatcher implements DirectoryWatcher {
4
+ readonly path: string;
5
+ readonly recursive: boolean;
6
+ closed = false;
7
+ private readonly listener: (event: string, filename: string | null) => void;
8
+
9
+ constructor(path: string, recursive: boolean, listener: (event: string, filename: string | null) => void) {
10
+ this.path = path;
11
+ this.recursive = recursive;
12
+ this.listener = listener;
13
+ }
14
+
15
+ /* What the platform would report for a write to `filename`, relative to the watched directory. */
16
+ emit(filename: string | null, event: 'change' | 'rename' = 'change'): void {
17
+ if (this.closed) {
18
+ return;
19
+ }
20
+ this.listener(event, filename);
21
+ }
22
+
23
+ on(): this {
24
+ return this;
25
+ }
26
+
27
+ close(): void {
28
+ this.closed = true;
29
+ }
30
+ }
31
+
32
+ /*
33
+ * A watch function and a settle timer that only move when a test says so: `emit` on a watcher stands
34
+ * in for the platform, and `settle` runs every timer that is due and waits for the work it started.
35
+ */
36
+ export class FakeWatch implements WatchSeams {
37
+ readonly watchers: FakeDirectoryWatcher[] = [];
38
+ private readonly due = new Map<number, () => unknown>();
39
+ private nextId = 1;
40
+
41
+ readonly watch: WatchDirectory = (path, options, listener) => {
42
+ const watcher = new FakeDirectoryWatcher(path, options.recursive, listener);
43
+ this.watchers.push(watcher);
44
+ return watcher;
45
+ };
46
+
47
+ readonly schedule = (callback: () => unknown, _ms: number): (() => void) => {
48
+ const id = this.nextId++;
49
+ this.due.set(id, callback);
50
+ return () => {
51
+ this.due.delete(id);
52
+ };
53
+ };
54
+
55
+ /* How many timers are waiting to run. */
56
+ get pending(): number {
57
+ return this.due.size;
58
+ }
59
+
60
+ openOn(path: string): FakeDirectoryWatcher[] {
61
+ return this.watchers.filter((watcher) => watcher.path === path && !watcher.closed);
62
+ }
63
+
64
+ /* The one open watcher on `path`, which a test that emits on it expects to exist. */
65
+ on(path: string): FakeDirectoryWatcher {
66
+ const [watcher, ...rest] = this.openOn(path);
67
+ if (!watcher || rest.length > 0) {
68
+ throw new Error(`Expected one open watcher on ${path}, found ${rest.length + (watcher ? 1 : 0)}`);
69
+ }
70
+ return watcher;
71
+ }
72
+
73
+ async settle(): Promise<void> {
74
+ const callbacks = [...this.due.values()];
75
+ this.due.clear();
76
+ await Promise.all(callbacks.map((callback) => callback()));
77
+ }
78
+ }
79
+
80
+ /* A sink's events, with a promise for the moment one of them passes a check, so a test awaits the event itself rather than a deadline. */
81
+ export class EventLog<T> {
82
+ readonly events: T[] = [];
83
+ private waiters: Array<{ check: (events: T[]) => boolean; resolve: () => void }> = [];
84
+
85
+ readonly push = (event: T): void => {
86
+ this.events.push(event);
87
+ const ready = this.waiters.filter((waiter) => waiter.check(this.events));
88
+ this.waiters = this.waiters.filter((waiter) => !ready.includes(waiter));
89
+ for (const waiter of ready) {
90
+ waiter.resolve();
91
+ }
92
+ };
93
+
94
+ until(check: (events: T[]) => boolean): Promise<void> {
95
+ if (check(this.events)) {
96
+ return Promise.resolve();
97
+ }
98
+ return new Promise((resolve) => {
99
+ this.waiters.push({ check, resolve });
100
+ });
101
+ }
102
+ }