agentbox-app 0.1.6

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 (302) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +176 -0
  3. package/bin/agentbox.mjs +103 -0
  4. package/briefs/adhd-mode.md +5 -0
  5. package/briefs/message-rules.md +95 -0
  6. package/briefs/worker.md +230 -0
  7. package/briefs/writing-rules.md +336 -0
  8. package/main/account-discovery.mjs +145 -0
  9. package/main/account-tooling.mjs +261 -0
  10. package/main/agent-activity.mjs +56 -0
  11. package/main/agent-files.mjs +422 -0
  12. package/main/agent-schedule.mjs +254 -0
  13. package/main/agent-sessions.mjs +435 -0
  14. package/main/agents.mjs +1095 -0
  15. package/main/analytics.mjs +206 -0
  16. package/main/answer-modes.mjs +206 -0
  17. package/main/approval-prompt-server.mjs +160 -0
  18. package/main/approvals.mjs +487 -0
  19. package/main/artifact-path.mjs +144 -0
  20. package/main/claude-bin.mjs +311 -0
  21. package/main/claude-input.mjs +117 -0
  22. package/main/claude-models.mjs +244 -0
  23. package/main/claude-plan.mjs +148 -0
  24. package/main/claude-usage.mjs +149 -0
  25. package/main/code-change.mjs +383 -0
  26. package/main/code-file.mjs +116 -0
  27. package/main/codex-account.mjs +146 -0
  28. package/main/codex-app-server.mjs +796 -0
  29. package/main/codex-approvals.mjs +712 -0
  30. package/main/codex-bin.mjs +261 -0
  31. package/main/codex-compaction.mjs +49 -0
  32. package/main/codex-models.mjs +194 -0
  33. package/main/codex-session.mjs +797 -0
  34. package/main/codex-threads.mjs +268 -0
  35. package/main/codex-usage-file.mjs +192 -0
  36. package/main/codex-usage.mjs +149 -0
  37. package/main/codex-watch.mjs +35 -0
  38. package/main/codex.mjs +757 -0
  39. package/main/config.mjs +345 -0
  40. package/main/copy-selection.mjs +55 -0
  41. package/main/crash-report.mjs +158 -0
  42. package/main/demo.mjs +254 -0
  43. package/main/dev-window.mjs +103 -0
  44. package/main/doc-file.mjs +99 -0
  45. package/main/doc-scheme.mjs +126 -0
  46. package/main/first-run.mjs +190 -0
  47. package/main/folders.mjs +91 -0
  48. package/main/fresh-user.mjs +120 -0
  49. package/main/git-change.mjs +323 -0
  50. package/main/img-scheme.mjs +90 -0
  51. package/main/instruction-settings.mjs +205 -0
  52. package/main/ipc.mjs +1558 -0
  53. package/main/live-replies.mjs +77 -0
  54. package/main/machine.mjs +113 -0
  55. package/main/main.mjs +972 -0
  56. package/main/menu.mjs +177 -0
  57. package/main/notify.mjs +155 -0
  58. package/main/open-source-file.mjs +8 -0
  59. package/main/project-identity.mjs +149 -0
  60. package/main/provider-commands.mjs +32 -0
  61. package/main/rail-note.mjs +74 -0
  62. package/main/repeats.mjs +261 -0
  63. package/main/row-label.mjs +185 -0
  64. package/main/screen-detail.mjs +44 -0
  65. package/main/serve.mjs +262 -0
  66. package/main/settings.mjs +912 -0
  67. package/main/staleness.mjs +76 -0
  68. package/main/store/chats.mjs +188 -0
  69. package/main/store/home.mjs +383 -0
  70. package/main/store/migrate.mjs +40 -0
  71. package/main/store/paths.mjs +82 -0
  72. package/main/store/pending-writes.mjs +122 -0
  73. package/main/store/project-lock.mjs +200 -0
  74. package/main/store/project.mjs +523 -0
  75. package/main/store/queues.mjs +31 -0
  76. package/main/store/work-items.mjs +299 -0
  77. package/main/store/write-queue.mjs +14 -0
  78. package/main/store-modules.mjs +23 -0
  79. package/main/store.mjs +1269 -0
  80. package/main/supervisor.mjs +6875 -0
  81. package/main/task-commands.mjs +161 -0
  82. package/main/task-folders-offthread.mjs +84 -0
  83. package/main/task-folders-thread.mjs +13 -0
  84. package/main/task-folders.mjs +409 -0
  85. package/main/task-remote-control.mjs +65 -0
  86. package/main/task-terminals.mjs +169 -0
  87. package/main/terminal-access.mjs +6 -0
  88. package/main/updater.mjs +240 -0
  89. package/main/zoom-keys.mjs +54 -0
  90. package/main/zoom-readout.mjs +20 -0
  91. package/mcp/agentbox-mcp.sh +34 -0
  92. package/mcp/core/account.mjs +77 -0
  93. package/mcp/core/chat.mjs +61 -0
  94. package/mcp/core/documents.mjs +238 -0
  95. package/mcp/core/page.mjs +152 -0
  96. package/mcp/core/products.mjs +110 -0
  97. package/mcp/core/skills.mjs +126 -0
  98. package/mcp/core/work.mjs +377 -0
  99. package/mcp/stdio.mjs +70 -0
  100. package/mcp/tools.mjs +257 -0
  101. package/package.json +186 -0
  102. package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
  103. package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
  104. package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
  105. package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
  106. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  107. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  108. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  109. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  110. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  111. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  112. package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
  113. package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
  114. package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
  115. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  116. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  117. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  118. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  119. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  120. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  121. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  122. package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
  123. package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
  124. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  125. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  126. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  127. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  128. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  129. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  130. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  131. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  132. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  133. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  134. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  135. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  136. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  137. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  138. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  139. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  140. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  141. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  142. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  143. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  144. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  145. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  146. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  147. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  148. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  149. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  150. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  151. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  152. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  153. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  154. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  155. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  156. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  157. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  158. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  159. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  160. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  161. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  162. package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
  163. package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
  164. package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
  165. package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
  166. package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
  167. package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
  168. package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
  169. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  170. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  171. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  172. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  173. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  174. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  175. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  176. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  177. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  178. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  179. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  180. package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -0
  181. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  182. package/renderer/dist/emulator/data/compression/README.md +11 -0
  183. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  184. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  185. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  186. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  187. package/renderer/dist/emulator/data/cores/README.md +22 -0
  188. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  189. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  190. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  191. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  192. package/renderer/dist/emulator/data/cores/package.json +65 -0
  193. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  194. package/renderer/dist/emulator/data/emulator.css +1614 -0
  195. package/renderer/dist/emulator/data/loader.js +170 -0
  196. package/renderer/dist/emulator/data/localization/README.md +63 -0
  197. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  198. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  199. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  200. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  201. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  202. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  203. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  204. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  205. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  206. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  207. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  208. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  209. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  210. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  211. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  212. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  213. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  214. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  215. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  216. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  217. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  218. package/renderer/dist/emulator/data/src/compression.js +142 -0
  219. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  220. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  221. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  222. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  223. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  224. package/renderer/dist/emulator/data/src/storage.js +125 -0
  225. package/renderer/dist/emulator/data/version.json +5 -0
  226. package/renderer/dist/emulator/host.js +299 -0
  227. package/renderer/dist/emulator/index.html +28 -0
  228. package/renderer/dist/emulator/provenance.json +6 -0
  229. package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
  230. package/renderer/dist/index.html +13 -0
  231. package/scripts/after-install.mjs +59 -0
  232. package/scripts/approval-server.sh +43 -0
  233. package/scripts/before-publish.mjs +133 -0
  234. package/shared/agent-import.d.mts +13 -0
  235. package/shared/agent-import.mjs +252 -0
  236. package/shared/agents.d.mts +190 -0
  237. package/shared/agents.mjs +937 -0
  238. package/shared/analytics-events.mjs +63 -0
  239. package/shared/answers.d.mts +12 -0
  240. package/shared/answers.mjs +80 -0
  241. package/shared/artifact-keys.d.mts +28 -0
  242. package/shared/artifact-keys.mjs +140 -0
  243. package/shared/bridge-map.mjs +128 -0
  244. package/shared/claude-commands.d.mts +20 -0
  245. package/shared/claude-commands.generated.mjs +149 -0
  246. package/shared/claude-commands.mjs +131 -0
  247. package/shared/claude-models.generated.d.mts +15 -0
  248. package/shared/claude-models.generated.mjs +26 -0
  249. package/shared/claude-usage.d.mts +16 -0
  250. package/shared/claude-usage.mjs +172 -0
  251. package/shared/codex-commands.d.mts +4 -0
  252. package/shared/codex-commands.mjs +17 -0
  253. package/shared/codex-import.d.mts +38 -0
  254. package/shared/codex-import.mjs +232 -0
  255. package/shared/codex-modes.mjs +159 -0
  256. package/shared/codex-usage.mjs +180 -0
  257. package/shared/contracts.mjs +412 -0
  258. package/shared/crash-scrub.mjs +182 -0
  259. package/shared/dashboard.mjs +481 -0
  260. package/shared/dead-run-trace.mjs +127 -0
  261. package/shared/demo-world.mjs +415 -0
  262. package/shared/effort-levels.d.mts +15 -0
  263. package/shared/effort-levels.mjs +76 -0
  264. package/shared/engines.d.mts +62 -0
  265. package/shared/engines.mjs +389 -0
  266. package/shared/first-run-examples.mjs +60 -0
  267. package/shared/first-run-line.mjs +149 -0
  268. package/shared/first-run-practice.d.mts +32 -0
  269. package/shared/first-run-practice.mjs +254 -0
  270. package/shared/first-run-shape.mjs +100 -0
  271. package/shared/fresh-user-home.mjs +189 -0
  272. package/shared/hook-failure.mjs +22 -0
  273. package/shared/install-id.mjs +39 -0
  274. package/shared/instruction-defaults.json +5 -0
  275. package/shared/library-folder.mjs +28 -0
  276. package/shared/notify-rules.mjs +120 -0
  277. package/shared/product-name.d.mts +6 -0
  278. package/shared/product-name.mjs +144 -0
  279. package/shared/project-folder-check.d.mts +22 -0
  280. package/shared/project-folder-check.mjs +152 -0
  281. package/shared/provider-commands.d.mts +8 -0
  282. package/shared/provider-commands.mjs +58 -0
  283. package/shared/rank.d.mts +19 -0
  284. package/shared/rank.mjs +146 -0
  285. package/shared/recovery.mjs +26 -0
  286. package/shared/referenced-files.d.mts +11 -0
  287. package/shared/referenced-files.mjs +122 -0
  288. package/shared/repeats.d.mts +45 -0
  289. package/shared/repeats.mjs +193 -0
  290. package/shared/schemes.mjs +33 -0
  291. package/shared/settings-terminal.d.mts +1 -0
  292. package/shared/settings-terminal.mjs +14 -0
  293. package/shared/side-build.mjs +57 -0
  294. package/shared/spawn-trouble.d.mts +8 -0
  295. package/shared/spawn-trouble.mjs +599 -0
  296. package/shared/terminal-state.d.mts +1 -0
  297. package/shared/terminal-state.mjs +58 -0
  298. package/shared/usage.d.mts +53 -0
  299. package/shared/usage.mjs +319 -0
  300. package/shared/work-items.mjs +561 -0
  301. package/shared/work-lines.mjs +360 -0
  302. package/worker-permissions.json +6 -0
