@oar-sh/oar 0.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 (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,499 @@
1
+ 'use strict';
2
+
3
+ import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
4
+
5
+ // Re-exported for existing importers; the table itself lives in shared/ so the
6
+ // Cursor worker's fallback cannot drift from this one.
7
+ export { resolveFallbackContextLimitTokens };
8
+
9
+ const CONTEXT_CACHE_MAX = 128;
10
+
11
+ function normalizeText(value) {
12
+ const text = String(value || '').trim();
13
+ return text || '';
14
+ }
15
+
16
+ function toNullableInt(value) {
17
+ if (value === null || value === undefined || value === '') return null;
18
+ const n = Number(value);
19
+ if (!Number.isFinite(n)) return null;
20
+ return Math.round(n);
21
+ }
22
+
23
+ function toNullablePercent(value) {
24
+ if (value === null || value === undefined || value === '') return null;
25
+ const n = Number(value);
26
+ if (!Number.isFinite(n)) return null;
27
+ return Math.round(n * 100) / 100;
28
+ }
29
+
30
+ function getByPath(obj, parts) {
31
+ let current = obj;
32
+ for (const part of (parts || [])) {
33
+ if (!current || typeof current !== 'object' || !(part in current)) return undefined;
34
+ current = current[part];
35
+ }
36
+ return current;
37
+ }
38
+
39
+ function findFirstNumericByKey(obj, candidateKeys) {
40
+ if (!obj || typeof obj !== 'object') return null;
41
+ const wanted = new Set((candidateKeys || []).map((key) => normalizeText(key)).filter(Boolean));
42
+ if (!wanted.size) return null;
43
+
44
+ const stack = [obj];
45
+ while (stack.length) {
46
+ const current = stack.pop();
47
+ if (!current || typeof current !== 'object') continue;
48
+ for (const [key, value] of Object.entries(current)) {
49
+ if (value && typeof value === 'object') stack.push(value);
50
+ if (!wanted.has(key)) continue;
51
+ const numeric = Number(value);
52
+ if (Number.isFinite(numeric)) return numeric;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function resolveContextLimitTokens(modelId, data, modelUsage, getModelContextLimitTokens = null) {
59
+ const directCandidates = [
60
+ getByPath(data, ['maxContextTokens']),
61
+ getByPath(data, ['contextWindow']),
62
+ getByPath(data, ['maxTokens']),
63
+ getByPath(data, ['contextLimitTokens']),
64
+ getByPath(data, ['max_context_tokens']),
65
+ getByPath(data, ['tokenBudget']),
66
+ getByPath(modelUsage, ['maxContextTokens']),
67
+ getByPath(modelUsage, ['contextWindow']),
68
+ getByPath(modelUsage, ['maxTokens']),
69
+ ];
70
+ for (const value of directCandidates) {
71
+ const numeric = Number(value);
72
+ if (Number.isFinite(numeric) && numeric > 0) return Math.round(numeric);
73
+ }
74
+ const model = normalizeText(modelId).toLowerCase();
75
+ const catalogLimit = typeof getModelContextLimitTokens === 'function'
76
+ ? toNullableInt(getModelContextLimitTokens(model))
77
+ : null;
78
+ if (catalogLimit !== null && catalogLimit > 0) return catalogLimit;
79
+ return resolveFallbackContextLimitTokens(model);
80
+ }
81
+
82
+ function resolveExplicitContextLimitTokens(data, modelUsage) {
83
+ const directCandidates = [
84
+ getByPath(data, ['maxContextTokens']),
85
+ getByPath(data, ['contextWindow']),
86
+ getByPath(data, ['maxTokens']),
87
+ getByPath(data, ['contextLimitTokens']),
88
+ getByPath(data, ['max_context_tokens']),
89
+ getByPath(data, ['tokenBudget']),
90
+ getByPath(modelUsage, ['maxContextTokens']),
91
+ getByPath(modelUsage, ['contextWindow']),
92
+ getByPath(modelUsage, ['maxTokens']),
93
+ ];
94
+ for (const value of directCandidates) {
95
+ const numeric = Number(value);
96
+ if (Number.isFinite(numeric) && numeric > 0) return Math.round(numeric);
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function extractSessionIdFromEventsPath(pathModule, eventsPath) {
102
+ const parent = pathModule.basename(pathModule.dirname(String(eventsPath || '')));
103
+ return parent || null;
104
+ }
105
+
106
+ function hasContextFields(data) {
107
+ return (
108
+ Number.isFinite(Number(data?.currentTokens)) ||
109
+ Number.isFinite(Number(data?.systemTokens)) ||
110
+ Number.isFinite(Number(data?.conversationTokens)) ||
111
+ Number.isFinite(Number(data?.toolDefinitionsTokens))
112
+ );
113
+ }
114
+
115
+ function hasUsageSignals(data) {
116
+ return hasContextFields(data)
117
+ || Number.isFinite(Number(data?.inputTokens))
118
+ || Number.isFinite(Number(data?.outputTokens))
119
+ || Number.isFinite(Number(data?.reasoningTokens))
120
+ || Number.isFinite(Number(data?.cacheReadTokens))
121
+ || Number.isFinite(Number(data?.cacheWriteTokens));
122
+ }
123
+
124
+ function resolveEventModelId(data) {
125
+ return normalizeText(data?.currentModel || data?.model || data?.newModel) || null;
126
+ }
127
+
128
+ function hasSnapshotSignals(data) {
129
+ const currentModel = resolveEventModelId(data);
130
+ const modelUsage = currentModel && data?.modelMetrics?.[currentModel]?.usage && typeof data.modelMetrics[currentModel].usage === 'object'
131
+ ? data.modelMetrics[currentModel].usage
132
+ : null;
133
+ return hasContextFields(data)
134
+ || resolveExplicitContextLimitTokens(data, modelUsage) !== null
135
+ || Number.isFinite(Number(data?.usedPercent))
136
+ || Number.isFinite(Number(data?.freeTokens))
137
+ || Number.isFinite(Number(data?.remainingTokens))
138
+ || Number.isFinite(Number(data?.bufferTokens))
139
+ || Number.isFinite(Number(data?.inputTokens))
140
+ || Number.isFinite(Number(data?.reasoningTokens))
141
+ || Number.isFinite(Number(data?.cacheReadTokens))
142
+ || Number.isFinite(Number(data?.cacheWriteTokens))
143
+ || Number.isFinite(Number(modelUsage?.inputTokens))
144
+ || Number.isFinite(Number(modelUsage?.reasoningTokens))
145
+ || Number.isFinite(Number(modelUsage?.cacheReadTokens))
146
+ || Number.isFinite(Number(modelUsage?.cacheWriteTokens));
147
+ }
148
+
149
+ function buildSnapshotFromEvent({ event, data, eventsPath, sessionId, pathModule, getModelContextLimitTokens }) {
150
+ const currentModel = resolveEventModelId(data);
151
+ const copilotSessionId = extractSessionIdFromEventsPath(pathModule, eventsPath) || sessionId;
152
+ const modelUsage = currentModel && data?.modelMetrics?.[currentModel]?.usage && typeof data.modelMetrics[currentModel].usage === 'object'
153
+ ? data.modelMetrics[currentModel].usage
154
+ : null;
155
+ const systemTokens = toNullableInt(data?.systemTokens);
156
+ const conversationTokens = toNullableInt(data?.conversationTokens);
157
+ const toolsTokens = toNullableInt(data?.toolDefinitionsTokens);
158
+ const promptTokens = toNullableInt(modelUsage?.inputTokens) ?? toNullableInt(data?.inputTokens);
159
+ const completionTokens = toNullableInt(modelUsage?.outputTokens) ?? toNullableInt(data?.outputTokens);
160
+ const reasoningTokens = toNullableInt(modelUsage?.reasoningTokens) ?? toNullableInt(data?.reasoningTokens);
161
+ const usedTotalTokens = toNullableInt(data?.currentTokens)
162
+ ?? toNullableInt(findFirstNumericByKey(data, ['usedTokens', 'totalTokens', 'usedTotalTokens']))
163
+ ?? ((systemTokens !== null || conversationTokens !== null || toolsTokens !== null)
164
+ ? (Number(systemTokens || 0) + Number(conversationTokens || 0) + Number(toolsTokens || 0))
165
+ : null)
166
+ ?? ((promptTokens !== null || completionTokens !== null || reasoningTokens !== null)
167
+ ? (Number(promptTokens || 0) + Number(completionTokens || 0) + Number(reasoningTokens || 0))
168
+ : null);
169
+ const contextLimitTokens = resolveContextLimitTokens(currentModel, data, modelUsage, getModelContextLimitTokens);
170
+ const usedPercent = toNullablePercent(data?.usedPercent)
171
+ ?? toNullablePercent(findFirstNumericByKey(data, ['usedPercent', 'contextUsagePercent', 'tokenUsagePercent']))
172
+ ?? ((usedTotalTokens !== null && contextLimitTokens !== null && contextLimitTokens > 0)
173
+ ? Math.round((usedTotalTokens / contextLimitTokens) * 10000) / 100
174
+ : null);
175
+ const reportedFreeTokens = toNullableInt(data?.freeTokens)
176
+ ?? toNullableInt(data?.remainingTokens)
177
+ ?? toNullableInt(findFirstNumericByKey(data, ['freeTokens', 'remainingTokens', 'availableTokens']));
178
+ const freeTokens = reportedFreeTokens
179
+ ?? ((usedTotalTokens !== null && contextLimitTokens !== null)
180
+ ? Math.max(0, contextLimitTokens - usedTotalTokens)
181
+ : null);
182
+ // A remainder the CLI reported is already net of its own buffer; only the
183
+ // fallback above folds the buffer into free space.
184
+ const freeTokensIncludesBuffer = reportedFreeTokens === null && freeTokens !== null;
185
+ const bufferTokens = toNullableInt(data?.bufferTokens)
186
+ ?? toNullableInt(findFirstNumericByKey(data, ['bufferTokens', 'safeBufferTokens']))
187
+ ?? null;
188
+ const systemToolsTokens = ((systemTokens !== null || toolsTokens !== null)
189
+ ? Number(systemTokens || 0) + Number(toolsTokens || 0)
190
+ : null);
191
+ const cacheReadTokens = toNullableInt(modelUsage?.cacheReadTokens)
192
+ ?? toNullableInt(data?.cacheReadTokens)
193
+ ?? toNullableInt(data?.compactionTokensUsed?.cacheReadTokens)
194
+ ?? null;
195
+ const cacheWriteTokens = toNullableInt(modelUsage?.cacheWriteTokens)
196
+ ?? toNullableInt(data?.cacheWriteTokens)
197
+ ?? toNullableInt(data?.compactionTokensUsed?.cacheWriteTokens)
198
+ ?? null;
199
+ const partialMetricsWarning = !hasContextFields(data)
200
+ ? 'Legacy context-window metrics are unavailable in current session events; showing partial token data only.'
201
+ : null;
202
+
203
+ return {
204
+ snapshot: {
205
+ runtime_session_id: sessionId,
206
+ copilot_session_id: copilotSessionId,
207
+ model: currentModel,
208
+ used_total_tokens: usedTotalTokens,
209
+ max_context_tokens: contextLimitTokens,
210
+ used_percent: usedPercent,
211
+ free_tokens: freeTokens,
212
+ free_tokens_includes_buffer: freeTokensIncludesBuffer,
213
+ buffer_tokens: bufferTokens,
214
+ system_tokens: systemTokens,
215
+ messages_tokens: conversationTokens,
216
+ tools_tokens: toolsTokens,
217
+ system_tools_tokens: toNullableInt(systemToolsTokens),
218
+ used_prompt_tokens: promptTokens,
219
+ used_completion_tokens: completionTokens,
220
+ reasoning_tokens: reasoningTokens,
221
+ cache_read_tokens: cacheReadTokens,
222
+ cache_write_tokens: cacheWriteTokens,
223
+ captured_at: normalizeText(event?.timestamp) || null,
224
+ estimate_kind: null,
225
+ },
226
+ error: partialMetricsWarning,
227
+ };
228
+ }
229
+
230
+ function createEstimateState(seed = null) {
231
+ return {
232
+ totalCompletionTokens: Number(seed?.totalCompletionTokens || 0),
233
+ latestTimestamp: normalizeText(seed?.latestTimestamp) || null,
234
+ latestModel: normalizeText(seed?.latestModel) || null,
235
+ explicitContextLimitTokens: toNullableInt(seed?.explicitContextLimitTokens),
236
+ };
237
+ }
238
+
239
+ function updateEstimateState(estimate, event, data) {
240
+ if (!estimate || !data || typeof data !== 'object') return estimate;
241
+ const next = estimate;
242
+ const modelId = resolveEventModelId(data);
243
+ const timestamp = normalizeText(event?.timestamp) || null;
244
+ if (modelId) next.latestModel = modelId;
245
+ if (timestamp) next.latestTimestamp = timestamp;
246
+ const currentModel = modelId;
247
+ const modelUsage = currentModel && data?.modelMetrics?.[currentModel]?.usage && typeof data.modelMetrics[currentModel].usage === 'object'
248
+ ? data.modelMetrics[currentModel].usage
249
+ : null;
250
+ const explicitContextLimitTokens = resolveExplicitContextLimitTokens(data, modelUsage);
251
+ if (explicitContextLimitTokens !== null) next.explicitContextLimitTokens = explicitContextLimitTokens;
252
+ if (String(event?.type || '').trim() !== 'assistant.message') return next;
253
+ const completionTokens = toNullableInt(modelUsage?.outputTokens) ?? toNullableInt(data?.outputTokens);
254
+ if (completionTokens !== null) next.totalCompletionTokens += completionTokens;
255
+ return next;
256
+ }
257
+
258
+ function buildEstimatedSnapshot({ estimate, eventsPath, sessionId, pathModule, getModelContextLimitTokens }) {
259
+ const totalCompletionTokens = toNullableInt(estimate?.totalCompletionTokens);
260
+ if (totalCompletionTokens === null || totalCompletionTokens <= 0) return null;
261
+ const copilotSessionId = extractSessionIdFromEventsPath(pathModule, eventsPath) || sessionId;
262
+ const model = normalizeText(estimate?.latestModel) || null;
263
+ const contextLimitTokens = toNullableInt(estimate?.explicitContextLimitTokens)
264
+ ?? resolveContextLimitTokens(model, null, null, getModelContextLimitTokens);
265
+ // Cumulative assistant output grows without bound while compaction keeps the
266
+ // real window in check, so this ratio can exceed 1. Clamp it: an occupancy
267
+ // gauge reading "340%" is worse than one pinned at 100%.
268
+ const usedPercent = (contextLimitTokens !== null && contextLimitTokens > 0)
269
+ ? Math.min(100, Math.round((totalCompletionTokens / contextLimitTokens) * 10000) / 100)
270
+ : null;
271
+ const freeTokens = (contextLimitTokens !== null)
272
+ ? Math.max(0, contextLimitTokens - totalCompletionTokens)
273
+ : null;
274
+ return {
275
+ snapshot: {
276
+ runtime_session_id: sessionId,
277
+ copilot_session_id: copilotSessionId,
278
+ model,
279
+ used_total_tokens: totalCompletionTokens,
280
+ max_context_tokens: contextLimitTokens,
281
+ used_percent: usedPercent,
282
+ free_tokens: freeTokens,
283
+ buffer_tokens: null,
284
+ system_tokens: null,
285
+ messages_tokens: totalCompletionTokens,
286
+ tools_tokens: null,
287
+ system_tools_tokens: null,
288
+ used_prompt_tokens: null,
289
+ used_completion_tokens: totalCompletionTokens,
290
+ reasoning_tokens: null,
291
+ cache_read_tokens: null,
292
+ cache_write_tokens: null,
293
+ captured_at: normalizeText(estimate?.latestTimestamp) || null,
294
+ estimate_kind: 'assistant-output-lower-bound',
295
+ },
296
+ error: 'Legacy context-window metrics are unavailable in current session events; showing a lower-bound estimate from cumulative assistant completion tokens only. Prompt, system/tool, cache, and buffer tokens are unavailable.',
297
+ };
298
+ }
299
+
300
+ function hydrateSnapshotFromEstimate(snapshot, estimate, getModelContextLimitTokens = null) {
301
+ if (!snapshot || typeof snapshot !== 'object') return snapshot;
302
+ const model = normalizeText(snapshot.model) || normalizeText(estimate?.latestModel) || null;
303
+ const contextLimitTokens = toNullableInt(snapshot.max_context_tokens)
304
+ ?? toNullableInt(estimate?.explicitContextLimitTokens)
305
+ ?? resolveContextLimitTokens(model, null, null, getModelContextLimitTokens);
306
+ const usedTotalTokens = toNullableInt(snapshot.used_total_tokens);
307
+ const usedPercent = toNullablePercent(snapshot.used_percent)
308
+ ?? ((usedTotalTokens !== null && contextLimitTokens !== null && contextLimitTokens > 0)
309
+ ? Math.round((usedTotalTokens / contextLimitTokens) * 10000) / 100
310
+ : null);
311
+ const freeTokens = toNullableInt(snapshot.free_tokens)
312
+ ?? ((usedTotalTokens !== null && contextLimitTokens !== null)
313
+ ? Math.max(0, contextLimitTokens - usedTotalTokens)
314
+ : null);
315
+ const completionTokens = toNullableInt(snapshot.used_completion_tokens)
316
+ ?? toNullableInt(estimate?.totalCompletionTokens);
317
+ return {
318
+ ...snapshot,
319
+ model,
320
+ max_context_tokens: contextLimitTokens,
321
+ used_percent: usedPercent,
322
+ free_tokens: freeTokens,
323
+ used_completion_tokens: completionTokens,
324
+ };
325
+ }
326
+
327
+ function consumeContextSignals(lines, options, seed = null) {
328
+ let latest = seed?.latest || null;
329
+ const estimate = createEstimateState(seed?.estimate || null);
330
+ for (const line of (lines || [])) {
331
+ if (!line) continue;
332
+ let event = null;
333
+ try {
334
+ event = JSON.parse(line);
335
+ } catch {
336
+ continue;
337
+ }
338
+ const data = event?.data && typeof event.data === 'object' ? event.data : null;
339
+ if (!data) continue;
340
+ updateEstimateState(estimate, event, data);
341
+ if (!hasSnapshotSignals(data)) continue;
342
+ latest = buildSnapshotFromEvent({ event, data, ...options });
343
+ }
344
+ return { latest, estimate };
345
+ }
346
+
347
+ function readFileChunk(fsModule, eventsPath, startOffset, endOffset) {
348
+ const safeStart = Math.max(0, Number(startOffset) || 0);
349
+ const safeEnd = Math.max(safeStart, Number(endOffset) || 0);
350
+ const byteLength = safeEnd - safeStart;
351
+ if (byteLength <= 0) return '';
352
+
353
+ const handle = fsModule.openSync(eventsPath, 'r');
354
+ try {
355
+ const buffer = Buffer.alloc(byteLength);
356
+ const bytesRead = fsModule.readSync(handle, buffer, 0, byteLength, safeStart);
357
+ return buffer.toString('utf8', 0, bytesRead);
358
+ } finally {
359
+ fsModule.closeSync(handle);
360
+ }
361
+ }
362
+
363
+ function splitJsonLines(text, carry = '') {
364
+ const combined = `${String(carry || '')}${String(text || '')}`;
365
+ if (!combined) return { lines: [], remainder: '' };
366
+ const normalized = combined.replace(/\r\n/g, '\n');
367
+ const endsWithNewline = normalized.endsWith('\n');
368
+ const parts = normalized.split('\n');
369
+ const remainder = endsWithNewline ? '' : parts.pop() || '';
370
+ return {
371
+ lines: parts.filter(Boolean),
372
+ remainder,
373
+ };
374
+ }
375
+
376
+ export function createContextSnapshotService({
377
+ fs,
378
+ path,
379
+ resolveSessionStateRoot,
380
+ getModelContextLimitTokens = null,
381
+ } = {}) {
382
+ const contextCache = new Map();
383
+
384
+ function cacheRecord(cacheKey, record) {
385
+ contextCache.set(cacheKey, record);
386
+ if (contextCache.size <= CONTEXT_CACHE_MAX) return;
387
+ let oldestKey = null;
388
+ let oldestAt = Number.POSITIVE_INFINITY;
389
+ for (const [key, value] of contextCache.entries()) {
390
+ const cachedAt = Number(value?.cachedAt || 0);
391
+ if (cachedAt < oldestAt) {
392
+ oldestAt = cachedAt;
393
+ oldestKey = key;
394
+ }
395
+ }
396
+ if (oldestKey) contextCache.delete(oldestKey);
397
+ }
398
+
399
+ function readContextFromSessionEvents(runtimeSessionId, runtimeSessionKey = null) {
400
+ const sessionId = normalizeText(runtimeSessionId);
401
+ const sessionKey = normalizeText(runtimeSessionKey || runtimeSessionId);
402
+ if (!sessionKey) {
403
+ return { snapshot: null, eventsPath: null, error: 'Missing runtime session ID' };
404
+ }
405
+
406
+ const root = String(resolveSessionStateRoot?.() || '').trim();
407
+ const eventsPath = path.join(root, sessionKey, 'events.jsonl');
408
+ if (!fs.existsSync(eventsPath)) {
409
+ contextCache.delete(sessionKey);
410
+ return { snapshot: null, eventsPath, error: `Session events file not found at ${eventsPath}` };
411
+ }
412
+
413
+ let stat = null;
414
+ try {
415
+ stat = fs.statSync(eventsPath);
416
+ } catch (error) {
417
+ contextCache.delete(sessionKey);
418
+ return { snapshot: null, eventsPath, error: `Failed reading events file stats: ${error?.message || String(error)}` };
419
+ }
420
+
421
+ const sizeBytes = Number(stat?.size || 0);
422
+ const mtimeMs = Number(stat?.mtimeMs || 0);
423
+ const cached = contextCache.get(sessionKey) || null;
424
+ if (cached && cached.eventsPath === eventsPath && cached.sizeBytes === sizeBytes && cached.mtimeMs === mtimeMs) {
425
+ cached.cachedAt = Date.now();
426
+ return {
427
+ snapshot: hydrateSnapshotFromEstimate(cached.snapshot, cached.estimate, getModelContextLimitTokens),
428
+ eventsPath,
429
+ error: cached.error || null,
430
+ };
431
+ }
432
+
433
+ const canAppend = cached
434
+ && cached.eventsPath === eventsPath
435
+ && sizeBytes >= Number(cached.sizeBytes || 0);
436
+ const startOffset = canAppend ? Number(cached.sizeBytes || 0) : 0;
437
+
438
+ let chunkText = '';
439
+ try {
440
+ chunkText = readFileChunk(fs, eventsPath, startOffset, sizeBytes);
441
+ } catch (error) {
442
+ return { snapshot: cached?.snapshot || null, eventsPath, error: `Failed reading events file: ${error?.message || String(error)}` };
443
+ }
444
+
445
+ const split = splitJsonLines(chunkText, canAppend ? cached?.remainder || '' : '');
446
+ const consumed = consumeContextSignals(split.lines, {
447
+ eventsPath,
448
+ sessionId,
449
+ pathModule: path,
450
+ getModelContextLimitTokens,
451
+ }, canAppend ? {
452
+ latest: cached?.latest || null,
453
+ estimate: cached?.estimate || null,
454
+ } : null);
455
+
456
+ const latest = consumed?.latest || null;
457
+ const estimated = buildEstimatedSnapshot({
458
+ estimate: consumed?.estimate || null,
459
+ eventsPath,
460
+ sessionId,
461
+ pathModule: path,
462
+ getModelContextLimitTokens,
463
+ });
464
+ const previousSnapshot = canAppend ? (cached?.snapshot || null) : null;
465
+ const previousError = canAppend ? (cached?.error || null) : null;
466
+ const nextSnapshot = hydrateSnapshotFromEstimate(
467
+ latest?.snapshot || estimated?.snapshot || previousSnapshot,
468
+ consumed?.estimate || null,
469
+ getModelContextLimitTokens,
470
+ );
471
+ const nextError = latest
472
+ ? (latest.error || null)
473
+ : estimated
474
+ ? (estimated.error || null)
475
+ : (previousError || (nextSnapshot ? null : 'No context-bearing events found for this session'));
476
+
477
+ cacheRecord(sessionKey, {
478
+ eventsPath,
479
+ sizeBytes,
480
+ mtimeMs,
481
+ latest,
482
+ estimate: consumed?.estimate || null,
483
+ snapshot: nextSnapshot,
484
+ error: nextError,
485
+ remainder: split.remainder,
486
+ cachedAt: Date.now(),
487
+ });
488
+
489
+ return {
490
+ snapshot: nextSnapshot,
491
+ eventsPath,
492
+ error: nextError,
493
+ };
494
+ }
495
+
496
+ return {
497
+ readContextFromSessionEvents,
498
+ };
499
+ }
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Builds the provider-neutral payload the context-usage modal renders.
5
+ *
6
+ * Claude sessions supply real per-category token counts from the Agent SDK;
7
+ * Copilot sessions only have the coarse system/tools/messages split recorded in
8
+ * their session events, so those are synthesized into the same shape. One
9
+ * renderer serves both.
10
+ */
11
+
12
+ // Fallback ordering/colors for synthesized (Copilot) categories. Claude
13
+ // categories arrive with their own SDK-assigned colors.
14
+ const COPILOT_CATEGORY_COLORS = Object.freeze({
15
+ 'System/Tools': 'blue',
16
+ Messages: 'orange',
17
+ Buffer: 'gray',
18
+ });
19
+
20
+ function toNullableInt(value) {
21
+ if (value === null || value === undefined || value === '') return null;
22
+ const n = Number(value);
23
+ if (!Number.isFinite(n)) return null;
24
+ return Math.round(n);
25
+ }
26
+
27
+ function toNullablePercent(value) {
28
+ if (value === null || value === undefined || value === '') return null;
29
+ const n = Number(value);
30
+ if (!Number.isFinite(n)) return null;
31
+ return Math.round(n * 100) / 100;
32
+ }
33
+
34
+ function normalizeText(value) {
35
+ return String(value || '').trim();
36
+ }
37
+
38
+ function percentOf(tokens, maxTokens) {
39
+ if (tokens === null || maxTokens === null || maxTokens <= 0) return null;
40
+ return Math.round((tokens / maxTokens) * 10000) / 100;
41
+ }
42
+
43
+ /**
44
+ * Split the unused window into the part a conversation may actually occupy and
45
+ * the reserve above the auto-compact threshold.
46
+ *
47
+ * Every snapshot writer derives free space as `max - used`, which still
48
+ * *contains* the region the buffer describes. Anything rendering the two side
49
+ * by side (the usage grid, the modal's free row) needs disjoint slices, or the
50
+ * pair overshoots the window — latent until Claude began reporting a real
51
+ * buffer instead of a always-zero one.
52
+ */
53
+ export function splitFreeAndBufferTokens(snapshot) {
54
+ const freeTokens = toNullableInt(snapshot?.free_tokens);
55
+ const bufferTokens = toNullableInt(snapshot?.buffer_tokens);
56
+ if (freeTokens === null || bufferTokens === null) return { freeTokens, bufferTokens };
57
+ // Only a writer that derived free space from `max - used` folded the buffer
58
+ // into it. A provider-reported remainder (Copilot's remainingTokens /
59
+ // availableTokens, which sit alongside its own safeBufferTokens) is already
60
+ // net of that reserve, and subtracting twice would understate it — so an
61
+ // unflagged snapshot is left alone.
62
+ if (snapshot?.free_tokens_includes_buffer !== true) return { freeTokens, bufferTokens };
63
+ // Past the threshold the reserve is bigger than what is left, so the buffer
64
+ // takes the remainder and free space reads as spent.
65
+ const reserved = Math.max(0, Math.min(bufferTokens, freeTokens));
66
+ return { freeTokens: freeTokens - reserved, bufferTokens: reserved };
67
+ }
68
+
69
+ function buildCopilotCategories(snapshot) {
70
+ // The auto-compact reserve is deliberately absent: it is not occupied
71
+ // context, and it now rides the view as its own field so Claude sessions
72
+ // (whose categories come from the SDK) account for it too.
73
+ const raw = [
74
+ ['System/Tools', toNullableInt(snapshot?.system_tools_tokens)],
75
+ ['Messages', toNullableInt(snapshot?.messages_tokens)],
76
+ ];
77
+ return raw
78
+ .filter(([, tokens]) => tokens !== null && tokens > 0)
79
+ .map(([name, tokens]) => ({
80
+ name,
81
+ tokens,
82
+ color: COPILOT_CATEGORY_COLORS[name] || null,
83
+ isDeferred: false,
84
+ }));
85
+ }
86
+
87
+ /**
88
+ * @param {object} args
89
+ * @param {object|null} args.snapshot the shared context snapshot contract
90
+ * @param {object|null} args.contextUsage normalized Claude SDK usage, when available
91
+ * @returns {object|null} null when there is nothing worth rendering
92
+ */
93
+ export function buildContextUsageView({ snapshot = null, contextUsage = null } = {}) {
94
+ const maxTokens = toNullableInt(contextUsage?.maxTokens)
95
+ ?? toNullableInt(snapshot?.max_context_tokens);
96
+ const totalTokens = toNullableInt(contextUsage?.totalTokens)
97
+ ?? toNullableInt(snapshot?.used_total_tokens);
98
+ if (totalTokens === null && maxTokens === null) return null;
99
+
100
+ const percentage = toNullablePercent(contextUsage?.percentage)
101
+ ?? toNullablePercent(snapshot?.used_percent)
102
+ ?? percentOf(totalTokens, maxTokens);
103
+
104
+ // Free space and buffer are two slices of the same unused window, so they are
105
+ // resolved together before either is rendered.
106
+ const storedFreeTokens = toNullableInt(snapshot?.free_tokens);
107
+ const { freeTokens, bufferTokens } = splitFreeAndBufferTokens({
108
+ free_tokens: storedFreeTokens
109
+ ?? ((totalTokens !== null && maxTokens !== null) ? Math.max(0, maxTokens - totalTokens) : null),
110
+ buffer_tokens: snapshot?.buffer_tokens,
111
+ // Deriving the value here means it necessarily contains the buffer.
112
+ free_tokens_includes_buffer: storedFreeTokens === null
113
+ ? true
114
+ : snapshot?.free_tokens_includes_buffer === true,
115
+ });
116
+
117
+ const sourceCategories = Array.isArray(contextUsage?.categories) && contextUsage.categories.length
118
+ ? contextUsage.categories
119
+ : buildCopilotCategories(snapshot);
120
+
121
+ const categories = sourceCategories
122
+ .map((entry) => ({
123
+ name: normalizeText(entry?.name),
124
+ tokens: toNullableInt(entry?.tokens),
125
+ color: normalizeText(entry?.color) || null,
126
+ isDeferred: entry?.isDeferred === true,
127
+ }))
128
+ .filter((entry) => entry.name && entry.tokens !== null && entry.tokens > 0)
129
+ .map((entry) => ({ ...entry, percent: percentOf(entry.tokens, maxTokens) }));
130
+
131
+ return {
132
+ model: normalizeText(contextUsage?.model) || normalizeText(snapshot?.model) || null,
133
+ totalTokens,
134
+ maxTokens,
135
+ // Clamp: an estimated snapshot can report more than 100% (see the
136
+ // cumulative-output estimator), and the bar must not overflow.
137
+ percentage: percentage === null ? null : Math.max(0, Math.min(100, percentage)),
138
+ categories,
139
+ freeTokens,
140
+ freePercent: percentOf(freeTokens, maxTokens),
141
+ // Unused, but unusable: the window above the auto-compact threshold. Split
142
+ // out of free space, so the two are rendered as separate rows and the
143
+ // table still accounts for the whole window.
144
+ bufferTokens,
145
+ bufferPercent: percentOf(bufferTokens, maxTokens),
146
+ // A token count, not a percent — the CLI compacts once the conversation
147
+ // crosses it. `rawMaxTokens` rides along as recorded (it tracks the active
148
+ // window setting, NOT the model's own limit — probed 2026-08-20 — so
149
+ // nothing may annotate the slider from it).
150
+ autoCompactThreshold: toNullableInt(contextUsage?.autoCompactThreshold),
151
+ rawMaxTokens: toNullableInt(contextUsage?.rawMaxTokens),
152
+ autocompactSource: normalizeText(contextUsage?.autocompactSource) || null,
153
+ isAutoCompactEnabled: contextUsage?.isAutoCompactEnabled === true,
154
+ // Copilot's degraded path reports a lower bound only; the UI labels it
155
+ // rather than presenting it as measured.
156
+ isEstimate: !!normalizeText(snapshot?.estimate_kind),
157
+ estimateKind: normalizeText(snapshot?.estimate_kind) || null,
158
+ capturedAt: normalizeText(snapshot?.captured_at) || null,
159
+ };
160
+ }