@ashlr/hub 3.7.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/CHANGELOG.md +257 -0
  2. package/dist/build-identity.json +2 -2
  3. package/dist/cli/resource-profile.js +95 -21
  4. package/dist/cli/resource-profile.js.map +1 -1
  5. package/dist/core/policy/local-only.d.ts +142 -2
  6. package/dist/core/policy/local-only.js +204 -15
  7. package/dist/core/policy/local-only.js.map +1 -1
  8. package/dist/core/resources/native-profile.d.ts +56 -0
  9. package/dist/core/resources/native-profile.js +368 -6
  10. package/dist/core/resources/native-profile.js.map +1 -1
  11. package/dist/core/run/budget.d.ts +22 -8
  12. package/dist/core/run/budget.js +80 -12
  13. package/dist/core/run/budget.js.map +1 -1
  14. package/dist/core/run/model-catalog.js +8 -1
  15. package/dist/core/run/model-catalog.js.map +1 -1
  16. package/dist/core/run/sandboxed-engine.js +3 -3
  17. package/dist/core/run/sandboxed-engine.js.map +1 -1
  18. package/dist/core/universe/builtins/preparation/manifest.json +1 -1
  19. package/dist/core/universe/builtins/preparation/preparation-bridge.mjs +69 -10
  20. package/dist/core/universe/builtins/preparation/preparation-verification-fixtures.mjs +68 -9
  21. package/dist/core/verse/adapters/claude.d.ts +49 -2
  22. package/dist/core/verse/adapters/claude.js +317 -56
  23. package/dist/core/verse/adapters/claude.js.map +1 -1
  24. package/dist/core/verse/adapters/codex.d.ts +81 -5
  25. package/dist/core/verse/adapters/codex.js +409 -47
  26. package/dist/core/verse/adapters/codex.js.map +1 -1
  27. package/dist/core/verse/adapters/grok.d.ts +13 -1
  28. package/dist/core/verse/adapters/grok.js +28 -2
  29. package/dist/core/verse/adapters/grok.js.map +1 -1
  30. package/dist/core/verse/adapters/index.d.ts +32 -0
  31. package/dist/core/verse/adapters/index.js +2 -0
  32. package/dist/core/verse/adapters/index.js.map +1 -1
  33. package/dist/core/verse/codex-rollout.d.ts +284 -0
  34. package/dist/core/verse/codex-rollout.js +786 -0
  35. package/dist/core/verse/codex-rollout.js.map +1 -0
  36. package/dist/core/verse/context-fit.d.ts +63 -0
  37. package/dist/core/verse/context-fit.js +355 -0
  38. package/dist/core/verse/context-fit.js.map +1 -0
  39. package/dist/core/verse/context-math.d.ts +215 -0
  40. package/dist/core/verse/context-math.js +365 -0
  41. package/dist/core/verse/context-math.js.map +1 -0
  42. package/dist/core/verse/local-models.d.ts +137 -1
  43. package/dist/core/verse/local-models.js +209 -5
  44. package/dist/core/verse/local-models.js.map +1 -1
  45. package/dist/core/verse/model-windows.d.ts +217 -0
  46. package/dist/core/verse/model-windows.js +616 -0
  47. package/dist/core/verse/model-windows.js.map +1 -0
  48. package/dist/core/verse/preferences.d.ts +123 -0
  49. package/dist/core/verse/preferences.js +403 -0
  50. package/dist/core/verse/preferences.js.map +1 -0
  51. package/dist/core/verse/project-memory.d.ts +112 -0
  52. package/dist/core/verse/project-memory.js +295 -0
  53. package/dist/core/verse/project-memory.js.map +1 -0
  54. package/dist/core/verse/seats.d.ts +106 -4
  55. package/dist/core/verse/seats.js +291 -89
  56. package/dist/core/verse/seats.js.map +1 -1
  57. package/dist/core/verse/session-engine.d.ts +117 -2
  58. package/dist/core/verse/session-engine.js +763 -65
  59. package/dist/core/verse/session-engine.js.map +1 -1
  60. package/dist/core/verse/session-handoff.d.ts +94 -0
  61. package/dist/core/verse/session-handoff.js +551 -0
  62. package/dist/core/verse/session-handoff.js.map +1 -0
  63. package/dist/core/verse/session-search.d.ts +63 -0
  64. package/dist/core/verse/session-search.js +213 -0
  65. package/dist/core/verse/session-search.js.map +1 -0
  66. package/dist/core/verse/session-store.d.ts +16 -2
  67. package/dist/core/verse/session-store.js +80 -6
  68. package/dist/core/verse/session-store.js.map +1 -1
  69. package/dist/core/verse/types.d.ts +333 -1
  70. package/dist/core/verse/types.js +54 -2
  71. package/dist/core/verse/types.js.map +1 -1
  72. package/dist/core/verse/verse-api.d.ts +40 -5
  73. package/dist/core/verse/verse-api.js +724 -25
  74. package/dist/core/verse/verse-api.js.map +1 -1
  75. package/dist/core/web/api.d.ts +14 -4
  76. package/dist/core/web/api.js +26 -11
  77. package/dist/core/web/api.js.map +1 -1
  78. package/dist/core/web/public/next/assets/{App-vtaHB9mp.js → App-XURmiAAp.js} +5 -5
  79. package/dist/core/web/public/next/assets/{App-vtaHB9mp.js.map → App-XURmiAAp.js.map} +1 -1
  80. package/dist/core/web/public/next/assets/{ApprovalsSection-B-bFJ0I9.js → ApprovalsSection-BriUzoZc.js} +2 -2
  81. package/dist/core/web/public/next/assets/{ApprovalsSection-B-bFJ0I9.js.map → ApprovalsSection-BriUzoZc.js.map} +1 -1
  82. package/dist/core/web/public/next/assets/AutonomySection-C4GBsQVc.js +2 -0
  83. package/dist/core/web/public/next/assets/{AutonomySection-DoqmsCjw.js.map → AutonomySection-C4GBsQVc.js.map} +1 -1
  84. package/dist/core/web/public/next/assets/ChatSection-BpOBOUvg.css +1 -0
  85. package/dist/core/web/public/next/assets/ChatSection-hlgPBRco.js +84 -0
  86. package/dist/core/web/public/next/assets/ChatSection-hlgPBRco.js.map +1 -0
  87. package/dist/core/web/public/next/assets/ConfirmDialog-BdQYI-Ng.js +2 -0
  88. package/dist/core/web/public/next/assets/ConfirmDialog-BdQYI-Ng.js.map +1 -0
  89. package/dist/core/web/public/next/assets/{Dialog-CPM9JxBc.js → Dialog-BxdIfmFH.js} +2 -2
  90. package/dist/core/web/public/next/assets/{Dialog-CPM9JxBc.js.map → Dialog-BxdIfmFH.js.map} +1 -1
  91. package/dist/core/web/public/next/assets/{DiffViewer-B84qVef9.js → DiffViewer-DmeRWuc4.js} +2 -2
  92. package/dist/core/web/public/next/assets/{DiffViewer-B84qVef9.js.map → DiffViewer-DmeRWuc4.js.map} +1 -1
  93. package/dist/core/web/public/next/assets/{McpSection-CEUpV_5X.js → McpSection-q34T7Tho.js} +2 -2
  94. package/dist/core/web/public/next/assets/{McpSection-CEUpV_5X.js.map → McpSection-q34T7Tho.js.map} +1 -1
  95. package/dist/core/web/public/next/assets/{MutationTokenDialog-DA8t97XT.js → MutationTokenDialog-CFAkwOEt.js} +2 -2
  96. package/dist/core/web/public/next/assets/{MutationTokenDialog-DA8t97XT.js.map → MutationTokenDialog-CFAkwOEt.js.map} +1 -1
  97. package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-Bq7Vyud0.js → ResourcePoolConsoleApp-DHc8GFOY.js} +4 -4
  98. package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-Bq7Vyud0.js.map → ResourcePoolConsoleApp-DHc8GFOY.js.map} +1 -1
  99. package/dist/core/web/public/next/assets/{RouteErrorBoundary-Badio_uO.js → RouteErrorBoundary-ZL9F4qUn.js} +2 -2
  100. package/dist/core/web/public/next/assets/{RouteErrorBoundary-Badio_uO.js.map → RouteErrorBoundary-ZL9F4qUn.js.map} +1 -1
  101. package/dist/core/web/public/next/assets/{Meter-CLcr-4wS.css → Segmented-8cf0Fypn.css} +1 -1
  102. package/dist/core/web/public/next/assets/{Meter--h3MmZS-.js → Segmented-DbRjVye3.js} +2 -2
  103. package/dist/core/web/public/next/assets/Segmented-DbRjVye3.js.map +1 -0
  104. package/dist/core/web/public/next/assets/SettingsSection-nJGGGYqv.js +2 -0
  105. package/dist/core/web/public/next/assets/{SettingsSection-BwrpQlpP.js.map → SettingsSection-nJGGGYqv.js.map} +1 -1
  106. package/dist/core/web/public/next/assets/UniverseConsoleApp-6GJydgo4.js +2 -0
  107. package/dist/core/web/public/next/assets/{UniverseConsoleApp-_FSohAdN.js.map → UniverseConsoleApp-6GJydgo4.js.map} +1 -1
  108. package/dist/core/web/public/next/assets/{UniverseView-DqcXbed4.js → UniverseView-DLDtNYL8.js} +2 -2
  109. package/dist/core/web/public/next/assets/{UniverseView-DqcXbed4.js.map → UniverseView-DLDtNYL8.js.map} +1 -1
  110. package/dist/core/web/public/next/assets/UsageSection-Cf9d9iFH.js +2 -0
  111. package/dist/core/web/public/next/assets/UsageSection-Cf9d9iFH.js.map +1 -0
  112. package/dist/core/web/public/next/assets/VerseConsoleApp-DsdKQoTH.css +1 -0
  113. package/dist/core/web/public/next/assets/VerseConsoleApp-y0uOHixd.js +3 -0
  114. package/dist/core/web/public/next/assets/VerseConsoleApp-y0uOHixd.js.map +1 -0
  115. package/dist/core/web/public/next/assets/{charts-CTZJ-JrH.js → charts-CVooqJWN.js} +2 -2
  116. package/dist/core/web/public/next/assets/{charts-CTZJ-JrH.js.map → charts-CVooqJWN.js.map} +1 -1
  117. package/dist/core/web/public/next/assets/context-model-Bqv8DVuH.js +2 -0
  118. package/dist/core/web/public/next/assets/context-model-Bqv8DVuH.js.map +1 -0
  119. package/dist/core/web/public/next/assets/global-eaY9GyX3.js +2 -0
  120. package/dist/core/web/public/next/assets/global-eaY9GyX3.js.map +1 -0
  121. package/dist/core/web/public/next/assets/{icons-D9vlf85u.js → icons-vex8rclC.js} +2 -2
  122. package/dist/core/web/public/next/assets/{icons-D9vlf85u.js.map → icons-vex8rclC.js.map} +1 -1
  123. package/dist/core/web/public/next/assets/{index-CZamKRUg.js → index-D2Eik-zt.js} +3 -3
  124. package/dist/core/web/public/next/assets/{index-CZamKRUg.js.map → index-D2Eik-zt.js.map} +1 -1
  125. package/dist/core/web/public/next/assets/{queries-B8eBnFox.js → queries-bAYU0VNy.js} +2 -2
  126. package/dist/core/web/public/next/assets/{queries-B8eBnFox.js.map → queries-bAYU0VNy.js.map} +1 -1
  127. package/dist/core/web/public/next/assets/use-guarded-action-C7QrP7Yp.js +2 -0
  128. package/dist/core/web/public/next/assets/{use-guarded-action-bzrVVAYq.js.map → use-guarded-action-C7QrP7Yp.js.map} +1 -1
  129. package/dist/core/web/public/next/assets/verse-model-TfDk9cky.js +2 -0
  130. package/dist/core/web/public/next/assets/verse-model-TfDk9cky.js.map +1 -0
  131. package/dist/core/web/public/next/assets/{workspace-model-v0a1gl-C.js → workspace-model-CHol5RVp.js} +2 -2
  132. package/dist/core/web/public/next/assets/{workspace-model-v0a1gl-C.js.map → workspace-model-CHol5RVp.js.map} +1 -1
  133. package/dist/core/web/public/next/index.html +1 -1
  134. package/dist/release-dependency-inventory.json +1 -1
  135. package/docs/README.md +1 -0
  136. package/package.json +1 -1
  137. package/dist/core/web/public/next/assets/AutonomySection-DoqmsCjw.js +0 -2
  138. package/dist/core/web/public/next/assets/ChatSection-BfnLe5dT.css +0 -1
  139. package/dist/core/web/public/next/assets/ChatSection-vNPIJffd.js +0 -80
  140. package/dist/core/web/public/next/assets/ChatSection-vNPIJffd.js.map +0 -1
  141. package/dist/core/web/public/next/assets/ConfirmDialog-Dd2APQhB.js +0 -2
  142. package/dist/core/web/public/next/assets/ConfirmDialog-Dd2APQhB.js.map +0 -1
  143. package/dist/core/web/public/next/assets/Meter--h3MmZS-.js.map +0 -1
  144. package/dist/core/web/public/next/assets/SettingsSection-BwrpQlpP.js +0 -2
  145. package/dist/core/web/public/next/assets/UniverseConsoleApp-_FSohAdN.js +0 -2
  146. package/dist/core/web/public/next/assets/UsageSection-Bq6Daaby.js +0 -2
  147. package/dist/core/web/public/next/assets/UsageSection-Bq6Daaby.js.map +0 -1
  148. package/dist/core/web/public/next/assets/VerseConsoleApp-CMkCizvL.css +0 -1
  149. package/dist/core/web/public/next/assets/VerseConsoleApp-CsIQv9id.js +0 -3
  150. package/dist/core/web/public/next/assets/VerseConsoleApp-CsIQv9id.js.map +0 -1
  151. package/dist/core/web/public/next/assets/global-D6bTxIRn.js +0 -2
  152. package/dist/core/web/public/next/assets/global-D6bTxIRn.js.map +0 -1
  153. package/dist/core/web/public/next/assets/use-guarded-action-bzrVVAYq.js +0 -2
  154. package/dist/core/web/public/next/assets/verse-model-CvtK6vP6.js +0 -2
  155. package/dist/core/web/public/next/assets/verse-model-CvtK6vP6.js.map +0 -1