@@ -0,0 +1,268 @@
1
+ // HER CODEX CONVERSATIONS, READ OFF WHAT CODEX LEAVES ON THE MAC.
2
+ //
3
+ // Codex keeps two things this reads, both plain files and both measured on her
4
+ // Mac on 2026-09-14 at 19:3x:
5
+ //
6
+ // ~/.codex/session_index.jsonl one line per thread Codex has named:
7
+ // {"id", "thread_name", "updated_at"}.
8
+ // 118 lines that day. A thread Codex has
9
+ // not named yet is simply not in it.
10
+ // ~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl
11
+ // the transcript. Line one is
12
+ // `session_meta` with `cwd`, `timestamp`
13
+ // and `thread_source`; the rest are
14
+ // events, of which `user_message` and
15
+ // `agent_message` are the two this reads.
16
+ //
17
+ // WHICH THREADS ARE HERS. `thread_source` is `user` for one she typed into,
18
+ // `guardian_review` for Codex's own reviewer of a turn, and `subagent` for a
19
+ // helper a thread spawned. 247 transcripts were touched in the seven days to
20
+ // 09-14; 158 of them were guardians. Without this filter the inbox would offer
21
+ // her four rows for every conversation she had.
22
+ //
23
+ // There is also `~/.codex/state_5.sqlite`, which has everything above in one
24
+ // table. It is not read: Electron ships no sqlite, the file is written live
25
+ // under WAL, and the two plain files carry what the row needs.
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import { isScratchFolder, shortFolder, threadTitle } from './agent-sessions.mjs';
30
+
31
+ export const CODEX_RECENT_DAYS = 7;
32
+ /** How many day folders the walk opens at most, whatever `days` says. */
33
+ const MAX_DAY_FOLDERS = 40;
34
+ /**
35
+ * A transcript is not read past this: the head and the tail are both inside it
36
+ * for every conversation measured, and a runaway file must not stall a tick. */
37
+ const MAX_BYTES = 8 * 1024 * 1024;
38
+
39
+ export const codexHome = (home = os.homedir()) => path.join(home, '.codex');
40
+
41
+ /** The names Codex gave her threads, by id. Missing file is an empty map. */
42
+ export function readCodexIndex(codexDir = codexHome()) {
43
+ const out = new Map();
44
+ let text = '';
45
+ try { text = fs.readFileSync(path.join(codexDir, 'session_index.jsonl'), 'utf8'); } catch { return out; }
46
+ for (const line of text.split('\n')) {
47
+ if (!line.trim()) continue;
48
+ try {
49
+ const j = JSON.parse(line);
50
+ const id = String(j?.id ?? '').trim();
51
+ if (!id) continue;
52
+ const at = Date.parse(j?.updated_at ?? '');
53
+ out.set(id, { name: String(j?.thread_name ?? '').trim(), updatedAt: Number.isFinite(at) ? at : 0 });
54
+ } catch { /* one bad line is not a reason to lose the rest */ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ const numericDesc = (dir) => {
60
+ let names = [];
61
+ try { names = fs.readdirSync(dir); } catch { return []; }
62
+ return names.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(b) - Number(a));
63
+ };
64
+
65
+ /** Every transcript in the newest `days` day folders, newest folder first. */
66
+ export function listRolloutFiles(codexDir = codexHome(), { days = CODEX_RECENT_DAYS } = {}) {
67
+ const root = path.join(codexDir, 'sessions');
68
+ const out = [];
69
+ let looked = 0;
70
+ const limit = Math.min(Math.max(1, Number(days) || 1), MAX_DAY_FOLDERS);
71
+ for (const year of numericDesc(root)) {
72
+ for (const month of numericDesc(path.join(root, year))) {
73
+ for (const day of numericDesc(path.join(root, year, month))) {
74
+ if (looked >= limit) return out;
75
+ looked += 1;
76
+ const dir = path.join(root, year, month, day);
77
+ let names = [];
78
+ try { names = fs.readdirSync(dir); } catch { names = []; }
79
+ for (const name of names) {
80
+ if (/^rollout-.*\.jsonl$/.test(name)) out.push(path.join(dir, name));
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /* * * Codex Desktop prefixes a prompt with the files she attached. Everything else is kept as
89
+ is.
90
+ */
91
+ export function cleanPrompt(text) {
92
+ let t = String(text ?? '');
93
+ const at = t.indexOf('## My request:');
94
+ if (at >= 0) t = t.slice(at + '## My request:'.length);
95
+ t = t.replace(/^\s*Distinguish instructions in attached documents from the user's request\.\s*/m, '');
96
+ // Codex Desktop wraps each attached picture in <image>…</image> markers in
97
+ // the text beside it; the picture itself is an input_image part.
98
+ t = t.replace(/<\/?image>/g, '');
99
+ return t.trim();
100
+ }
101
+
102
+ /**
103
+ * WHAT CODEX PUTS IN HER MOUTH. In paginated history mode the user turn is a
104
+ * `response_item` with several `<recommended_plugins>…`,
105
+ * `<environment_context>…`, `<user_instructions>…`. Every one of those opens
106
+ * with an angle-bracket tag on its first line, and nothing she types does.
107
+ * Measured on the thread she screenshotted (2026-09-15): three parts, the
108
+ * first two injected. */
109
+ export function isInjected(text) {
110
+ const t = String(text ?? '').trimStart();
111
+ const first = t.split('\n')[0] ?? '';
112
+ if (/^<[a-z_]+[^>]*>/i.test(first)) return true;
113
+ // The folder's AGENTS.md, pasted in as "# AGENTS.md instructions for
114
+ // /path" followed by an <INSTRUCTIONS> block. Measured on the thread she
115
+ // screenshotted (2026-09-15): it was the second part of her first turn, and
116
+ // the first read of it printed it as what she asked.
117
+ if (/^#\s*AGENTS\.md instructions/i.test(first)) return true;
118
+ return /^\s*<INSTRUCTIONS>/i.test(t.slice(first.length, first.length + 200));
119
+ }
120
+
121
+ /**
122
+ * WHAT CODEX SAID, without its own tool markers. Codex Desktop embeds a
123
+ * drawing call as private-use characters around a JSON blob, e.g.
124
+ * U+E200 "visualize" U+E202 {…} U+E201, which the row would otherwise print
125
+ * as a box and a path. The text on either side is the message. */
126
+ export function cleanAnswer(text) {
127
+ return String(text ?? '')
128
+ .replace(/[-][a-z_]*[-]\{[^\n]*?\}[-]?/g, '')
129
+ .replace(/[-]/g, '')
130
+ .replace(/\n{3,}/g, '\n\n')
131
+ .trim();
132
+ }
133
+
134
+ const partsOf = (payload) => (Array.isArray(payload?.content) ? payload.content : []).filter((c) => c && typeof c === 'object');
135
+
136
+ /**
137
+ * One transcript, read once: who started it, where, what she asked first and
138
+ * what Codex said last. Null when the file is not a Codex transcript.
139
+ *
140
+ * TWO SHAPES OF TRANSCRIPT, AND BOTH ARE READ. Codex's `history_mode` is
141
+ * `legacy` or `paginated` (line one says which). Legacy writes `event_msg`
142
+ * lines of type `user_message` / `agent_message` with a `message` string.
143
+ * Paginated writes none of those: the turns are `response_item` lines of type
144
+ * `message` with a `role` and a `content` array of `input_text`, `input_image`
145
+ * and `output_text` parts. Measured 2026-09-15 across the seven days before:
146
+ * all 61 of her own threads were paginated, and the first build read only
147
+ * legacy, which is why her row said "Codex has not answered yet." */
148
+ export function readRollout(file) {
149
+ let text = '';
150
+ try {
151
+ const fd = fs.openSync(file, 'r');
152
+ try {
153
+ const size = fs.fstatSync(fd).size;
154
+ const buf = Buffer.alloc(Math.min(size, MAX_BYTES));
155
+ fs.readSync(fd, buf, 0, buf.length, 0);
156
+ text = buf.toString('utf8');
157
+ } finally { fs.closeSync(fd); }
158
+ } catch { return null; }
159
+ const lines = text.split('\n');
160
+ let meta = null;
161
+ let prompt = '';
162
+ let last = '';
163
+ let turns = 0;
164
+ let images = 0;
165
+ let lastActive = 0;
166
+ // Paginated mode writes the same turn twice when an event_msg is also
167
+ // present, so a turn is counted once whichever line says it.
168
+ let sawEventTurns = false;
169
+ for (const line of lines) {
170
+ if (!line.trim()) continue;
171
+ let j;
172
+ try { j = JSON.parse(line); } catch { continue; }
173
+ if (!meta) {
174
+ if (j?.type !== 'session_meta') return null;
175
+ const p = j.payload ?? {};
176
+ const startedAt = Date.parse(p.timestamp ?? j.timestamp ?? '');
177
+ meta = {
178
+ id: String(p.session_id ?? p.id ?? '').trim(),
179
+ cwd: String(p.cwd ?? '').trim(),
180
+ threadSource: typeof p.thread_source === 'string' ? p.thread_source : (typeof p.source === 'string' ? 'user' : 'other'),
181
+ startedAt: Number.isFinite(startedAt) ? startedAt : 0,
182
+ };
183
+ continue;
184
+ }
185
+ const at = Date.parse(j.timestamp ?? '');
186
+ if (Number.isFinite(at) && at > lastActive) lastActive = at;
187
+ const p = j.payload ?? {};
188
+ if (j.type === 'event_msg') {
189
+ if (p.type === 'user_message') {
190
+ const said = cleanPrompt(p.message);
191
+ if (!prompt && said && !isInjected(said)) prompt = said;
192
+ turns += 1;
193
+ sawEventTurns = true;
194
+ } else if (p.type === 'agent_message') {
195
+ const said = cleanAnswer(p.message);
196
+ if (said) last = said;
197
+ }
198
+ continue;
199
+ }
200
+ if (j.type !== 'response_item' || p.type !== 'message') continue;
201
+ if (p.role === 'user') {
202
+ const parts = partsOf(p);
203
+ images += parts.filter((c) => c.type === 'input_image').length;
204
+ const hers = parts
205
+ .filter((c) => c.type === 'input_text' && typeof c.text === 'string' && !isInjected(c.text))
206
+ .map((c) => cleanPrompt(c.text))
207
+ .filter(Boolean);
208
+ if (!hers.length) continue; // a turn made of nothing but Codex's own preamble
209
+ if (!prompt) prompt = hers.join('\n\n').trim();
210
+ if (!sawEventTurns) turns += 1;
211
+ } else if (p.role === 'assistant') {
212
+ const said = cleanAnswer(partsOf(p).filter((c) => c.type === 'output_text').map((c) => c.text ?? '').join('\n'));
213
+ if (said) last = said;
214
+ }
215
+ }
216
+ if (!meta || !meta.id) return null;
217
+ let mtime = 0;
218
+ try { mtime = fs.statSync(file).mtimeMs; } catch { mtime = 0; }
219
+ return { ...meta, prompt, last, turns, images, lastActive, path: file, mtime };
220
+ }
221
+
222
+ /**
223
+ * A transcript written to in the last two minutes is a conversation Codex is
224
+ * still in. Two minutes because Codex writes a token count line every turn and
225
+ * a turn on her Mac was never longer than that between writes. */
226
+ export const LIVE_MS = 2 * 60_000;
227
+ export const isLive = (mtime, now = Date.now()) => Number(mtime) > 0 && now - Number(mtime) < LIVE_MS;
228
+
229
+ /**
230
+ * HER CONVERSATIONS, newest first, in the shape the import card and the
231
+ * import row both read. One entry per thread she typed into, in a real folder,
232
+ * touched in the last `days` days. */
233
+ export function readCodexThreads({ home = os.homedir(), codexDir = codexHome(home), days = CODEX_RECENT_DAYS, now = Date.now() } = {}) {
234
+ const index = readCodexIndex(codexDir);
235
+ const cutoff = now - Math.max(0, Number(days) || 0) * 86400 * 1000;
236
+ const out = [];
237
+ const skipped = { notHers: 0, scratch: 0, old: 0, empty: 0, unreadable: 0 };
238
+ for (const file of listRolloutFiles(codexDir, { days })) {
239
+ const r = readRollout(file);
240
+ if (!r) { skipped.unreadable += 1; continue; }
241
+ if (r.threadSource !== 'user') { skipped.notHers += 1; continue; }
242
+ if (!r.cwd || isScratchFolder(r.cwd, home)) { skipped.scratch += 1; continue; }
243
+ const named = index.get(r.id);
244
+ const when = Math.max(named?.updatedAt ?? 0, r.mtime, r.startedAt);
245
+ if (when < cutoff) { skipped.old += 1; continue; }
246
+ const title = named?.name || threadTitle([r.prompt]);
247
+ if (!title) { skipped.empty += 1; continue; }
248
+ out.push({
249
+ id: r.id,
250
+ source: 'codex',
251
+ folder: r.cwd,
252
+ folderName: path.basename(r.cwd),
253
+ short: shortFolder(r.cwd, home),
254
+ title,
255
+ when,
256
+ startedAt: r.startedAt,
257
+ lastActive: Math.max(r.lastActive, r.mtime),
258
+ turns: r.turns,
259
+ images: r.images,
260
+ path: file,
261
+ prompt: r.prompt,
262
+ last: r.last,
263
+ live: isLive(r.mtime, now),
264
+ });
265
+ }
266
+ out.sort((a, b) => b.when - a.when);
267
+ return { threads: out, skipped };
268
+ }
@@ -0,0 +1,192 @@
1
+ // THE CODEX FIGURE SHE ALREADY HAS ON THIS MAC, WHEN NOTHING HAS PUSHED ONE.
2
+ //
3
+ // IT WAS NOT A BUG, AND IT WAS STILL WRONG. main/codex-usage.mjs holds one rule
4
+ // and it is a good one: never spawn anything and never ask OpenAI anything to
5
+ // draw a picture. Codex's figures ride the app-server the fleet is already
6
+ // running and arrive unprompted during a turn. The unstated cost of that rule is
7
+ // that the app only ever learns a number for work POWERUP ITSELF RAN. She runs
8
+ // Codex in its own app all day, so on her Mac the push had never arrived and the
9
+ // panel was empty while her weekly limit sat at 100% used.
10
+ //
11
+ // SO THERE IS A THIRD SOURCE, AND IT BREAKS NEITHER HALF OF THAT RULE. Codex
12
+ // writes every rate-limit report it receives into its own session log under
13
+ // CODEX_HOME, as a `rate_limits` object on the `token_count` events. Reading it
14
+ // starts no process, sends nothing anywhere, and touches no credential: it is a
15
+ // file on her disk that Codex put there. Measured on her Mac 2026-09-18: 1,140
16
+ // such records across her twenty-five most recent sessions, the newest written
17
+ // the same minute she filed the row.
18
+ //
19
+ // THE PUSH STILL WINS. A live notification is this second's truth and a log is
20
+ // the last thing anybody wrote down, so main/codex-usage.mjs reads this only when
21
+ // it has nothing of its own. Nothing about the existing path changes.
22
+ //
23
+ // IT IS THE SAME ACCOUNT OR IT IS NOT READ. One CODEX_HOME is one login, and the
24
+ // log lives inside the home, so a reading taken from `~/.codex/sessions` is a
25
+ // fact about whoever is signed into `~/.codex` and about nobody else. The home is
26
+ // handed in for exactly that reason, never guessed at here.
27
+ //
28
+ // THE FILES ARE ENORMOUS AND ARE NEVER READ WHOLE. Her newest session log was
29
+ // 791 MB on the day this was written. Only the tail of each is read, newest
30
+ // first, and the answer is cached against the file's own modified time so an idle
31
+ // app re-reads nothing.
32
+
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+
36
+ /**
37
+ * How much of the end of a session log to read. Her records sit roughly 1.6 MB
38
+ * apart in the biggest file measured, so this holds several of them; a file
39
+ * whose tail has none simply falls through to the next one. */
40
+ const TAIL_BYTES = 8 * 1024 * 1024;
41
+
42
+ /**
43
+ * How many session logs to look back through before giving up. A log with no
44
+ * rate limits in its tail is ordinary: a session can end on a long tool output.
45
+ * Three is enough to cross that and is still three cheap reads. */
46
+ const FILES = 3;
47
+
48
+ /**
49
+ * Don't answer out of a log nobody has written to in a fortnight. A stale
50
+ * percentage is worse than no percentage, and the panel says how old its reading
51
+ * is rather than hiding it, so this only has to catch the absurd cases. */
52
+ const TOO_OLD_MS = 14 * 24 * 60 * 60 * 1000;
53
+
54
+ const plain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
55
+
56
+ /**
57
+ * A window is only worth having if it says how much is spent. Either spelling,
58
+ * because the log is snake_case and the push is camel. */
59
+ const usable = (window) => plain(window)
60
+ && (Number.isFinite(window.used_percent) || Number.isFinite(window.usedPercent));
61
+
62
+ /**
63
+ * THE NEWEST SESSION LOGS UNDER THIS HOME, newest first.
64
+ *
65
+ * Codex files them by date (`sessions/2026/09/18/rollout-....jsonl`), so the
66
+ * walk is three levels of directory and never a full recursive scan of a folder
67
+ * holding a thousand files. Only the newest day or two is ever opened.
68
+ */
69
+ function newestLogs(home, limit = FILES) {
70
+ const root = path.join(home, 'sessions');
71
+ const found = [];
72
+ const descend = (dir, depth) => {
73
+ let entries;
74
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
75
+ if (depth === 0) {
76
+ for (const entry of entries) {
77
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
78
+ const file = path.join(dir, entry.name);
79
+ try { found.push({ file, at: fs.statSync(file).mtimeMs }); } catch { /* it went away */ }
80
+ }
81
+ return;
82
+ }
83
+ // Newest folder first, and only a couple of them: the name sorts as the date
84
+ // because Codex names them for the date.
85
+ const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort().reverse().slice(0, 2);
86
+ for (const name of dirs) descend(path.join(dir, name), depth - 1);
87
+ };
88
+ descend(root, 3);
89
+ return found.sort((a, b) => b.at - a.at).slice(0, limit);
90
+ }
91
+
92
+ /** The end of a file, without reading the front of it. */
93
+ function tail(file, bytes = TAIL_BYTES) {
94
+ let fd = null;
95
+ try {
96
+ fd = fs.openSync(file, 'r');
97
+ const size = fs.fstatSync(fd).size;
98
+ const take = Math.min(size, bytes);
99
+ const buffer = Buffer.allocUnsafe(take);
100
+ fs.readSync(fd, buffer, 0, take, size - take);
101
+ return { text: buffer.toString('utf8'), whole: take === size };
102
+ } catch {
103
+ return null;
104
+ } finally {
105
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* nothing to do */ } }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * THE LAST RATE LIMITS THIS LOG RECORDS, or null.
111
+ *
112
+ * Lines are walked backwards because the last report is the one that is true. A
113
+ * report carrying no usable window is skipped rather than accepted: Codex emits
114
+ * an all-null snapshot for limit families that do not apply to the account (her
115
+ * `premium` family, every field null), and taking one of those would blank a
116
+ * panel that had a real figure a moment earlier.
117
+ */
118
+ function lastLimitsIn(file) {
119
+ const read = tail(file);
120
+ if (!read) return null;
121
+ const lines = read.text.split('\n');
122
+ // The first line is a fragment unless the whole file was read, and a fragment
123
+ // is not JSON.
124
+ if (!read.whole) lines.shift();
125
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
126
+ const line = lines[i];
127
+ if (!line || !line.includes('"rate_limits"')) continue;
128
+ let event = null;
129
+ try { event = JSON.parse(line); } catch { continue; }
130
+ const limits = find(event);
131
+ if (!limits) continue;
132
+ if (!usable(limits.primary) && !usable(limits.secondary)) continue;
133
+ const at = Date.parse(event?.timestamp ?? '');
134
+ return {
135
+ snapshot: { primary: limits.primary ?? null, secondary: limits.secondary ?? null },
136
+ at: Number.isFinite(at) ? at : null,
137
+ };
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * `rate_limits` sits a couple of levels inside the event and Codex has moved it
144
+ * before, so it is looked for rather than reached for. */
145
+ function find(node, depth = 0) {
146
+ if (!plain(node) || depth > 6) return null;
147
+ if (plain(node.rate_limits)) return node.rate_limits;
148
+ for (const value of Object.values(node)) {
149
+ const hit = find(value, depth + 1);
150
+ if (hit) return hit;
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * What was read last, so an app nobody is touching re-reads nothing. Keyed by
157
+ * home, and thrown away when the newest log is written to again. */
158
+ const remembered = new Map();
159
+
160
+ /**
161
+ * THE LAST FIGURE CODEX WROTE DOWN UNDER THIS HOME, or null because it never
162
+ * wrote one. Null is an ordinary answer on a Mac where Codex has never run.
163
+ *
164
+ * @param {string|null} home The CODEX_HOME this reading would belong to.
165
+ * @returns {{ snapshot: object, at: number }|null}
166
+ */
167
+ export function loggedCodexLimits(home, { now = Date.now() } = {}) {
168
+ if (!home) return null;
169
+ const logs = newestLogs(home);
170
+ if (!logs.length) return null;
171
+ if (now - logs[0].at > TOO_OLD_MS) return null;
172
+
173
+ const seen = remembered.get(home);
174
+ if (seen && seen.file === logs[0].file && seen.at === logs[0].at) return seen.reading;
175
+
176
+ let reading = null;
177
+ for (const log of logs) {
178
+ reading = lastLimitsIn(log.file);
179
+ if (reading) {
180
+ // The event's own timestamp when it has one, and the file's otherwise.
181
+ if (reading.at === null) reading = { ...reading, at: log.at };
182
+ break;
183
+ }
184
+ }
185
+ remembered.set(home, { file: logs[0].file, at: logs[0].at, reading });
186
+ return reading;
187
+ }
188
+
189
+ /** Drop what was remembered. For tests, and for a home that has changed under us. */
190
+ export function forgetLoggedLimits() {
191
+ remembered.clear();
192
+ }
@@ -0,0 +1,149 @@
1
+ // WHAT CODEX HAS ALREADY SAID IS LEFT, AND NOTHING IS SPENT ASKING.
2
+ //
3
+ // The pure half is shared/codex-usage.mjs: the payload in, the limits out. This
4
+ // is the half that holds what a running app-server reported, and the whole of
5
+ // its shape is two rules.
6
+ //
7
+ // NEVER SPAWN ANYTHING TO DRAW A PICTURE. main/claude-usage.mjs is built around
8
+ // the opposite fact -- `claude -p /usage` costs a child process and twenty-five
9
+ // seconds, so it is a cache with a background refresh and a thirty-minute
10
+ // backoff after a miss. None of that applies here and none of it is copied.
11
+ // Codex's figures ride the `codex app-server` THE FLEET IS ALREADY RUNNING:
12
+ // they arrive unprompted as `account/rateLimits/updated` during a turn.
13
+ //
14
+ // AND NEVER ASK FOR ONE EITHER (the founder, 2026-09-05). This reader briefly
15
+ // also sent an explicit rate-limits read down that pipe whenever its figure
16
+ // went stale. It is deliberately gone, and this paragraph is here so the next
17
+ // reader does not put it back as an obvious improvement.
18
+ //
19
+ // IT IS NOT A LOCAL LOOKUP. Measured on this Mac 2026-09-05 against a real
20
+ // `codex app-server` 0.148.0 in a scratch CODEX_HOME with no credentials, that
21
+ // read answered `{"error":{"code":-32600,"message":"codex account
22
+ // authentication required to read rate limits"}}` -- an auth demand, not an
23
+ // empty snapshot. A call served out of state the app-server already holds does
24
+ // not need an account token; one that has to SEND that token does. The binary
25
+ // agrees: `app-server/src/request_processors/account_processor.rs` reaches
26
+ // `backend-client/src/client/rate_limit_resets.rs`, and the strings beside it
27
+ // are `/api/codex/usage`, `https://chatgpt.com/backend-api`, `failed to fetch
28
+ // codex rate limits: no snapshots returned` and `rate limit reset credit
29
+ // detail request timed out`. Local state does not time out, and has no
30
+ // snapshots to fail to return.
31
+ //
32
+ // SO IT IS A NEW NETWORK CALL, and CLAUDE.md's rule on those is not
33
+ // negotiable: "Anything that leaves the machine... a new network call of any
34
+ // kind" means `legal/privacy.html` and `legal/terms.html` are edited IN THE
35
+ // SAME SESSION. Those pages live in the founder's store rather than in this
36
+ // repo, so that obligation cannot be discharged from a session working here,
37
+ // and shipping the call without it would break a stated law rather than bend
38
+ // one.
39
+ //
40
+ // THE COST OF LIVING WITHOUT IT WAS MEASURED AND IS SMALL. The push is free
41
+ // and already arrives, so the corner fills a beat later -- once a Codex turn
42
+ // starts pushing -- rather than being asked for. And a workspace set to Codex
43
+ // with nothing running drew no corner either way, because there was no
44
+ // app-server there to ask.
45
+ //
46
+ // SO THERE IS NO READING AT ALL UNTIL A TURN REPORTS ONE, AND THAT IS A NORMAL
47
+ // TUESDAY rather than a failure. On a Mac where no Codex work has run the corner
48
+ // draws nothing, and nothing was spent finding that out. That is the honest
49
+ // answer and the one the dashboard law asks for: a limit nobody has read is not
50
+ // a limit at nothing, and it may never be drawn as a zero.
51
+ //
52
+ // A READING OUTLIVES THE PROCESS THAT GAVE IT, deliberately. The app-server
53
+ // goes down with the last worker; the figures it reported are still the last
54
+ // true thing anybody knows, and the panel already says how old they are.
55
+ //
56
+ // AND A READING BELONGS TO THE LOGIN THAT GAVE IT. There is one app-server per
57
+ // CODEX_HOME since a second Codex login became real (`_codexServer` in
58
+ // main/supervisor.mjs), and CODEX_HOME is the variable that names which account
59
+ // pays. A reading taken under one home is not a fact about another, so it is
60
+ // dropped rather than redrawn when the home moves. Showing login A's percentage
61
+ // over login B's work is exactly the defect this whole slice is fixing, one
62
+ // level down.
63
+
64
+ import { codexLimits, mergeRateLimits } from '../shared/codex-usage.mjs';
65
+ import { loggedCodexLimits } from './codex-usage-file.mjs';
66
+
67
+ export class CodexUsage {
68
+ /**
69
+ * @param {object} how @param { => string|null} how.home Which CODEX_HOME the
70
+ * current login is, so a reading taken under another one is dropped rather
71
+ * than redrawn. @param { => void} [how.onChange] Told when the figures move,
72
+ * so the window redraws. Never called for an update that changed nothing.
73
+ */
74
+ constructor({ home = () => null, onChange = () => {}, now = () => Date.now(), readLog = loggedCodexLimits } = {}) {
75
+ this._home = home;
76
+ this._onChange = onChange;
77
+ this._now = now;
78
+ /** Handed in so a test can answer without a session log on disk. */
79
+ this._readLog = readLog;
80
+ /** @type {{ primary: object|null, secondary: object|null }|null} */
81
+ this._snapshot = null;
82
+ this._at = 0;
83
+ this._takenUnder = null;
84
+ }
85
+
86
+ /**
87
+ * THE LAST THING CODEX REPORTED, INSTANTLY AND WITHOUT ASKING ANYBODY.
88
+ *
89
+ * Null until the first push lands, which on most Macs is for ever, because
90
+ * most Macs never run a Codex worker. The corner draws nothing then rather
91
+ * than a bar full of zeroes: a limit nobody has read is not a limit at
92
+ * nothing.
93
+ */
94
+ read() {
95
+ if (this._takenUnder !== null && this._takenUnder !== this._home()) this._forget();
96
+ const limits = codexLimits(this._snapshot);
97
+ if (limits.length) return { limits, at: this._at };
98
+ // NOTHING HAS PUSHED, SO ASK WHAT CODEX ALREADY WROTE DOWN. This is the
99
+ // whole of: the push only ever arrives for work the app itself ran, and she
100
+ // runs Codex in its own app, so on her Mac the panel stayed empty while her
101
+ // weekly limit was full. main/codex-usage-file.mjs is a read of a file
102
+ // under this same CODEX_HOME. It starts no process and sends nothing
103
+ // anywhere, so neither rule above is bent.
104
+ return this._logged();
105
+ }
106
+
107
+ /**
108
+ * The last figure in Codex's own session log under this home, shaped exactly
109
+ * like a pushed one. Null on a Mac where Codex has never run. */
110
+ _logged() {
111
+ const reading = this._readLog(this._home(), { now: this._now() });
112
+ const limits = codexLimits(reading?.snapshot);
113
+ return limits.length ? { limits, at: reading.at } : null;
114
+ }
115
+
116
+ /**
117
+ * A NOTIFICATION OFF THE APP-SERVER'S LOOSE CHANNEL, AND THE ONLY WAY A FIGURE
118
+ * EVER GETS IN HERE. Everything that belongs to no thread arrives on it
119
+ * (main/codex-app-server.mjs `onNotification`), so this is handed every one of
120
+ * them and keeps the one it is about.
121
+ *
122
+ * IT MERGES. The update is documented sparse -- see shared/codex-usage.mjs for
123
+ * the schema's own wording -- so a notification carrying only the five hour
124
+ * window must not take the week away with it.
125
+ */
126
+ saw(method, params, home = this._home()) {
127
+ if (method !== 'account/rateLimits/updated') return false;
128
+ // A login that is not the one the corner is about has nothing to say about
129
+ // it. There is one app-server per home and they all report through here.
130
+ if (home !== this._home()) return false;
131
+ return this._keep(mergeRateLimits(this._snapshot, params?.rateLimits), home);
132
+ }
133
+
134
+ _keep(snapshot, home) {
135
+ const before = JSON.stringify(this._snapshot);
136
+ this._snapshot = snapshot;
137
+ this._at = this._now();
138
+ this._takenUnder = home;
139
+ if (JSON.stringify(snapshot) === before) return false;
140
+ this._onChange();
141
+ return true;
142
+ }
143
+
144
+ _forget() {
145
+ this._snapshot = null;
146
+ this._at = 0;
147
+ this._takenUnder = null;
148
+ }
149
+ }
@@ -0,0 +1,35 @@
1
+ // THE WATCH THAT NOTICES A CODEX CONVERSATION AND ASKS.
2
+ //
3
+ // Her pick on 19:53: "ask first". So every minute this reads the conversations
4
+ // she had in Codex this week, and for each one that runs in a folder one of her
5
+ // projects points at and has no row anywhere yet, it files the asking row in
6
+ // that project. It also keeps the rows she said yes to current: when Codex
7
+ // writes another answer, the row's result follows it.
8
+ //
9
+ // A conversation in a folder no project points at gets no row here. It is
10
+ // still on the ⌘K import card, under a section that makes the project on the
11
+ // press, which is the card's own way of handling a new folder.
12
+ import { readCodexThreads } from './codex-threads.mjs';
13
+
14
+ export const CODEX_POLL_MS = 60_000;
15
+
16
+ export function scanCodex({ store, readThreads = readCodexThreads, now = Date.now() } = {}) {
17
+ let threads = [];
18
+ try { ({ threads } = readThreads({ now })); } catch { return { asked: 0, refreshed: 0, retold: 0, threads: 0 }; }
19
+ const asked = store.askAboutCodexThreads(threads, { now });
20
+ const refreshed = store.refreshCodexMirrors(threads, { now });
21
+ const retold = store.refreshCodexAsks(threads, { now });
22
+ return { asked, refreshed, retold, threads: threads.length };
23
+ }
24
+
25
+ export function startCodexWatch({ store, readThreads = readCodexThreads, pollMs = CODEX_POLL_MS, firstDelayMs = 8_000 } = {}) {
26
+ const run = () => {
27
+ try { scanCodex({ store, readThreads }); } catch (e) { console.warn('codex watch:', e?.message ?? e); }
28
+ };
29
+ const first = setTimeout(run, firstDelayMs);
30
+ const timer = setInterval(run, pollMs);
31
+ return {
32
+ scanNow: run,
33
+ stop: () => { clearTimeout(first); clearInterval(timer); },
34
+ };
35
+ }