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.
- package/LICENSE +674 -0
- package/README.md +176 -0
- package/bin/agentbox.mjs +103 -0
- package/briefs/adhd-mode.md +5 -0
- package/briefs/message-rules.md +95 -0
- package/briefs/worker.md +230 -0
- package/briefs/writing-rules.md +336 -0
- package/main/account-discovery.mjs +145 -0
- package/main/account-tooling.mjs +261 -0
- package/main/agent-activity.mjs +56 -0
- package/main/agent-files.mjs +422 -0
- package/main/agent-schedule.mjs +254 -0
- package/main/agent-sessions.mjs +435 -0
- package/main/agents.mjs +1095 -0
- package/main/analytics.mjs +206 -0
- package/main/answer-modes.mjs +206 -0
- package/main/approval-prompt-server.mjs +160 -0
- package/main/approvals.mjs +487 -0
- package/main/artifact-path.mjs +144 -0
- package/main/claude-bin.mjs +311 -0
- package/main/claude-input.mjs +117 -0
- package/main/claude-models.mjs +244 -0
- package/main/claude-plan.mjs +148 -0
- package/main/claude-usage.mjs +149 -0
- package/main/code-change.mjs +383 -0
- package/main/code-file.mjs +116 -0
- package/main/codex-account.mjs +146 -0
- package/main/codex-app-server.mjs +796 -0
- package/main/codex-approvals.mjs +712 -0
- package/main/codex-bin.mjs +261 -0
- package/main/codex-compaction.mjs +49 -0
- package/main/codex-models.mjs +194 -0
- package/main/codex-session.mjs +797 -0
- package/main/codex-threads.mjs +268 -0
- package/main/codex-usage-file.mjs +192 -0
- package/main/codex-usage.mjs +149 -0
- package/main/codex-watch.mjs +35 -0
- package/main/codex.mjs +757 -0
- package/main/config.mjs +345 -0
- package/main/copy-selection.mjs +55 -0
- package/main/crash-report.mjs +158 -0
- package/main/demo.mjs +254 -0
- package/main/dev-window.mjs +103 -0
- package/main/doc-file.mjs +99 -0
- package/main/doc-scheme.mjs +126 -0
- package/main/first-run.mjs +190 -0
- package/main/folders.mjs +91 -0
- package/main/fresh-user.mjs +120 -0
- package/main/git-change.mjs +323 -0
- package/main/img-scheme.mjs +90 -0
- package/main/instruction-settings.mjs +205 -0
- package/main/ipc.mjs +1558 -0
- package/main/live-replies.mjs +77 -0
- package/main/machine.mjs +113 -0
- package/main/main.mjs +972 -0
- package/main/menu.mjs +177 -0
- package/main/notify.mjs +155 -0
- package/main/open-source-file.mjs +8 -0
- package/main/project-identity.mjs +149 -0
- package/main/provider-commands.mjs +32 -0
- package/main/rail-note.mjs +74 -0
- package/main/repeats.mjs +261 -0
- package/main/row-label.mjs +185 -0
- package/main/screen-detail.mjs +44 -0
- package/main/serve.mjs +262 -0
- package/main/settings.mjs +912 -0
- package/main/staleness.mjs +76 -0
- package/main/store/chats.mjs +188 -0
- package/main/store/home.mjs +383 -0
- package/main/store/migrate.mjs +40 -0
- package/main/store/paths.mjs +82 -0
- package/main/store/pending-writes.mjs +122 -0
- package/main/store/project-lock.mjs +200 -0
- package/main/store/project.mjs +523 -0
- package/main/store/queues.mjs +31 -0
- package/main/store/work-items.mjs +299 -0
- package/main/store/write-queue.mjs +14 -0
- package/main/store-modules.mjs +23 -0
- package/main/store.mjs +1269 -0
- package/main/supervisor.mjs +6875 -0
- package/main/task-commands.mjs +161 -0
- package/main/task-folders-offthread.mjs +84 -0
- package/main/task-folders-thread.mjs +13 -0
- package/main/task-folders.mjs +409 -0
- package/main/task-remote-control.mjs +65 -0
- package/main/task-terminals.mjs +169 -0
- package/main/terminal-access.mjs +6 -0
- package/main/updater.mjs +240 -0
- package/main/zoom-keys.mjs +54 -0
- package/main/zoom-readout.mjs +20 -0
- package/mcp/agentbox-mcp.sh +34 -0
- package/mcp/core/account.mjs +77 -0
- package/mcp/core/chat.mjs +61 -0
- package/mcp/core/documents.mjs +238 -0
- package/mcp/core/page.mjs +152 -0
- package/mcp/core/products.mjs +110 -0
- package/mcp/core/skills.mjs +126 -0
- package/mcp/core/work.mjs +377 -0
- package/mcp/stdio.mjs +70 -0
- package/mcp/tools.mjs +257 -0
- package/package.json +186 -0
- package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
- package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
- package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
- package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
- package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
- package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
- package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
- package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
- package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
- package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
- package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
- package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
- package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
- package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
- package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
- package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
- package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
- package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
- package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
- package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
- package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
- package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
- package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
- package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
- package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
- package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
- package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
- package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
- package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
- package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
- package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
- package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
- package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
- package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
- package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
- package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
- package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
- package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
- package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
- package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
- package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
- package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
- package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
- package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
- package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
- package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
- package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
- package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
- package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
- package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
- package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
- package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
- package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
- package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +299 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
- package/renderer/dist/index.html +13 -0
- package/scripts/after-install.mjs +59 -0
- package/scripts/approval-server.sh +43 -0
- package/scripts/before-publish.mjs +133 -0
- package/shared/agent-import.d.mts +13 -0
- package/shared/agent-import.mjs +252 -0
- package/shared/agents.d.mts +190 -0
- package/shared/agents.mjs +937 -0
- package/shared/analytics-events.mjs +63 -0
- package/shared/answers.d.mts +12 -0
- package/shared/answers.mjs +80 -0
- package/shared/artifact-keys.d.mts +28 -0
- package/shared/artifact-keys.mjs +140 -0
- package/shared/bridge-map.mjs +128 -0
- package/shared/claude-commands.d.mts +20 -0
- package/shared/claude-commands.generated.mjs +149 -0
- package/shared/claude-commands.mjs +131 -0
- package/shared/claude-models.generated.d.mts +15 -0
- package/shared/claude-models.generated.mjs +26 -0
- package/shared/claude-usage.d.mts +16 -0
- package/shared/claude-usage.mjs +172 -0
- package/shared/codex-commands.d.mts +4 -0
- package/shared/codex-commands.mjs +17 -0
- package/shared/codex-import.d.mts +38 -0
- package/shared/codex-import.mjs +232 -0
- package/shared/codex-modes.mjs +159 -0
- package/shared/codex-usage.mjs +180 -0
- package/shared/contracts.mjs +412 -0
- package/shared/crash-scrub.mjs +182 -0
- package/shared/dashboard.mjs +481 -0
- package/shared/dead-run-trace.mjs +127 -0
- package/shared/demo-world.mjs +415 -0
- package/shared/effort-levels.d.mts +15 -0
- package/shared/effort-levels.mjs +76 -0
- package/shared/engines.d.mts +62 -0
- package/shared/engines.mjs +389 -0
- package/shared/first-run-examples.mjs +60 -0
- package/shared/first-run-line.mjs +149 -0
- package/shared/first-run-practice.d.mts +32 -0
- package/shared/first-run-practice.mjs +254 -0
- package/shared/first-run-shape.mjs +100 -0
- package/shared/fresh-user-home.mjs +189 -0
- package/shared/hook-failure.mjs +22 -0
- package/shared/install-id.mjs +39 -0
- package/shared/instruction-defaults.json +5 -0
- package/shared/library-folder.mjs +28 -0
- package/shared/notify-rules.mjs +120 -0
- package/shared/product-name.d.mts +6 -0
- package/shared/product-name.mjs +144 -0
- package/shared/project-folder-check.d.mts +22 -0
- package/shared/project-folder-check.mjs +152 -0
- package/shared/provider-commands.d.mts +8 -0
- package/shared/provider-commands.mjs +58 -0
- package/shared/rank.d.mts +19 -0
- package/shared/rank.mjs +146 -0
- package/shared/recovery.mjs +26 -0
- package/shared/referenced-files.d.mts +11 -0
- package/shared/referenced-files.mjs +122 -0
- package/shared/repeats.d.mts +45 -0
- package/shared/repeats.mjs +193 -0
- package/shared/schemes.mjs +33 -0
- package/shared/settings-terminal.d.mts +1 -0
- package/shared/settings-terminal.mjs +14 -0
- package/shared/side-build.mjs +57 -0
- package/shared/spawn-trouble.d.mts +8 -0
- package/shared/spawn-trouble.mjs +599 -0
- package/shared/terminal-state.d.mts +1 -0
- package/shared/terminal-state.mjs +58 -0
- package/shared/usage.d.mts +53 -0
- package/shared/usage.mjs +319 -0
- package/shared/work-items.mjs +561 -0
- package/shared/work-lines.mjs +360 -0
- 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
|
+
}
|