@@ -0,0 +1,786 @@
1
+ /**
2
+ * Codex rollout reader — the ONLY place Verse learns a codex turn's exact
3
+ * context occupancy, its window, its compactions and its true per-turn usage.
4
+ *
5
+ * WHY THIS EXISTS. `codex exec --json` never prints per-call usage or the
6
+ * window: its `turn.completed.usage` is a SUM over every model call in the
7
+ * turn (a 20-call turn reports ~20× the live prompt), and codex seeds that sum
8
+ * from the rollout when a thread is resumed, so on `exec resume` it is the
9
+ * THREAD's running total rather than the turn's. The CLI's own session file
10
+ * does carry the truth, one `token_count` per model call:
11
+ *
12
+ * <CODEX_HOME>/sessions/YYYY/MM/DD/rollout-<local ts>-<thread id>.jsonl
13
+ * {"timestamp":…,"type":"event_msg","payload":{"type":"token_count","info":{
14
+ * "total_token_usage":{…cumulative…},"last_token_usage":{…this call…},
15
+ * "model_context_window":258400}}}
16
+ * {"timestamp":…,"type":"event_msg","payload":{"type":"task_started","model_context_window":258400}}
17
+ * {"timestamp":…,"type":"compacted","payload":{…replacement history, often MEGABYTES…}}
18
+ * {"timestamp":…,"type":"token_usage_record","payload":{"thread_id":…,"turn_token_usage":{…}}} (0.149+)
19
+ *
20
+ * (Shapes verified against 4,001 rollouts on this machine, CLI 0.136 – 0.155.)
21
+ *
22
+ * WHAT "OCCUPANCY" MEANS HERE. `last_token_usage.total_tokens` — the last
23
+ * call's prompt plus its reply. That is the quantity codex itself compares to
24
+ * its auto-compaction limit (no uncompacted call ever exceeded 244,800 = 90% of
25
+ * the raw 272k window, across 62,104 calls), and it is what the NEXT call's
26
+ * prompt starts from, so the meter and the compaction tick share one unit.
27
+ * Right after a compaction codex writes a `token_count` whose cumulative total
28
+ * is unchanged but whose `last_token_usage` is the post-compaction size — so a
29
+ * reading is taken from EVERY token_count, while usage is only summed for
30
+ * records whose cumulative total moved (a real model call).
31
+ *
32
+ * BOUNDS. A `compacted` line is routinely 2–17 MB (it embeds the replacement
33
+ * history), so nothing here ever holds a whole line in memory: lines are
34
+ * scanned in chunks and only their first CODEX_ROLLOUT_LINE_HEAD_BYTES are
35
+ * kept. The small records (token_count ≤ 1 KB) are parsed whole; an oversized
36
+ * line is classified from its head alone. First reads look at most
37
+ * CODEX_ROLLOUT_TAIL_BYTES back from EOF (doubling to a hard cap only when the
38
+ * turn's start is not in that window); incremental reads scan at most
39
+ * CODEX_ROLLOUT_MAX_SCAN_BYTES. A missing, pruned, rewritten or unreadable file
40
+ * yields null / "no reading" — nothing here throws.
41
+ *
42
+ * PRIVACY. Paths computed here name native-profile directories (account
43
+ * identity). They are used to read files and are never returned in events,
44
+ * logged, or persisted.
45
+ */
46
+ import { closeSync, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs';
47
+ import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
48
+ // ---------------------------------------------------------------------------
49
+ // Bounds
50
+ // ---------------------------------------------------------------------------
51
+ /** First read of a rollout: at most this much from EOF (spec: ≤ 2 MB). */
52
+ export const CODEX_ROLLOUT_TAIL_BYTES = 2 * 1024 * 1024;
53
+ /**
54
+ * When the turn's first record is not inside the first tail window (a turn
55
+ * that wrote megabytes before the first poll — i.e. an early compaction), the
56
+ * window doubles up to this cap. Past it the turn is accounted as partial.
57
+ */
58
+ export const CODEX_ROLLOUT_MAX_TAIL_BYTES = 16 * 1024 * 1024;
59
+ /** One incremental read scans at most this much; beyond it the reader jumps to the tail and records a gap. */
60
+ export const CODEX_ROLLOUT_MAX_SCAN_BYTES = 32 * 1024 * 1024;
61
+ /** Bytes of each line retained for classification. Every record we parse whole is far smaller. */
62
+ export const CODEX_ROLLOUT_LINE_HEAD_BYTES = 64 * 1024;
63
+ const READ_CHUNK_BYTES = 256 * 1024;
64
+ /** Day directories a fallback thread search may open (≈ two years of daily use). */
65
+ const MAX_DAY_DIRS = 750;
66
+ /** profile.json is a few hundred bytes; anything bigger is not a profile. */
67
+ const MAX_PROFILE_BYTES = 64 * 1024;
68
+ const MAX_CACHE_ENTRIES = 128;
69
+ const NEWLINE = 0x0a;
70
+ function isObject(value) {
71
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
72
+ }
73
+ function count(value) {
74
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
75
+ }
76
+ function positiveIntOrNull(value) {
77
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
78
+ }
79
+ export const ZERO_CODEX_TOTALS = Object.freeze({
80
+ inputTokens: 0,
81
+ cachedInputTokens: 0,
82
+ cacheWriteInputTokens: 0,
83
+ outputTokens: 0,
84
+ reasoningOutputTokens: 0,
85
+ totalTokens: 0,
86
+ });
87
+ /** A codex usage block → totals, or null when it is not one. `total_tokens` falls back to input + output. */
88
+ export function codexTotals(value) {
89
+ if (!isObject(value))
90
+ return null;
91
+ const hasAny = ['input_tokens', 'output_tokens', 'total_tokens'].some((key) => typeof value[key] === 'number');
92
+ if (!hasAny)
93
+ return null;
94
+ const inputTokens = count(value['input_tokens']);
95
+ const outputTokens = count(value['output_tokens']);
96
+ const reported = count(value['total_tokens']);
97
+ return {
98
+ inputTokens,
99
+ cachedInputTokens: count(value['cached_input_tokens']),
100
+ cacheWriteInputTokens: count(value['cache_write_input_tokens']),
101
+ outputTokens,
102
+ reasoningOutputTokens: count(value['reasoning_output_tokens']),
103
+ totalTokens: reported > 0 ? reported : inputTokens + outputTokens,
104
+ };
105
+ }
106
+ export function addCodexTotals(a, b) {
107
+ return {
108
+ inputTokens: a.inputTokens + b.inputTokens,
109
+ cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens,
110
+ cacheWriteInputTokens: a.cacheWriteInputTokens + b.cacheWriteInputTokens,
111
+ outputTokens: a.outputTokens + b.outputTokens,
112
+ reasoningOutputTokens: a.reasoningOutputTokens + b.reasoningOutputTokens,
113
+ totalTokens: a.totalTokens + b.totalTokens,
114
+ };
115
+ }
116
+ /** a − b, each bucket clamped at 0 (a counter that went backwards is a reset, never negative usage). */
117
+ export function subtractCodexTotals(a, b) {
118
+ return {
119
+ inputTokens: Math.max(0, a.inputTokens - b.inputTokens),
120
+ cachedInputTokens: Math.max(0, a.cachedInputTokens - b.cachedInputTokens),
121
+ cacheWriteInputTokens: Math.max(0, a.cacheWriteInputTokens - b.cacheWriteInputTokens),
122
+ outputTokens: Math.max(0, a.outputTokens - b.outputTokens),
123
+ reasoningOutputTokens: Math.max(0, a.reasoningOutputTokens - b.reasoningOutputTokens),
124
+ totalTokens: Math.max(0, a.totalTokens - b.totalTokens),
125
+ };
126
+ }
127
+ /**
128
+ * The live occupancy one `token_count` describes: its last call's
129
+ * `total_tokens` (prompt + reply), the figure codex compacts against.
130
+ */
131
+ export function codexOccupancy(last) {
132
+ return last.totalTokens > 0 ? last.totalTokens : last.inputTokens + last.outputTokens;
133
+ }
134
+ function timestampMs(value) {
135
+ if (typeof value !== 'string' || value.length > 64)
136
+ return null;
137
+ const ms = Date.parse(value);
138
+ return Number.isFinite(ms) ? ms : null;
139
+ }
140
+ /** Classify one COMPLETE, small line. Unknown or malformed lines → null. */
141
+ function classifyParsed(obj, offset) {
142
+ const at = timestampMs(obj['timestamp']);
143
+ const type = obj['type'];
144
+ const payload = isObject(obj['payload']) ? obj['payload'] : null;
145
+ if (type === 'compacted')
146
+ return { kind: 'compacted', offset, at };
147
+ if (!payload)
148
+ return null;
149
+ if (type === 'event_msg') {
150
+ const inner = payload['type'];
151
+ if (inner === 'token_count') {
152
+ // `info` is null on a rate-limit-only refresh: no reading in it.
153
+ const info = isObject(payload['info']) ? payload['info'] : null;
154
+ if (!info)
155
+ return null;
156
+ const total = codexTotals(info['total_token_usage']);
157
+ const last = codexTotals(info['last_token_usage']);
158
+ if (!total || !last)
159
+ return null;
160
+ return { kind: 'token-count', offset, at, total, last, modelContextWindow: positiveIntOrNull(info['model_context_window']) };
161
+ }
162
+ if (inner === 'task_started') {
163
+ return { kind: 'task-started', offset, at, modelContextWindow: positiveIntOrNull(payload['model_context_window']) };
164
+ }
165
+ return null;
166
+ }
167
+ if (type === 'token_usage_record') {
168
+ const threadId = typeof payload['thread_id'] === 'string' ? payload['thread_id'] : null;
169
+ return { kind: 'token-usage-record', offset, at, threadId, turn: codexTotals(payload['turn_token_usage']) };
170
+ }
171
+ return null;
172
+ }
173
+ /**
174
+ * An oversized line cannot be parsed, but every rollout line starts
175
+ * `{"timestamp":"…","type":"…",…` — so a `compacted` record (the only big line
176
+ * this reader cares about) is recognisable from its first bytes. The type is
177
+ * only trusted when it appears BEFORE `"payload"`, i.e. at the top level.
178
+ */
179
+ function classifyHead(head, offset) {
180
+ const payloadAt = head.indexOf('"payload"');
181
+ const prefix = payloadAt === -1 ? head.slice(0, 512) : head.slice(0, payloadAt);
182
+ if (!/"type"\s*:\s*"compacted"/.test(prefix))
183
+ return null;
184
+ const ts = /"timestamp"\s*:\s*"([^"]{1,64})"/.exec(prefix);
185
+ return { kind: 'compacted', offset, at: ts ? timestampMs(ts[1]) : null };
186
+ }
187
+ function classifyLine(head, offset, length) {
188
+ if (length <= head.length) {
189
+ const text = head.toString('utf8').trim();
190
+ if (!text.startsWith('{'))
191
+ return null;
192
+ let parsed;
193
+ try {
194
+ parsed = JSON.parse(text);
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ return isObject(parsed) ? classifyParsed(parsed, offset) : null;
200
+ }
201
+ return classifyHead(head.toString('utf8'), offset);
202
+ }
203
+ /**
204
+ * Scan complete lines in [from, to), keeping only each line's head. When
205
+ * `skipFirstPartial`, bytes up to and including the first newline belong to a
206
+ * line that started before `from` and are discarded.
207
+ */
208
+ function scanLines(fd, from, to, budget, skipFirstPartial, onLine) {
209
+ const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
210
+ const headParts = [];
211
+ let headBytes = 0;
212
+ let lineStart = from;
213
+ let skipping = skipFirstPartial;
214
+ let pos = from;
215
+ let scanned = 0;
216
+ const keep = (slice) => {
217
+ if (skipping || headBytes >= CODEX_ROLLOUT_LINE_HEAD_BYTES || slice.length === 0)
218
+ return;
219
+ const take = slice.subarray(0, CODEX_ROLLOUT_LINE_HEAD_BYTES - headBytes);
220
+ headParts.push(Buffer.from(take));
221
+ headBytes += take.length;
222
+ };
223
+ while (pos < to) {
224
+ if (scanned >= budget)
225
+ return { end: skipping ? pos : lineStart, pos, exhausted: true };
226
+ const want = Math.min(READ_CHUNK_BYTES, to - pos);
227
+ let n;
228
+ try {
229
+ n = readSync(fd, chunk, 0, want, pos);
230
+ }
231
+ catch {
232
+ break;
233
+ }
234
+ if (n <= 0)
235
+ break;
236
+ let cursor = 0;
237
+ while (cursor < n) {
238
+ const nl = chunk.indexOf(NEWLINE, cursor);
239
+ if (nl === -1 || nl >= n) {
240
+ keep(chunk.subarray(cursor, n));
241
+ cursor = n;
242
+ break;
243
+ }
244
+ keep(chunk.subarray(cursor, nl));
245
+ const lineEnd = pos + nl;
246
+ if (!skipping) {
247
+ const length = lineEnd - lineStart;
248
+ if (length > 0)
249
+ onLine(Buffer.concat(headParts, headBytes), lineStart, length);
250
+ }
251
+ skipping = false;
252
+ headParts.length = 0;
253
+ headBytes = 0;
254
+ lineStart = lineEnd + 1;
255
+ cursor = nl + 1;
256
+ }
257
+ pos += n;
258
+ scanned += n;
259
+ }
260
+ return { end: skipping ? pos : lineStart, pos, exhausted: false };
261
+ }
262
+ function byteBefore(fd, offset) {
263
+ if (offset <= 0)
264
+ return null;
265
+ const one = Buffer.allocUnsafe(1);
266
+ try {
267
+ return readSync(fd, one, 0, 1, offset - 1) === 1 ? one[0] : null;
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ }
273
+ /** Open a rollout for reading: a regular, non-symlinked file, or null. */
274
+ function openRollout(file) {
275
+ try {
276
+ const link = lstatSync(file);
277
+ if (!link.isFile() || link.isSymbolicLink())
278
+ return null;
279
+ }
280
+ catch {
281
+ return null;
282
+ }
283
+ let fd;
284
+ try {
285
+ fd = openSync(file, 'r');
286
+ }
287
+ catch {
288
+ return null;
289
+ }
290
+ try {
291
+ const stat = fstatSync(fd);
292
+ if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size < 0) {
293
+ closeSync(fd);
294
+ return null;
295
+ }
296
+ return { fd, size: stat.size, ino: stat.ino };
297
+ }
298
+ catch {
299
+ try {
300
+ closeSync(fd);
301
+ }
302
+ catch { /* already closed */ }
303
+ return null;
304
+ }
305
+ }
306
+ function readRange(fd, size, from, budget, midLine) {
307
+ const start = Math.max(0, Math.min(from, size));
308
+ const records = [];
309
+ // A start that is not a line boundary cuts the line it lands in: drop it.
310
+ const cut = midLine || (start > 0 && byteBefore(fd, start) !== NEWLINE);
311
+ const scan = scanLines(fd, start, size, budget, cut, (head, offset, length) => {
312
+ const record = classifyLine(head, offset, length);
313
+ if (record)
314
+ records.push(record);
315
+ });
316
+ return { size, start, end: scan.end, records, skipped: scan.exhausted };
317
+ }
318
+ /**
319
+ * Complete records from the last `maxBytes` of a rollout (never more than
320
+ * CODEX_ROLLOUT_MAX_TAIL_BYTES). A line cut by the window start is dropped.
321
+ * Null when the file is missing or unreadable.
322
+ */
323
+ export function readCodexRolloutTail(file, maxBytes = CODEX_ROLLOUT_TAIL_BYTES) {
324
+ const opened = openRollout(file);
325
+ if (!opened)
326
+ return null;
327
+ try {
328
+ const window = Math.max(1, Math.min(Math.floor(maxBytes), CODEX_ROLLOUT_MAX_TAIL_BYTES));
329
+ return readRange(opened.fd, opened.size, Math.max(0, opened.size - window), window, false);
330
+ }
331
+ finally {
332
+ try {
333
+ closeSync(opened.fd);
334
+ }
335
+ catch { /* already closed */ }
336
+ }
337
+ }
338
+ /**
339
+ * Complete records from byte `from` to EOF, scanning at most `maxScanBytes`.
340
+ * Null when the file is missing, unreadable, or SHORTER than `from` (rewritten
341
+ * or truncated — the caller must start over rather than trust its offset).
342
+ */
343
+ export function readCodexRolloutFrom(file, from, maxScanBytes = CODEX_ROLLOUT_MAX_SCAN_BYTES) {
344
+ const opened = openRollout(file);
345
+ if (!opened)
346
+ return null;
347
+ try {
348
+ if (!Number.isSafeInteger(from) || from < 0 || from > opened.size)
349
+ return null;
350
+ return readRange(opened.fd, opened.size, from, Math.max(1, maxScanBytes), false);
351
+ }
352
+ finally {
353
+ try {
354
+ closeSync(opened.fd);
355
+ }
356
+ catch { /* already closed */ }
357
+ }
358
+ }
359
+ /**
360
+ * One-shot extraction from a rollout's tail (or from `afterOffset`, when
361
+ * given). Missing or pruned file → null; never throws.
362
+ */
363
+ export function summarizeCodexRollout(file, opts = {}) {
364
+ const read = opts.afterOffset !== undefined
365
+ ? readCodexRolloutFrom(file, opts.afterOffset, opts.maxBytes ?? CODEX_ROLLOUT_MAX_SCAN_BYTES)
366
+ : readCodexRolloutTail(file, opts.maxBytes ?? CODEX_ROLLOUT_TAIL_BYTES);
367
+ if (!read)
368
+ return null;
369
+ let lastTokenCount = null;
370
+ let taskStartedWindow = null;
371
+ let compactions = 0;
372
+ const after = opts.afterOffset ?? 0;
373
+ for (const record of read.records) {
374
+ if (record.kind === 'token-count') {
375
+ lastTokenCount = {
376
+ lastTotalTokens: codexOccupancy(record.last),
377
+ lastInputTokens: record.last.inputTokens,
378
+ modelContextWindow: record.modelContextWindow,
379
+ total: record.total,
380
+ };
381
+ }
382
+ else if (record.kind === 'task-started') {
383
+ taskStartedWindow = record.modelContextWindow ?? taskStartedWindow;
384
+ }
385
+ else if (record.kind === 'compacted' && record.offset >= after) {
386
+ compactions += 1;
387
+ }
388
+ }
389
+ return { lastTokenCount, taskStartedWindow, compactions, size: read.size, end: read.end };
390
+ }
391
+ // ---------------------------------------------------------------------------
392
+ // Locating a seat's rollouts
393
+ // ---------------------------------------------------------------------------
394
+ function boundedSet(map, key, value) {
395
+ if (map.size >= MAX_CACHE_ENTRIES && !map.has(key)) {
396
+ const oldest = map.keys().next().value;
397
+ if (oldest !== undefined)
398
+ map.delete(oldest);
399
+ }
400
+ map.set(key, value);
401
+ }
402
+ const nativeStateCache = new Map();
403
+ function isCleanAbsolute(value) {
404
+ return typeof value === 'string' && value.length > 1 && value.length <= 4096 && isAbsolute(value) &&
405
+ normalize(value) === value && !value.includes('\0');
406
+ }
407
+ /**
408
+ * The CODEX_HOME a seat's launcher pins, derived from the launcher argv the
409
+ * session was created with — the same `<profile>/launcher.mjs` ⇒
410
+ * `<profile>/native-state` relation `accounts.ts` (codexSessionRoots) and
411
+ * `mcp-seat-view.ts` use, preferring the profile's own `profile.json`
412
+ * `nativeStatePath` when it is readable.
413
+ *
414
+ * Null for a launcher-less seat: Verse never reads the unpinned global
415
+ * `~/.codex`, whose rollouts belong to other tools and other accounts.
416
+ */
417
+ export function codexNativeStatePath(launcher) {
418
+ if (!Array.isArray(launcher))
419
+ return null;
420
+ const launcherPath = [...launcher].reverse().find((part) => isCleanAbsolute(part) && basename(part) === 'launcher.mjs');
421
+ if (!launcherPath)
422
+ return null;
423
+ const cached = nativeStateCache.get(launcherPath);
424
+ if (cached)
425
+ return cached;
426
+ const profileDir = dirname(launcherPath);
427
+ let resolved = join(profileDir, 'native-state');
428
+ try {
429
+ const manifestPath = join(profileDir, 'profile.json');
430
+ const stat = lstatSync(manifestPath);
431
+ if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= MAX_PROFILE_BYTES) {
432
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
433
+ if (isObject(manifest)) {
434
+ const provider = manifest['provider'];
435
+ if (provider !== undefined && provider !== 'codex')
436
+ return null;
437
+ if (isCleanAbsolute(manifest['nativeStatePath']))
438
+ resolved = manifest['nativeStatePath'];
439
+ }
440
+ }
441
+ }
442
+ catch {
443
+ // No manifest (or an unreadable one): the launcher's sibling is the pinned home.
444
+ }
445
+ boundedSet(nativeStateCache, launcherPath, resolved);
446
+ return resolved;
447
+ }
448
+ /**
449
+ * Codex thread ids are UUIDs (v7 on every CLI this was verified against). The
450
+ * id is interpolated into a filename match, so anything else is refused.
451
+ */
452
+ const THREAD_ID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
453
+ export function isCodexThreadId(value) {
454
+ return typeof value === 'string' && THREAD_ID_RE.test(value);
455
+ }
456
+ /** Creation time embedded in a UUIDv7 (first 48 bits, epoch ms), else null. */
457
+ export function uuidV7Millis(id) {
458
+ if (!isCodexThreadId(id) || id[14] !== '7')
459
+ return null;
460
+ const ms = Number.parseInt(id.slice(0, 8) + id.slice(9, 13), 16);
461
+ // Sanity window: 2020 … 2100. Anything outside is not a timestamp.
462
+ return Number.isSafeInteger(ms) && ms > 1_577_836_800_000 && ms < 4_102_444_800_000 ? ms : null;
463
+ }
464
+ function pad2(n) {
465
+ return n < 10 ? `0${n}` : String(n);
466
+ }
467
+ /** `YYYY/MM/DD` in LOCAL time — codex names both the directory and the file from local time. */
468
+ function localDayPath(ms) {
469
+ const d = new Date(ms);
470
+ return [String(d.getFullYear()), pad2(d.getMonth() + 1), pad2(d.getDate())];
471
+ }
472
+ function safeReaddir(dir) {
473
+ try {
474
+ return readdirSync(dir);
475
+ }
476
+ catch {
477
+ return [];
478
+ }
479
+ }
480
+ function rolloutIn(dir, suffix) {
481
+ let best = null;
482
+ for (const name of safeReaddir(dir)) {
483
+ if (!name.startsWith('rollout-') || !name.endsWith(suffix))
484
+ continue;
485
+ const path = join(dir, name);
486
+ try {
487
+ const stat = lstatSync(path);
488
+ if (!stat.isFile() || stat.isSymbolicLink())
489
+ continue;
490
+ if (!best || stat.mtimeMs > best.mtime)
491
+ best = { path, mtime: stat.mtimeMs };
492
+ }
493
+ catch {
494
+ // Pruned between readdir and lstat.
495
+ }
496
+ }
497
+ return best?.path ?? null;
498
+ }
499
+ const rolloutCache = new Map();
500
+ function stillRollout(path) {
501
+ try {
502
+ const stat = lstatSync(path);
503
+ return stat.isFile() && !stat.isSymbolicLink();
504
+ }
505
+ catch {
506
+ return false;
507
+ }
508
+ }
509
+ /**
510
+ * Path of the rollout for `threadId` under `<nativeStatePath>/sessions`, or
511
+ * null when there is none (never run, pruned, `--ephemeral`, unreadable).
512
+ *
513
+ * Cheap path first: a UUIDv7 id carries its creation instant, so the file is
514
+ * in that LOCAL day's directory (±1 day for a timezone change or a thread
515
+ * created just before midnight). Otherwise a bounded newest-first descent.
516
+ * If several files match (never observed), the most recently written wins.
517
+ */
518
+ export function locateCodexRollout(nativeStatePath, threadId, opts = {}) {
519
+ if (!isCleanAbsolute(nativeStatePath) || !isCodexThreadId(threadId))
520
+ return null;
521
+ const sessionsRoot = join(nativeStatePath, 'sessions');
522
+ const key = `${sessionsRoot}\0${threadId}`;
523
+ const cached = rolloutCache.get(key);
524
+ if (cached && stillRollout(cached))
525
+ return cached;
526
+ if (cached)
527
+ rolloutCache.delete(key);
528
+ const suffix = `-${threadId}.jsonl`;
529
+ const remember = (path) => {
530
+ if (path)
531
+ boundedSet(rolloutCache, key, path);
532
+ return path;
533
+ };
534
+ const created = uuidV7Millis(threadId);
535
+ if (created !== null) {
536
+ const day = 24 * 60 * 60 * 1000;
537
+ const tried = new Set();
538
+ for (const ms of [created, created - day, created + day]) {
539
+ const dir = join(sessionsRoot, ...localDayPath(ms));
540
+ if (tried.has(dir))
541
+ continue;
542
+ tried.add(dir);
543
+ const found = rolloutIn(dir, suffix);
544
+ if (found)
545
+ return remember(found);
546
+ }
547
+ }
548
+ if (opts.fullScan === false)
549
+ return null;
550
+ let dirsOpened = 0;
551
+ const years = safeReaddir(sessionsRoot).filter((n) => /^\d{4}$/.test(n)).sort().reverse();
552
+ for (const year of years) {
553
+ const months = safeReaddir(join(sessionsRoot, year)).filter((n) => /^\d{2}$/.test(n)).sort().reverse();
554
+ for (const month of months) {
555
+ const days = safeReaddir(join(sessionsRoot, year, month)).filter((n) => /^\d{2}$/.test(n)).sort().reverse();
556
+ for (const dayName of days) {
557
+ if (++dirsOpened > MAX_DAY_DIRS)
558
+ return null;
559
+ const found = rolloutIn(join(sessionsRoot, year, month, dayName), suffix);
560
+ if (found)
561
+ return remember(found);
562
+ }
563
+ }
564
+ }
565
+ return null;
566
+ }
567
+ /** Test seam: forget cached profile and rollout paths. */
568
+ export function resetCodexRolloutCaches() {
569
+ nativeStateCache.clear();
570
+ rolloutCache.clear();
571
+ }
572
+ export function createCodexTurnTracker() {
573
+ return {
574
+ file: null,
575
+ ino: null,
576
+ offset: 0,
577
+ midLine: false,
578
+ initialized: false,
579
+ regionFound: false,
580
+ gap: false,
581
+ baseline: null,
582
+ prevTotalTokens: null,
583
+ calls: 0,
584
+ callSum: { ...ZERO_CODEX_TOTALS },
585
+ firstCall: null,
586
+ lastTotals: null,
587
+ turnUsage: null,
588
+ reading: null,
589
+ taskStartedWindow: null,
590
+ compactions: [],
591
+ };
592
+ }
593
+ /** Reset everything a (re)discovery recomputes, keeping only the file identity. */
594
+ function resetAccounting(tracker) {
595
+ const fresh = createCodexTurnTracker();
596
+ Object.assign(tracker, { ...fresh, file: tracker.file, ino: tracker.ino });
597
+ }
598
+ function applyRecord(tracker, record, inTurn, threadId) {
599
+ switch (record.kind) {
600
+ case 'token-count': {
601
+ tracker.reading = {
602
+ tokens: codexOccupancy(record.last),
603
+ window: record.modelContextWindow ?? tracker.taskStartedWindow,
604
+ inTurn,
605
+ offset: record.offset,
606
+ };
607
+ if (!inTurn) {
608
+ tracker.baseline = record.total;
609
+ tracker.prevTotalTokens = record.total.totalTokens;
610
+ return;
611
+ }
612
+ const newCall = tracker.prevTotalTokens === null || record.total.totalTokens !== tracker.prevTotalTokens;
613
+ if (newCall) {
614
+ tracker.calls += 1;
615
+ tracker.callSum = addCodexTotals(tracker.callSum, record.last);
616
+ tracker.firstCall ??= { total: record.total, last: record.last };
617
+ }
618
+ tracker.lastTotals = record.total;
619
+ tracker.prevTotalTokens = record.total.totalTokens;
620
+ for (const compaction of tracker.compactions) {
621
+ if (compaction.postTokens === null)
622
+ compaction.postTokens = tracker.reading.tokens;
623
+ }
624
+ return;
625
+ }
626
+ case 'compacted':
627
+ if (inTurn)
628
+ tracker.compactions.push({ preTokens: tracker.reading?.tokens ?? null, postTokens: null });
629
+ return;
630
+ case 'task-started':
631
+ if (inTurn)
632
+ tracker.taskStartedWindow = record.modelContextWindow ?? tracker.taskStartedWindow;
633
+ return;
634
+ case 'token-usage-record':
635
+ if (inTurn && record.turn && (threadId === null || record.threadId === null || record.threadId === threadId)) {
636
+ tracker.turnUsage = record.turn;
637
+ }
638
+ return;
639
+ default:
640
+ return;
641
+ }
642
+ }
643
+ /**
644
+ * Turn-start discovery. Records written at or after `startedAtMs` (the spawn
645
+ * instant) belong to this turn: an `exec` process runs exactly one turn, and
646
+ * the previous turn's process had exited before this one was spawned.
647
+ */
648
+ function discover(tracker, fd, size, startedAtMs, threadId) {
649
+ for (let window = CODEX_ROLLOUT_TAIL_BYTES;; window *= 2) {
650
+ const capped = Math.min(window, CODEX_ROLLOUT_MAX_TAIL_BYTES);
651
+ const read = readRange(fd, size, Math.max(0, size - capped), capped, false);
652
+ const atFileStart = read.start === 0;
653
+ let regionIndex = -1;
654
+ for (let i = read.records.length - 1; i >= 0; i--) {
655
+ const at = read.records[i].at;
656
+ if (at !== null && at < startedAtMs) {
657
+ regionIndex = i + 1;
658
+ break;
659
+ }
660
+ }
661
+ const regionFound = regionIndex !== -1 || atFileStart;
662
+ if (regionIndex === -1)
663
+ regionIndex = 0;
664
+ const baselineInWindow = read.records.slice(0, regionIndex).some((r) => r.kind === 'token-count');
665
+ const settled = (regionFound && (baselineInWindow || atFileStart)) || capped >= CODEX_ROLLOUT_MAX_TAIL_BYTES;
666
+ if (!settled)
667
+ continue;
668
+ resetAccounting(tracker);
669
+ tracker.initialized = true;
670
+ tracker.regionFound = regionFound;
671
+ tracker.gap = !regionFound || read.skipped;
672
+ // No token_count before this turn in a window that reaches the file start: the thread had no calls yet.
673
+ if (atFileStart) {
674
+ tracker.baseline = { ...ZERO_CODEX_TOTALS };
675
+ tracker.prevTotalTokens = 0;
676
+ }
677
+ read.records.forEach((record, index) => applyRecord(tracker, record, index >= regionIndex, threadId));
678
+ if (!regionFound)
679
+ tracker.baseline = null;
680
+ tracker.offset = read.end;
681
+ tracker.midLine = false;
682
+ return;
683
+ }
684
+ }
685
+ /**
686
+ * Bring a turn's tracker up to date with `file`. Returns false when the file
687
+ * cannot be read (the tracker keeps what it had). Bounded: one discovery of
688
+ * ≤ CODEX_ROLLOUT_MAX_TAIL_BYTES, then ≤ CODEX_ROLLOUT_MAX_SCAN_BYTES per call.
689
+ */
690
+ export function advanceCodexTurnTracker(tracker, file, startedAtMs, threadId = null) {
691
+ const opened = openRollout(file);
692
+ if (!opened)
693
+ return false;
694
+ try {
695
+ const rewritten = tracker.file !== file || tracker.ino !== opened.ino || opened.size < tracker.offset;
696
+ if (!tracker.initialized || rewritten) {
697
+ tracker.file = file;
698
+ tracker.ino = opened.ino;
699
+ // A rewritten file (0.155 `migrate-rollouts`, a prune) invalidates every
700
+ // offset: rediscover from timestamps, which recomputes the turn exactly.
701
+ discover(tracker, opened.fd, opened.size, startedAtMs, threadId);
702
+ return true;
703
+ }
704
+ if (opened.size === tracker.offset)
705
+ return true;
706
+ let from = tracker.offset;
707
+ let midLine = tracker.midLine;
708
+ if (opened.size - from > CODEX_ROLLOUT_MAX_SCAN_BYTES) {
709
+ // Too much appeared at once: keep the newest tail, account the rest as a gap.
710
+ from = opened.size - CODEX_ROLLOUT_TAIL_BYTES;
711
+ midLine = byteBefore(opened.fd, from) !== NEWLINE;
712
+ tracker.gap = true;
713
+ }
714
+ const read = readRange(opened.fd, opened.size, from, CODEX_ROLLOUT_MAX_SCAN_BYTES, midLine);
715
+ for (const record of read.records)
716
+ applyRecord(tracker, record, true, threadId);
717
+ if (read.skipped && read.end <= from) {
718
+ // One line longer than the whole scan budget: step past what was read
719
+ // and drop the rest of that line next time.
720
+ tracker.offset = Math.min(opened.size, from + CODEX_ROLLOUT_MAX_SCAN_BYTES);
721
+ tracker.midLine = true;
722
+ tracker.gap = true;
723
+ }
724
+ else {
725
+ tracker.offset = read.end;
726
+ tracker.midLine = false;
727
+ }
728
+ return true;
729
+ }
730
+ finally {
731
+ try {
732
+ closeSync(opened.fd);
733
+ }
734
+ catch { /* already closed */ }
735
+ }
736
+ }
737
+ function sameUsage(a, b) {
738
+ return a.inputTokens === b.inputTokens && a.cachedInputTokens === b.cachedInputTokens && a.outputTokens === b.outputTokens;
739
+ }
740
+ /**
741
+ * This turn's true usage, from the best evidence available.
742
+ *
743
+ * WHY `turn.completed` IS NOT TRUSTED ON ITS OWN: codex prints its session's
744
+ * `total_token_usage` there, and on `exec resume` that session is seeded from
745
+ * the rollout — so turn 5 reports turns 1–5. Summing it per turn (which is
746
+ * what the engine does with `usage` events) would count turn 1 five times.
747
+ * The rollout's per-call records are the ground truth either way; the printed
748
+ * figure is used only when the rollout cannot answer, and even then a figure
749
+ * that equals the rollout's running total is converted to a delta.
750
+ *
751
+ * Verified on this machine's rollouts (221 turns with a CLI per-turn record):
752
+ * the per-call sum equals codex's own `turn_token_usage` exactly on every
753
+ * fully observed turn WITHOUT a compaction; with one, the record is larger by
754
+ * that compaction request's usage, which codex never writes as a
755
+ * `token_count` (its running total does not move either). The record is
756
+ * therefore preferred when present (0.149+); on older CLIs no codex surface —
757
+ * rollout or stdout — counts compaction requests, and neither does this.
758
+ *
759
+ * @param reported `turn.completed.usage` (null when the turn printed none)
760
+ * @param firstTurn true when this turn STARTED the thread — its printed
761
+ * figure is per-turn by construction
762
+ */
763
+ export function codexTurnUsage(tracker, reported, firstTurn) {
764
+ if (tracker?.turnUsage)
765
+ return { totals: tracker.turnUsage, source: 'turn-record' };
766
+ if (tracker && tracker.calls > 0 && tracker.regionFound && !tracker.gap) {
767
+ return { totals: tracker.callSum, source: 'calls' };
768
+ }
769
+ if (reported) {
770
+ if (firstTurn || !tracker?.lastTotals || !sameUsage(reported, tracker.lastTotals)) {
771
+ return { totals: reported, source: 'reported' };
772
+ }
773
+ // The printed figure IS the running total.
774
+ if (tracker.baseline)
775
+ return { totals: subtractCodexTotals(reported, tracker.baseline), source: 'reported-delta' };
776
+ if (tracker.firstCall) {
777
+ const before = subtractCodexTotals(tracker.firstCall.total, tracker.firstCall.last);
778
+ return { totals: subtractCodexTotals(reported, before), source: 'calls-partial' };
779
+ }
780
+ return { totals: reported, source: 'reported' };
781
+ }
782
+ if (tracker && tracker.calls > 0)
783
+ return { totals: tracker.callSum, source: 'calls-partial' };
784
+ return null;
785
+ }
786
+ //# sourceMappingURL=codex-rollout.js.map