@gokulvenkatareddy/cortex 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1295 -0
- package/apps/octogent/.github/workflows/ci.yml +40 -0
- package/apps/octogent/.shims/claude +4 -0
- package/apps/octogent/AGENTS.md +71 -0
- package/apps/octogent/CONTRIBUTING.md +72 -0
- package/apps/octogent/LICENSE +21 -0
- package/apps/octogent/README.md +184 -0
- package/apps/octogent/apps/api/AGENTS.md +32 -0
- package/apps/octogent/apps/api/package.json +19 -0
- package/apps/octogent/apps/api/src/agentStateDetection.ts +181 -0
- package/apps/octogent/apps/api/src/claudeSessionScanner.ts +235 -0
- package/apps/octogent/apps/api/src/claudeSkills.ts +182 -0
- package/apps/octogent/apps/api/src/claudeUsage.ts +922 -0
- package/apps/octogent/apps/api/src/cli.ts +595 -0
- package/apps/octogent/apps/api/src/codeIntelStore.ts +46 -0
- package/apps/octogent/apps/api/src/codexUsage.ts +278 -0
- package/apps/octogent/apps/api/src/createApiServer/codeIntelRoutes.ts +60 -0
- package/apps/octogent/apps/api/src/createApiServer/conversationRoutes.ts +128 -0
- package/apps/octogent/apps/api/src/createApiServer/deckRoutes.ts +873 -0
- package/apps/octogent/apps/api/src/createApiServer/gitParsers.ts +140 -0
- package/apps/octogent/apps/api/src/createApiServer/gitRoutes.ts +214 -0
- package/apps/octogent/apps/api/src/createApiServer/miscRoutes.ts +316 -0
- package/apps/octogent/apps/api/src/createApiServer/monitorParsers.ts +137 -0
- package/apps/octogent/apps/api/src/createApiServer/monitorRoutes.ts +95 -0
- package/apps/octogent/apps/api/src/createApiServer/requestHandler.ts +311 -0
- package/apps/octogent/apps/api/src/createApiServer/requestParsers.ts +25 -0
- package/apps/octogent/apps/api/src/createApiServer/routeHelpers.ts +97 -0
- package/apps/octogent/apps/api/src/createApiServer/security.ts +70 -0
- package/apps/octogent/apps/api/src/createApiServer/terminalParsers.ts +167 -0
- package/apps/octogent/apps/api/src/createApiServer/terminalRoutes.ts +315 -0
- package/apps/octogent/apps/api/src/createApiServer/types.ts +24 -0
- package/apps/octogent/apps/api/src/createApiServer/uiStateParsers.ts +255 -0
- package/apps/octogent/apps/api/src/createApiServer/upgradeHandler.ts +38 -0
- package/apps/octogent/apps/api/src/createApiServer/usageRoutes.ts +84 -0
- package/apps/octogent/apps/api/src/createApiServer.ts +176 -0
- package/apps/octogent/apps/api/src/deck/readDeckTentacles.ts +595 -0
- package/apps/octogent/apps/api/src/githubRepoSummary.ts +397 -0
- package/apps/octogent/apps/api/src/logging.ts +9 -0
- package/apps/octogent/apps/api/src/monitor/defaults.ts +3 -0
- package/apps/octogent/apps/api/src/monitor/index.ts +8 -0
- package/apps/octogent/apps/api/src/monitor/repository.ts +303 -0
- package/apps/octogent/apps/api/src/monitor/service.ts +349 -0
- package/apps/octogent/apps/api/src/monitor/types.ts +120 -0
- package/apps/octogent/apps/api/src/monitor/xProvider.ts +587 -0
- package/apps/octogent/apps/api/src/projectPersistence.ts +377 -0
- package/apps/octogent/apps/api/src/prompts/index.ts +10 -0
- package/apps/octogent/apps/api/src/prompts/promptResolver.ts +145 -0
- package/apps/octogent/apps/api/src/runtimeMetadata.ts +69 -0
- package/apps/octogent/apps/api/src/server.ts +80 -0
- package/apps/octogent/apps/api/src/setupState.ts +80 -0
- package/apps/octogent/apps/api/src/setupStatus.ts +174 -0
- package/apps/octogent/apps/api/src/startupPrerequisites.ts +146 -0
- package/apps/octogent/apps/api/src/terminalRuntime/channelMessaging.ts +87 -0
- package/apps/octogent/apps/api/src/terminalRuntime/claudeTranscript.ts +279 -0
- package/apps/octogent/apps/api/src/terminalRuntime/constants.ts +15 -0
- package/apps/octogent/apps/api/src/terminalRuntime/conversations.ts +492 -0
- package/apps/octogent/apps/api/src/terminalRuntime/gitOperations.ts +341 -0
- package/apps/octogent/apps/api/src/terminalRuntime/hookProcessor.ts +405 -0
- package/apps/octogent/apps/api/src/terminalRuntime/protocol.ts +46 -0
- package/apps/octogent/apps/api/src/terminalRuntime/ptyEnvironment.ts +50 -0
- package/apps/octogent/apps/api/src/terminalRuntime/registry.ts +423 -0
- package/apps/octogent/apps/api/src/terminalRuntime/sessionRuntime.ts +671 -0
- package/apps/octogent/apps/api/src/terminalRuntime/systemClients.ts +432 -0
- package/apps/octogent/apps/api/src/terminalRuntime/types.ts +157 -0
- package/apps/octogent/apps/api/src/terminalRuntime/worktreeManager.ts +135 -0
- package/apps/octogent/apps/api/src/terminalRuntime.ts +567 -0
- package/apps/octogent/apps/api/src/usageUtils.ts +16 -0
- package/apps/octogent/apps/api/src/ws-shim.d.ts +28 -0
- package/apps/octogent/apps/api/tests/agentStateDetection.test.ts +67 -0
- package/apps/octogent/apps/api/tests/claudeUsage.test.ts +583 -0
- package/apps/octogent/apps/api/tests/codexUsage.test.ts +107 -0
- package/apps/octogent/apps/api/tests/createApiServer.test.ts +3207 -0
- package/apps/octogent/apps/api/tests/githubRepoSummary.test.ts +100 -0
- package/apps/octogent/apps/api/tests/logging.test.ts +33 -0
- package/apps/octogent/apps/api/tests/monitorApi.test.ts +467 -0
- package/apps/octogent/apps/api/tests/monitorCore.test.ts +104 -0
- package/apps/octogent/apps/api/tests/promptResolver.test.ts +109 -0
- package/apps/octogent/apps/api/tests/protocol.test.ts +14 -0
- package/apps/octogent/apps/api/tests/sessionRuntime.test.ts +608 -0
- package/apps/octogent/apps/api/tests/startupPrerequisites.test.ts +70 -0
- package/apps/octogent/apps/api/tests/upgradeHandler.test.ts +40 -0
- package/apps/octogent/apps/api/tests/xMonitorProvider.test.ts +109 -0
- package/apps/octogent/apps/api/tsconfig.json +7 -0
- package/apps/octogent/apps/api/vitest.config.ts +7 -0
- package/apps/octogent/apps/web/AGENTS.md +38 -0
- package/apps/octogent/apps/web/index.html +13 -0
- package/apps/octogent/apps/web/package.json +32 -0
- package/apps/octogent/apps/web/public/octopus-favicon.svg +26 -0
- package/apps/octogent/apps/web/src/App.tsx +646 -0
- package/apps/octogent/apps/web/src/app/canvas/types.ts +34 -0
- package/apps/octogent/apps/web/src/app/codeIntelAggregation.ts +278 -0
- package/apps/octogent/apps/web/src/app/constants.ts +28 -0
- package/apps/octogent/apps/web/src/app/conversationNormalizers.ts +135 -0
- package/apps/octogent/apps/web/src/app/formatTimestamp.ts +18 -0
- package/apps/octogent/apps/web/src/app/githubMetrics.ts +76 -0
- package/apps/octogent/apps/web/src/app/githubNormalizers.ts +91 -0
- package/apps/octogent/apps/web/src/app/hooks/useAgentRuntimeStates.ts +18 -0
- package/apps/octogent/apps/web/src/app/hooks/useBackendLivenessPolling.ts +53 -0
- package/apps/octogent/apps/web/src/app/hooks/useCanvasGraphData.ts +449 -0
- package/apps/octogent/apps/web/src/app/hooks/useCanvasTransform.ts +260 -0
- package/apps/octogent/apps/web/src/app/hooks/useClaudeUsagePolling.ts +40 -0
- package/apps/octogent/apps/web/src/app/hooks/useClickOutside.ts +30 -0
- package/apps/octogent/apps/web/src/app/hooks/useCodeIntelRuntime.ts +83 -0
- package/apps/octogent/apps/web/src/app/hooks/useCodexUsagePolling.ts +35 -0
- package/apps/octogent/apps/web/src/app/hooks/useConsoleKeyboardShortcuts.ts +31 -0
- package/apps/octogent/apps/web/src/app/hooks/useConversationsRuntime.ts +377 -0
- package/apps/octogent/apps/web/src/app/hooks/useForceSimulation.ts +319 -0
- package/apps/octogent/apps/web/src/app/hooks/useGitHubPrimaryViewModel.ts +143 -0
- package/apps/octogent/apps/web/src/app/hooks/useGithubSummaryPolling.ts +28 -0
- package/apps/octogent/apps/web/src/app/hooks/useInitialColumnsHydration.ts +64 -0
- package/apps/octogent/apps/web/src/app/hooks/useMonitorRuntime.ts +220 -0
- package/apps/octogent/apps/web/src/app/hooks/usePersistedUiState.ts +536 -0
- package/apps/octogent/apps/web/src/app/hooks/usePollingData.ts +79 -0
- package/apps/octogent/apps/web/src/app/hooks/usePromptLibrary.ts +185 -0
- package/apps/octogent/apps/web/src/app/hooks/useTentacleGitLifecycle.ts +530 -0
- package/apps/octogent/apps/web/src/app/hooks/useTerminalCompletionNotification.ts +94 -0
- package/apps/octogent/apps/web/src/app/hooks/useTerminalMutations.ts +266 -0
- package/apps/octogent/apps/web/src/app/hooks/useTerminalStateReconciliation.ts +23 -0
- package/apps/octogent/apps/web/src/app/hooks/useUsageHeatmapPolling.ts +43 -0
- package/apps/octogent/apps/web/src/app/hooks/useWorkspaceSetup.ts +80 -0
- package/apps/octogent/apps/web/src/app/hotkeys.ts +31 -0
- package/apps/octogent/apps/web/src/app/monitorNormalizers.ts +145 -0
- package/apps/octogent/apps/web/src/app/notificationSounds.ts +164 -0
- package/apps/octogent/apps/web/src/app/terminalRuntimeStateStore.ts +261 -0
- package/apps/octogent/apps/web/src/app/terminalState.ts +21 -0
- package/apps/octogent/apps/web/src/app/types.ts +42 -0
- package/apps/octogent/apps/web/src/app/uiStateNormalizers.ts +113 -0
- package/apps/octogent/apps/web/src/app/usageNormalizers.ts +58 -0
- package/apps/octogent/apps/web/src/components/ActiveAgentsSidebar.tsx +60 -0
- package/apps/octogent/apps/web/src/components/ActivityPrimaryView.tsx +21 -0
- package/apps/octogent/apps/web/src/components/AgentStateBadge.tsx +47 -0
- package/apps/octogent/apps/web/src/components/CanvasPrimaryView.tsx +1532 -0
- package/apps/octogent/apps/web/src/components/ClearAllConversationsDialog.tsx +33 -0
- package/apps/octogent/apps/web/src/components/CodeIntelArcDiagram.tsx +245 -0
- package/apps/octogent/apps/web/src/components/CodeIntelPrimaryView.tsx +104 -0
- package/apps/octogent/apps/web/src/components/CodeIntelTreemap.tsx +138 -0
- package/apps/octogent/apps/web/src/components/ConsolePrimaryNav.tsx +31 -0
- package/apps/octogent/apps/web/src/components/ConversationsPrimaryView.tsx +243 -0
- package/apps/octogent/apps/web/src/components/DeckPrimaryView.tsx +613 -0
- package/apps/octogent/apps/web/src/components/DeleteTentacleDialog.tsx +91 -0
- package/apps/octogent/apps/web/src/components/EmptyOctopus.tsx +715 -0
- package/apps/octogent/apps/web/src/components/GitHubPrimaryView.tsx +494 -0
- package/apps/octogent/apps/web/src/components/MonitorPrimaryView.tsx +475 -0
- package/apps/octogent/apps/web/src/components/PrimaryViewRouter.tsx +99 -0
- package/apps/octogent/apps/web/src/components/PromptsPrimaryView.tsx +243 -0
- package/apps/octogent/apps/web/src/components/RuntimeStatusStrip.tsx +273 -0
- package/apps/octogent/apps/web/src/components/SettingsPrimaryView.tsx +92 -0
- package/apps/octogent/apps/web/src/components/SidebarActionPanel.tsx +124 -0
- package/apps/octogent/apps/web/src/components/SidebarConversationsList.tsx +279 -0
- package/apps/octogent/apps/web/src/components/SidebarPromptsList.tsx +116 -0
- package/apps/octogent/apps/web/src/components/TelemetryTape.tsx +106 -0
- package/apps/octogent/apps/web/src/components/TentacleGitActionsDialog.tsx +341 -0
- package/apps/octogent/apps/web/src/components/Terminal.tsx +524 -0
- package/apps/octogent/apps/web/src/components/TerminalPromptPicker.tsx +140 -0
- package/apps/octogent/apps/web/src/components/UsageHeatmap.tsx +702 -0
- package/apps/octogent/apps/web/src/components/canvas/CanvasTentaclePanel.tsx +485 -0
- package/apps/octogent/apps/web/src/components/canvas/CanvasTerminalColumn.tsx +89 -0
- package/apps/octogent/apps/web/src/components/canvas/DeleteAllTerminalsDialog.tsx +221 -0
- package/apps/octogent/apps/web/src/components/canvas/OctopusNode.tsx +307 -0
- package/apps/octogent/apps/web/src/components/canvas/SessionNode.tsx +185 -0
- package/apps/octogent/apps/web/src/components/deck/ActionCards.tsx +118 -0
- package/apps/octogent/apps/web/src/components/deck/AddTentacleForm.tsx +269 -0
- package/apps/octogent/apps/web/src/components/deck/DeckBottomActions.tsx +56 -0
- package/apps/octogent/apps/web/src/components/deck/TentaclePod.tsx +334 -0
- package/apps/octogent/apps/web/src/components/deck/WorkspaceSetupCard.tsx +105 -0
- package/apps/octogent/apps/web/src/components/deck/octopusVisuals.ts +72 -0
- package/apps/octogent/apps/web/src/components/terminalReplay.ts +62 -0
- package/apps/octogent/apps/web/src/components/terminalWheel.ts +54 -0
- package/apps/octogent/apps/web/src/components/ui/ActionButton.tsx +34 -0
- package/apps/octogent/apps/web/src/components/ui/ConfirmationDialog.tsx +86 -0
- package/apps/octogent/apps/web/src/components/ui/MarkdownContent.tsx +43 -0
- package/apps/octogent/apps/web/src/components/ui/SettingsToggle.tsx +34 -0
- package/apps/octogent/apps/web/src/components/ui/StatusBadge.tsx +24 -0
- package/apps/octogent/apps/web/src/main.tsx +17 -0
- package/apps/octogent/apps/web/src/runtime/HttpTerminalSnapshotReader.ts +87 -0
- package/apps/octogent/apps/web/src/runtime/runtimeEndpoints.ts +412 -0
- package/apps/octogent/apps/web/src/styles/chrome-and-buttons.css +272 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-activity.css +358 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-canvas.css +1843 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-code-intel.css +227 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-conversations.css +705 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-deck.css +1524 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-github.css +541 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-monitor.css +595 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-pixpack.css +81 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-prompts.css +474 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-settings.css +207 -0
- package/apps/octogent/apps/web/src/styles/console-chrome-status-nav.css +441 -0
- package/apps/octogent/apps/web/src/styles/console-overrides-telemetry.css +320 -0
- package/apps/octogent/apps/web/src/styles/console-theme-tokens.css +25 -0
- package/apps/octogent/apps/web/src/styles/cortex-theme.css +412 -0
- package/apps/octogent/apps/web/src/styles/foundation.css +100 -0
- package/apps/octogent/apps/web/src/styles/sidebar-and-scrollbars.css +447 -0
- package/apps/octogent/apps/web/src/styles/terminal-and-status.css +356 -0
- package/apps/octogent/apps/web/src/styles.css +25 -0
- package/apps/octogent/apps/web/src/types/ws.d.ts +23 -0
- package/apps/octogent/apps/web/tests/CanvasPrimaryView.test.tsx +347 -0
- package/apps/octogent/apps/web/tests/HttpTerminalSnapshotReader.test.tsx +54 -0
- package/apps/octogent/apps/web/tests/RuntimeStatusStrip.test.tsx +70 -0
- package/apps/octogent/apps/web/tests/Terminal.test.tsx +87 -0
- package/apps/octogent/apps/web/tests/add-tentacle-form.test.tsx +48 -0
- package/apps/octogent/apps/web/tests/app-github-runtime.test.tsx +162 -0
- package/apps/octogent/apps/web/tests/app-monitor-runtime.test.tsx +657 -0
- package/apps/octogent/apps/web/tests/app-shell-navigation.test.tsx +109 -0
- package/apps/octogent/apps/web/tests/app-swarm-refresh.test.tsx +268 -0
- package/apps/octogent/apps/web/tests/app-ui-state-persistence.test.tsx +116 -0
- package/apps/octogent/apps/web/tests/app-workspace-setup.test.tsx +217 -0
- package/apps/octogent/apps/web/tests/canvas-tentacle-panel.test.tsx +195 -0
- package/apps/octogent/apps/web/tests/delete-all-terminals-dialog.test.tsx +76 -0
- package/apps/octogent/apps/web/tests/githubMetrics.test.tsx +52 -0
- package/apps/octogent/apps/web/tests/hotkeys.test.tsx +44 -0
- package/apps/octogent/apps/web/tests/runtimeEndpoints.test.tsx +240 -0
- package/apps/octogent/apps/web/tests/setup.ts +39 -0
- package/apps/octogent/apps/web/tests/tentacle-pod.test.tsx +62 -0
- package/apps/octogent/apps/web/tests/terminalReplay.test.ts +71 -0
- package/apps/octogent/apps/web/tests/terminalState.test.tsx +49 -0
- package/apps/octogent/apps/web/tests/terminalWheel.test.tsx +51 -0
- package/apps/octogent/apps/web/tests/test-utils/appTestHarness.ts +48 -0
- package/apps/octogent/apps/web/tests/uiPrimitives.test.tsx +31 -0
- package/apps/octogent/apps/web/tests/useAgentRuntimeStates.test.tsx +47 -0
- package/apps/octogent/apps/web/tsconfig.json +8 -0
- package/apps/octogent/apps/web/vite.api.bundle.config.mts +32 -0
- package/apps/octogent/apps/web/vite.config.ts +22 -0
- package/apps/octogent/bin/octogent +3 -0
- package/apps/octogent/biome.json +21 -0
- package/apps/octogent/docs/concepts/mental-model.md +79 -0
- package/apps/octogent/docs/concepts/runtime-and-api.md +60 -0
- package/apps/octogent/docs/concepts/tentacles.md +85 -0
- package/apps/octogent/docs/getting-started/installation.md +54 -0
- package/apps/octogent/docs/getting-started/quickstart.md +79 -0
- package/apps/octogent/docs/guides/inter-agent-messaging.md +43 -0
- package/apps/octogent/docs/guides/orchestrating-child-agents.md +49 -0
- package/apps/octogent/docs/guides/working-with-todos.md +56 -0
- package/apps/octogent/docs/index.md +40 -0
- package/apps/octogent/docs/reference/api.md +103 -0
- package/apps/octogent/docs/reference/cli.md +71 -0
- package/apps/octogent/docs/reference/experimental-features.md +28 -0
- package/apps/octogent/docs/reference/filesystem-layout.md +62 -0
- package/apps/octogent/docs/reference/troubleshooting.md +49 -0
- package/apps/octogent/package.json +35 -0
- package/apps/octogent/packages/core/AGENTS.md +31 -0
- package/apps/octogent/packages/core/package.json +12 -0
- package/apps/octogent/packages/core/src/adapters/InMemoryTerminalSnapshotReader.ts +10 -0
- package/apps/octogent/packages/core/src/application/buildTerminalList.ts +13 -0
- package/apps/octogent/packages/core/src/domain/agentRuntime.ts +18 -0
- package/apps/octogent/packages/core/src/domain/channel.ts +8 -0
- package/apps/octogent/packages/core/src/domain/completionSound.ts +14 -0
- package/apps/octogent/packages/core/src/domain/conversation.ts +48 -0
- package/apps/octogent/packages/core/src/domain/deck.ts +33 -0
- package/apps/octogent/packages/core/src/domain/git.ts +32 -0
- package/apps/octogent/packages/core/src/domain/monitor.ts +62 -0
- package/apps/octogent/packages/core/src/domain/setup.ts +27 -0
- package/apps/octogent/packages/core/src/domain/terminal.ts +17 -0
- package/apps/octogent/packages/core/src/domain/uiState.ts +22 -0
- package/apps/octogent/packages/core/src/domain/usage.ts +60 -0
- package/apps/octogent/packages/core/src/index.ts +15 -0
- package/apps/octogent/packages/core/src/ports/TerminalSnapshotReader.ts +5 -0
- package/apps/octogent/packages/core/src/util/typeCoercion.ts +20 -0
- package/apps/octogent/packages/core/tests/buildTerminalList.test.ts +75 -0
- package/apps/octogent/packages/core/tsconfig.json +7 -0
- package/apps/octogent/packages/core/tsconfig.tsbuildinfo +1 -0
- package/apps/octogent/packages/core/vitest.config.ts +7 -0
- package/apps/octogent/pnpm-lock.yaml +3212 -0
- package/apps/octogent/pnpm-workspace.yaml +3 -0
- package/apps/octogent/prompts/meta-prompt-generator.md +223 -0
- package/apps/octogent/prompts/octoboss-clean-contexts.md +30 -0
- package/apps/octogent/prompts/octoboss-reorganize-tentacles.md +29 -0
- package/apps/octogent/prompts/octoboss-reorganize-todos.md +27 -0
- package/apps/octogent/prompts/sandbox-init.md +3 -0
- package/apps/octogent/prompts/swarm-parent.md +83 -0
- package/apps/octogent/prompts/swarm-worker.md +50 -0
- package/apps/octogent/prompts/tentacle-context-init.md +1 -0
- package/apps/octogent/prompts/tentacle-planner.md +110 -0
- package/apps/octogent/prompts/tentacle-reorganize-todos.md +20 -0
- package/apps/octogent/prompts/tentacle-update-tentacle.md +18 -0
- package/apps/octogent/scripts/build-package.mjs +23 -0
- package/apps/octogent/scripts/dev.mjs +158 -0
- package/apps/octogent/scripts/smoke-public-install.mjs +271 -0
- package/apps/octogent/static/images/octogent-header.png +0 -0
- package/apps/octogent/static/images/preview_1.jpg +0 -0
- package/apps/octogent/static/images/preview_2.jpg +0 -0
- package/apps/octogent/static/images/preview_3.jpg +0 -0
- package/apps/octogent/static/images/preview_4.jpg +0 -0
- package/apps/octogent/static/images/preview_5.jpg +0 -0
- package/apps/octogent/static/images/preview_6.jpg +0 -0
- package/apps/octogent/tsconfig.base.json +16 -0
- package/bin/AGI +3 -0
- package/bin/AGI-install-app +71 -0
- package/bin/AGI-ui +16 -0
- package/bin/AGI-voice +15 -0
- package/bin/AGI-web +16 -0
- package/bin/cortex +109 -0
- package/bin/cortex-octogent +99 -0
- package/bin/import-specifier.mjs +13 -0
- package/bin/import-specifier.test.mjs +13 -0
- package/bin/octo +150 -0
- package/dist/cli.mjs +555650 -0
- package/package.json +157 -0
- package/scripts/setup-wizard.ts +390 -0
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { type ClaudeUsageSnapshot, asNumber, asRecord, asString } from "@octogent/core";
|
|
7
|
+
import { logVerbose } from "./logging";
|
|
8
|
+
import { toResetIso } from "./usageUtils";
|
|
9
|
+
|
|
10
|
+
const CLAUDE_CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
|
|
11
|
+
const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
12
|
+
const CLAUDE_OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
13
|
+
const CLAUDE_OAUTH_USAGE_BETA_HEADER = "oauth-2025-04-20";
|
|
14
|
+
|
|
15
|
+
const CLI_PTY_TIMEOUT_MS = 25_000;
|
|
16
|
+
const CLI_PTY_SETTLE_MS = 3_500;
|
|
17
|
+
const CLI_PTY_ENTER_INTERVAL_MS = 600;
|
|
18
|
+
const CLI_PTY_POST_USAGE_GRACE_MS = 2_500;
|
|
19
|
+
const CLI_PTY_READY_DELAY_MS = 900;
|
|
20
|
+
const CLI_PTY_USAGE_RETRY_MS = 3_000;
|
|
21
|
+
const CLI_PTY_COLS = 160;
|
|
22
|
+
const CLI_PTY_ROWS = 50;
|
|
23
|
+
|
|
24
|
+
/** Like core's `asString`, but trims whitespace and rejects empty strings. */
|
|
25
|
+
const asTrimmedString = (value: unknown): string | null => {
|
|
26
|
+
const raw = asString(value);
|
|
27
|
+
if (raw === null) return null;
|
|
28
|
+
const trimmed = raw.trim();
|
|
29
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type { ClaudeUsageSnapshot };
|
|
33
|
+
|
|
34
|
+
type ClaudeUsageStatus = ClaudeUsageSnapshot["status"];
|
|
35
|
+
|
|
36
|
+
type ClaudeOauthCredentials = {
|
|
37
|
+
accessToken: string;
|
|
38
|
+
scopes: string[];
|
|
39
|
+
rateLimitTier: string | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type ClaudeUsageDependencies = {
|
|
43
|
+
now?: () => Date;
|
|
44
|
+
readCredentialsJson?: () => Promise<unknown>;
|
|
45
|
+
fetchImpl?: typeof fetch;
|
|
46
|
+
spawnCliUsage?: () => Promise<string | null>;
|
|
47
|
+
projectStateDir?: string;
|
|
48
|
+
backgroundRefreshOnly?: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const unavailableSnapshot = (
|
|
52
|
+
now: Date,
|
|
53
|
+
message: string,
|
|
54
|
+
status: ClaudeUsageStatus = "unavailable",
|
|
55
|
+
): ClaudeUsageSnapshot => ({
|
|
56
|
+
status,
|
|
57
|
+
fetchedAt: now.toISOString(),
|
|
58
|
+
source: "none",
|
|
59
|
+
message,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const normalizeScopes = (value: unknown): string[] => {
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
return value
|
|
65
|
+
.map((item) => asTrimmedString(item))
|
|
66
|
+
.filter((item): item is string => item !== null);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const scopeString = asTrimmedString(value);
|
|
70
|
+
if (!scopeString) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return scopeString
|
|
75
|
+
.split(/\s+/u)
|
|
76
|
+
.map((item) => item.trim())
|
|
77
|
+
.filter((item) => item.length > 0);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const readClaudeOauthCredentials = (credentialsJson: unknown): ClaudeOauthCredentials | null => {
|
|
81
|
+
const record = asRecord(credentialsJson);
|
|
82
|
+
if (!record) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const oauth = asRecord(record.claudeAiOauth ?? record.claude_ai_oauth);
|
|
87
|
+
if (!oauth) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const accessToken = asTrimmedString(oauth.accessToken ?? oauth.access_token);
|
|
92
|
+
if (!accessToken) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const scopes = normalizeScopes(oauth.scopes ?? oauth.scope);
|
|
97
|
+
const rateLimitTier = asTrimmedString(oauth.rateLimitTier ?? oauth.rate_limit_tier);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
accessToken,
|
|
101
|
+
scopes,
|
|
102
|
+
rateLimitTier,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const resolveUsageWindow = (
|
|
107
|
+
usagePayload: Record<string, unknown>,
|
|
108
|
+
key: "five_hour" | "seven_day" | "seven_day_sonnet" | "seven_day_opus",
|
|
109
|
+
): Record<string, unknown> | null => {
|
|
110
|
+
const directWindow = asRecord(usagePayload[key]);
|
|
111
|
+
if (directWindow) {
|
|
112
|
+
return directWindow;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const rateLimits = asRecord(usagePayload.rate_limits ?? usagePayload.rateLimits);
|
|
116
|
+
return asRecord(rateLimits?.[key]);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const readErrorMessage = (value: unknown): string | null => {
|
|
120
|
+
const payload = asRecord(value);
|
|
121
|
+
if (!payload) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const directMessage = asTrimmedString(payload.message);
|
|
126
|
+
if (directMessage) {
|
|
127
|
+
return directMessage;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const errorPayload = asRecord(payload.error);
|
|
131
|
+
return asTrimmedString(errorPayload?.message);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const readUsageErrorMessage = async (response: Response): Promise<string | null> => {
|
|
135
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
136
|
+
try {
|
|
137
|
+
if (contentType.includes("application/json")) {
|
|
138
|
+
return readErrorMessage((await response.json()) as unknown);
|
|
139
|
+
}
|
|
140
|
+
return asTrimmedString(await response.text());
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const readWindowPercent = (window: Record<string, unknown> | null): number | null =>
|
|
147
|
+
asNumber(window?.used_percent ?? window?.usedPercent ?? window?.utilization);
|
|
148
|
+
|
|
149
|
+
const readWindowResetAt = (window: Record<string, unknown> | null): string | null =>
|
|
150
|
+
toResetIso(window?.reset_at ?? window?.resetAt ?? window?.resets_at);
|
|
151
|
+
|
|
152
|
+
const inferPlanType = (rateLimitTier: string | null): string | null => {
|
|
153
|
+
const tier = rateLimitTier?.toLowerCase() ?? "";
|
|
154
|
+
if (tier.includes("max")) return "Claude Max";
|
|
155
|
+
if (tier.includes("pro")) return "Claude Pro";
|
|
156
|
+
if (tier.includes("team")) return "Claude Team";
|
|
157
|
+
if (tier.includes("enterprise")) return "Claude Enterprise";
|
|
158
|
+
return null;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const mapUsageSnapshot = (
|
|
162
|
+
usageJson: unknown,
|
|
163
|
+
now: Date,
|
|
164
|
+
rateLimitTier: string | null,
|
|
165
|
+
): ClaudeUsageSnapshot => {
|
|
166
|
+
const usagePayload = asRecord(usageJson);
|
|
167
|
+
if (!usagePayload) {
|
|
168
|
+
throw new Error("invalid_usage_payload");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const primaryWindow = resolveUsageWindow(usagePayload, "five_hour");
|
|
172
|
+
const weeklyWindow =
|
|
173
|
+
resolveUsageWindow(usagePayload, "seven_day") ??
|
|
174
|
+
resolveUsageWindow(usagePayload, "seven_day_opus");
|
|
175
|
+
const sonnetWindow = resolveUsageWindow(usagePayload, "seven_day_sonnet");
|
|
176
|
+
|
|
177
|
+
const extraUsage = asRecord(usagePayload.extra_usage ?? usagePayload.extraUsage);
|
|
178
|
+
let extraUsageCostUsed: number | null = null;
|
|
179
|
+
let extraUsageCostLimit: number | null = null;
|
|
180
|
+
if (extraUsage?.is_enabled === true || extraUsage?.isEnabled === true) {
|
|
181
|
+
const rawUsed = asNumber(extraUsage.used_credits ?? extraUsage.usedCredits);
|
|
182
|
+
const rawLimit = asNumber(extraUsage.monthly_limit ?? extraUsage.monthlyLimit);
|
|
183
|
+
if (rawUsed !== null && rawLimit !== null) {
|
|
184
|
+
extraUsageCostUsed = rawUsed / 100;
|
|
185
|
+
extraUsageCostLimit = rawLimit / 100;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
status: "ok",
|
|
191
|
+
fetchedAt: now.toISOString(),
|
|
192
|
+
source: "oauth-api",
|
|
193
|
+
planType:
|
|
194
|
+
asTrimmedString(usagePayload.plan_type ?? usagePayload.planType) ??
|
|
195
|
+
inferPlanType(rateLimitTier),
|
|
196
|
+
primaryUsedPercent: readWindowPercent(primaryWindow),
|
|
197
|
+
primaryResetAt: readWindowResetAt(primaryWindow),
|
|
198
|
+
secondaryUsedPercent: readWindowPercent(weeklyWindow),
|
|
199
|
+
secondaryResetAt: readWindowResetAt(weeklyWindow),
|
|
200
|
+
sonnetUsedPercent: readWindowPercent(sonnetWindow),
|
|
201
|
+
sonnetResetAt: readWindowResetAt(sonnetWindow),
|
|
202
|
+
extraUsageCostUsed,
|
|
203
|
+
extraUsageCostLimit,
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const readKeychainCredentials = (): Promise<string | null> =>
|
|
208
|
+
new Promise((resolve) => {
|
|
209
|
+
if (process.platform !== "darwin") {
|
|
210
|
+
resolve(null);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
execFile(
|
|
215
|
+
"security",
|
|
216
|
+
["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"],
|
|
217
|
+
{ timeout: 5_000 },
|
|
218
|
+
(error, stdout) => {
|
|
219
|
+
if (error || !stdout.trim()) {
|
|
220
|
+
resolve(null);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
resolve(stdout.trim());
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const readDefaultCredentialsJson = async (): Promise<unknown> => {
|
|
229
|
+
const keychainText = await readKeychainCredentials();
|
|
230
|
+
if (keychainText) {
|
|
231
|
+
try {
|
|
232
|
+
return JSON.parse(keychainText) as unknown;
|
|
233
|
+
} catch {
|
|
234
|
+
// keychain data is not valid JSON, fall through to file
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const fileText = await readFile(CLAUDE_CREDENTIALS_PATH, "utf8");
|
|
239
|
+
return JSON.parse(fileText) as unknown;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// CLI PTY usage source — persistent singleton session (like CodexBar)
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
const ANSI_ESCAPE = String.fromCharCode(0x1b);
|
|
247
|
+
const ANSI_CSI_RE = new RegExp(`${ANSI_ESCAPE}\\[[0-?]*[ -/]*[@-~]`, "gu");
|
|
248
|
+
|
|
249
|
+
export const stripAnsiCodes = (text: string): string => text.replace(ANSI_CSI_RE, "");
|
|
250
|
+
|
|
251
|
+
const STOP_NEEDLES = [
|
|
252
|
+
"current week (all models)",
|
|
253
|
+
"current week (opus)",
|
|
254
|
+
"current week (sonnet only)",
|
|
255
|
+
"current week (sonnet)",
|
|
256
|
+
"current session",
|
|
257
|
+
"failed to load usage data",
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const USAGE_COMMAND_NEEDLES = ["/usage", "current week", "current session"];
|
|
261
|
+
|
|
262
|
+
const PERCENT_RE = /(\d{1,3}(?:\.\d+)?)\s*%/u;
|
|
263
|
+
|
|
264
|
+
const USED_KEYWORDS = ["used", "spent", "consumed"];
|
|
265
|
+
const REMAINING_KEYWORDS = ["left", "remaining", "available"];
|
|
266
|
+
const CLI_USAGE_LABEL_GROUPS = [
|
|
267
|
+
["current session"],
|
|
268
|
+
["current week (all models)", "current week (opus)"],
|
|
269
|
+
["current week (sonnet only)", "current week (sonnet)"],
|
|
270
|
+
] as const;
|
|
271
|
+
|
|
272
|
+
type ParsedCliUsage = {
|
|
273
|
+
primaryUsedPercent: number | null;
|
|
274
|
+
secondaryUsedPercent: number | null;
|
|
275
|
+
sonnetUsedPercent: number | null;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const percentFromLine = (line: string): number | null => {
|
|
279
|
+
const match = PERCENT_RE.exec(line);
|
|
280
|
+
if (!match) return null;
|
|
281
|
+
|
|
282
|
+
const percentText = match[1];
|
|
283
|
+
if (!percentText) return null;
|
|
284
|
+
|
|
285
|
+
const raw = Number.parseFloat(percentText);
|
|
286
|
+
const clamped = Math.max(0, Math.min(100, raw));
|
|
287
|
+
const lower = line.toLowerCase();
|
|
288
|
+
const contextStart = Math.max(0, match.index - 16);
|
|
289
|
+
const contextEnd = Math.min(lower.length, match.index + match[0].length + 24);
|
|
290
|
+
const context = lower.slice(contextStart, contextEnd);
|
|
291
|
+
|
|
292
|
+
// "2% used" → store as 2 (already represents usage)
|
|
293
|
+
if (USED_KEYWORDS.some((kw) => context.includes(kw))) {
|
|
294
|
+
return Math.round(clamped * 10) / 10;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// "98% remaining" → convert to used: 100 - 98 = 2
|
|
298
|
+
if (REMAINING_KEYWORDS.some((kw) => context.includes(kw))) {
|
|
299
|
+
return Math.round((100 - clamped) * 10) / 10;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Default: assume it's "used" (Claude CLI convention per screenshot)
|
|
303
|
+
return Math.round(clamped * 10) / 10;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const normalizeCliText = (text: string): string => text.toLowerCase().replace(/\s+/gu, " ");
|
|
307
|
+
|
|
308
|
+
const findLabelMatch = (
|
|
309
|
+
normalizedText: string,
|
|
310
|
+
labelSubstrings: readonly string[],
|
|
311
|
+
): { index: number; label: string } | null => {
|
|
312
|
+
let bestMatch: { index: number; label: string } | null = null;
|
|
313
|
+
|
|
314
|
+
for (const label of labelSubstrings) {
|
|
315
|
+
const index = normalizedText.indexOf(label);
|
|
316
|
+
if (index === -1) continue;
|
|
317
|
+
if (bestMatch === null || index < bestMatch.index) {
|
|
318
|
+
bestMatch = { index, label };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return bestMatch;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const extractLabeledPercent = (
|
|
326
|
+
cleanOutput: string,
|
|
327
|
+
labelSubstrings: readonly string[],
|
|
328
|
+
): number | null => {
|
|
329
|
+
const normalizedText = normalizeCliText(cleanOutput);
|
|
330
|
+
const match = findLabelMatch(normalizedText, labelSubstrings);
|
|
331
|
+
if (!match) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const start = match.index + match.label.length;
|
|
336
|
+
let end = normalizedText.length;
|
|
337
|
+
|
|
338
|
+
for (const labels of CLI_USAGE_LABEL_GROUPS) {
|
|
339
|
+
const nextMatch = findLabelMatch(normalizedText.slice(start), labels);
|
|
340
|
+
if (!nextMatch) continue;
|
|
341
|
+
end = Math.min(end, start + nextMatch.index);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return percentFromLine(normalizedText.slice(start, end));
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
export const parseCliUsageOutput = (rawOutput: string): ParsedCliUsage => {
|
|
348
|
+
const clean = stripAnsiCodes(rawOutput);
|
|
349
|
+
const primaryUsedPercent = extractLabeledPercent(clean, ["current session"]);
|
|
350
|
+
const secondaryUsedPercent = extractLabeledPercent(clean, [
|
|
351
|
+
"current week (all models)",
|
|
352
|
+
"current week (opus)",
|
|
353
|
+
]);
|
|
354
|
+
const sonnetUsedPercent = extractLabeledPercent(clean, [
|
|
355
|
+
"current week (sonnet only)",
|
|
356
|
+
"current week (sonnet)",
|
|
357
|
+
]);
|
|
358
|
+
|
|
359
|
+
return { primaryUsedPercent, secondaryUsedPercent, sonnetUsedPercent };
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const resolveClaudeBinary = (): string | null => {
|
|
363
|
+
try {
|
|
364
|
+
const result = execFileSync("which", ["claude"], {
|
|
365
|
+
timeout: 3_000,
|
|
366
|
+
encoding: "utf8",
|
|
367
|
+
}).trim();
|
|
368
|
+
return result || null;
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const scrubbedEnv = (): Record<string, string> => {
|
|
375
|
+
const env: Record<string, string> = {};
|
|
376
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
377
|
+
if (key === "CLAUDECODE") continue;
|
|
378
|
+
if (key.startsWith("ANTHROPIC_")) continue;
|
|
379
|
+
if (value !== undefined) env[key] = value;
|
|
380
|
+
}
|
|
381
|
+
return env;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// CLI PTY spawn — fresh process each time, results cached
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
let cachedSnapshot: { snapshot: ClaudeUsageSnapshot; fetchedAt: number } | null = null;
|
|
389
|
+
const CACHE_TTL_MS = 300_000;
|
|
390
|
+
let refreshInFlight: Promise<ClaudeUsageSnapshot> | null = null;
|
|
391
|
+
const CLAUDE_USAGE_SNAPSHOT_FILENAME = "claude-usage-snapshot.json";
|
|
392
|
+
|
|
393
|
+
const getCachedOkSnapshot = (): ClaudeUsageSnapshot | null =>
|
|
394
|
+
cachedSnapshot?.snapshot.status === "ok" ? cachedSnapshot.snapshot : null;
|
|
395
|
+
|
|
396
|
+
const resolveSnapshotPath = (projectStateDir: string | undefined): string | null =>
|
|
397
|
+
projectStateDir ? join(projectStateDir, "state", CLAUDE_USAGE_SNAPSHOT_FILENAME) : null;
|
|
398
|
+
|
|
399
|
+
const normalizePersistedSnapshot = (value: unknown): ClaudeUsageSnapshot | null => {
|
|
400
|
+
const record = asRecord(value);
|
|
401
|
+
if (!record) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const status = asTrimmedString(record.status);
|
|
406
|
+
const source = asTrimmedString(record.source);
|
|
407
|
+
const fetchedAt = asTrimmedString(record.fetchedAt);
|
|
408
|
+
if (status !== "ok" || (source !== "cli-pty" && source !== "oauth-api") || fetchedAt === null) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
status,
|
|
414
|
+
source,
|
|
415
|
+
fetchedAt,
|
|
416
|
+
message: asTrimmedString(record.message),
|
|
417
|
+
planType: asTrimmedString(record.planType),
|
|
418
|
+
primaryUsedPercent: asNumber(record.primaryUsedPercent),
|
|
419
|
+
primaryResetAt: asTrimmedString(record.primaryResetAt),
|
|
420
|
+
secondaryUsedPercent: asNumber(record.secondaryUsedPercent),
|
|
421
|
+
secondaryResetAt: asTrimmedString(record.secondaryResetAt),
|
|
422
|
+
sonnetUsedPercent: asNumber(record.sonnetUsedPercent),
|
|
423
|
+
sonnetResetAt: asTrimmedString(record.sonnetResetAt),
|
|
424
|
+
extraUsageCostUsed: asNumber(record.extraUsageCostUsed),
|
|
425
|
+
extraUsageCostLimit: asNumber(record.extraUsageCostLimit),
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const readPersistedOkSnapshot = async (
|
|
430
|
+
projectStateDir: string | undefined,
|
|
431
|
+
): Promise<ClaudeUsageSnapshot | null> => {
|
|
432
|
+
const snapshotPath = resolveSnapshotPath(projectStateDir);
|
|
433
|
+
if (!snapshotPath) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const raw = await readFile(snapshotPath, "utf8");
|
|
439
|
+
return normalizePersistedSnapshot(JSON.parse(raw) as unknown);
|
|
440
|
+
} catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const persistOkSnapshot = async (
|
|
446
|
+
snapshot: ClaudeUsageSnapshot,
|
|
447
|
+
projectStateDir: string | undefined,
|
|
448
|
+
): Promise<void> => {
|
|
449
|
+
if (snapshot.status !== "ok") {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const snapshotPath = resolveSnapshotPath(projectStateDir);
|
|
454
|
+
if (!snapshotPath) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
await mkdir(dirname(snapshotPath), { recursive: true });
|
|
460
|
+
await writeFile(snapshotPath, JSON.stringify(snapshot), "utf8");
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.warn(
|
|
463
|
+
`[claude-usage] unable to persist snapshot: ${error instanceof Error ? error.message : String(error)}`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const cacheOkSnapshot = async (
|
|
469
|
+
snapshot: ClaudeUsageSnapshot,
|
|
470
|
+
projectStateDir: string | undefined,
|
|
471
|
+
): Promise<ClaudeUsageSnapshot> => {
|
|
472
|
+
cachedSnapshot = { snapshot, fetchedAt: Date.now() };
|
|
473
|
+
await persistOkSnapshot(snapshot, projectStateDir);
|
|
474
|
+
return snapshot;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// Patterns that indicate the CLI welcome screen has fully rendered.
|
|
478
|
+
// After ANSI stripping, cursor-movement codes collapse spaces, so we
|
|
479
|
+
// match with all whitespace removed (e.g. "tipsforgettingstarted").
|
|
480
|
+
const READY_NEEDLES = [
|
|
481
|
+
"tipsforgettingstarted",
|
|
482
|
+
"recentactivity",
|
|
483
|
+
"welcomeback",
|
|
484
|
+
"whatcanihelpyouwith",
|
|
485
|
+
];
|
|
486
|
+
|
|
487
|
+
const isClaudeCliReady = (normalized: string, collapsed: string): boolean => {
|
|
488
|
+
if (READY_NEEDLES.some((needle) => collapsed.includes(needle))) {
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Claude Code v2.1.x can land directly on the shell prompt instead of the
|
|
493
|
+
// older welcome copy. In that mode we see the product header and a visible
|
|
494
|
+
// prompt glyph, but none of the historical ready markers.
|
|
495
|
+
return collapsed.includes("claudecodev") && normalized.includes("❯");
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const spawnCliAndCapture = (binary: string): Promise<string | null> =>
|
|
499
|
+
new Promise<string | null>((resolve) => {
|
|
500
|
+
import("node-pty")
|
|
501
|
+
.then((pty) => {
|
|
502
|
+
let buffer = "";
|
|
503
|
+
let usageBuffer = "";
|
|
504
|
+
let done = false;
|
|
505
|
+
let phase: "waiting" | "capturing" = "waiting";
|
|
506
|
+
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
507
|
+
let enterTimer: ReturnType<typeof setInterval> | null = null;
|
|
508
|
+
let usageRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
509
|
+
let usageSentAt = 0;
|
|
510
|
+
let usageSendCount = 0;
|
|
511
|
+
|
|
512
|
+
const term = pty.spawn(binary, ["--allowed-tools", ""], {
|
|
513
|
+
name: "xterm-256color",
|
|
514
|
+
cols: CLI_PTY_COLS,
|
|
515
|
+
rows: CLI_PTY_ROWS,
|
|
516
|
+
env: scrubbedEnv(),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const finish = (result: string | null) => {
|
|
520
|
+
if (done) return;
|
|
521
|
+
done = true;
|
|
522
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
523
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
524
|
+
if (enterTimer) clearInterval(enterTimer);
|
|
525
|
+
if (usageRetryTimer) clearTimeout(usageRetryTimer);
|
|
526
|
+
try {
|
|
527
|
+
term.kill();
|
|
528
|
+
} catch {
|
|
529
|
+
/* already dead */
|
|
530
|
+
}
|
|
531
|
+
resolve(result);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const deadlineTimer = setTimeout(() => {
|
|
535
|
+
finish(usageBuffer.length > 0 ? usageBuffer : buffer.length > 0 ? buffer : null);
|
|
536
|
+
}, CLI_PTY_TIMEOUT_MS);
|
|
537
|
+
|
|
538
|
+
const sendUsageCommand = () => {
|
|
539
|
+
if (phase === "waiting") {
|
|
540
|
+
phase = "capturing";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Capture only the latest /usage render, not the startup shell.
|
|
544
|
+
usageBuffer = "";
|
|
545
|
+
usageSentAt = Date.now();
|
|
546
|
+
usageSendCount += 1;
|
|
547
|
+
logVerbose("[claude-usage] CLI ready, sending /usage");
|
|
548
|
+
try {
|
|
549
|
+
term.write("/usage\r");
|
|
550
|
+
} catch {
|
|
551
|
+
finish(null);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// Periodic Enter presses to refresh TUI render
|
|
555
|
+
enterTimer = setInterval(() => {
|
|
556
|
+
try {
|
|
557
|
+
term.write("\r");
|
|
558
|
+
} catch {
|
|
559
|
+
/* ignore */
|
|
560
|
+
}
|
|
561
|
+
}, CLI_PTY_ENTER_INTERVAL_MS);
|
|
562
|
+
|
|
563
|
+
if (usageRetryTimer) clearTimeout(usageRetryTimer);
|
|
564
|
+
usageRetryTimer = setTimeout(() => {
|
|
565
|
+
const usageCollapsed = stripAnsiCodes(usageBuffer).toLowerCase().replace(/\s+/gu, "");
|
|
566
|
+
const sawStopNeedle = STOP_NEEDLES.some((needle) =>
|
|
567
|
+
usageCollapsed.includes(needle.replace(/\s+/gu, "")),
|
|
568
|
+
);
|
|
569
|
+
if (!done && usageSendCount < 2 && !sawStopNeedle) {
|
|
570
|
+
logVerbose("[claude-usage] CLI usage view did not render yet, retrying /usage");
|
|
571
|
+
sendUsageCommand();
|
|
572
|
+
}
|
|
573
|
+
}, CLI_PTY_USAGE_RETRY_MS);
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
term.onData((data: string) => {
|
|
577
|
+
buffer += data;
|
|
578
|
+
if (phase === "capturing") {
|
|
579
|
+
usageBuffer += data;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const normalized = stripAnsiCodes(buffer).toLowerCase();
|
|
583
|
+
|
|
584
|
+
const collapsed = normalized.replace(/\s+/gu, "");
|
|
585
|
+
|
|
586
|
+
// Handle trust prompts
|
|
587
|
+
if (collapsed.includes("doyoutrust")) {
|
|
588
|
+
try {
|
|
589
|
+
term.write("y\r");
|
|
590
|
+
} catch {
|
|
591
|
+
/* ignore */
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Phase 1: wait for welcome screen to render, then send /usage
|
|
597
|
+
if (phase === "waiting") {
|
|
598
|
+
if (isClaudeCliReady(normalized, collapsed)) {
|
|
599
|
+
phase = "capturing";
|
|
600
|
+
usageBuffer = "";
|
|
601
|
+
setTimeout(() => {
|
|
602
|
+
if (!done && phase === "capturing" && usageSendCount === 0) {
|
|
603
|
+
sendUsageCommand();
|
|
604
|
+
}
|
|
605
|
+
}, CLI_PTY_READY_DELAY_MS);
|
|
606
|
+
}
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Phase 2: capturing /usage output — look for stop needles
|
|
611
|
+
const usageCollapsed = stripAnsiCodes(usageBuffer).toLowerCase().replace(/\s+/gu, "");
|
|
612
|
+
const sawUsageCommand = USAGE_COMMAND_NEEDLES.some((needle) =>
|
|
613
|
+
usageCollapsed.includes(needle.replace(/\s+/gu, "")),
|
|
614
|
+
);
|
|
615
|
+
const usageGraceElapsed = Date.now() - usageSentAt >= CLI_PTY_POST_USAGE_GRACE_MS;
|
|
616
|
+
if (
|
|
617
|
+
!settleTimer &&
|
|
618
|
+
usageGraceElapsed &&
|
|
619
|
+
sawUsageCommand &&
|
|
620
|
+
STOP_NEEDLES.some((n) => usageCollapsed.includes(n.replace(/\s+/gu, "")))
|
|
621
|
+
) {
|
|
622
|
+
settleTimer = setTimeout(() => finish(usageBuffer), CLI_PTY_SETTLE_MS);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
term.onExit(() =>
|
|
627
|
+
finish(usageBuffer.length > 0 ? usageBuffer : buffer.length > 0 ? buffer : null),
|
|
628
|
+
);
|
|
629
|
+
})
|
|
630
|
+
.catch(() => resolve(null));
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const spawnDefaultCliUsage = async (): Promise<string | null> => {
|
|
634
|
+
const binary = resolveClaudeBinary();
|
|
635
|
+
if (!binary) return null;
|
|
636
|
+
return spawnCliAndCapture(binary);
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
/** Exported for testing — resets the snapshot cache. */
|
|
640
|
+
export const resetCliSession = (): void => {
|
|
641
|
+
cachedSnapshot = null;
|
|
642
|
+
refreshInFlight = null;
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
/** Clears the cached usage snapshot so the next read triggers a fresh fetch. */
|
|
646
|
+
export const invalidateUsageCache = (): void => {
|
|
647
|
+
cachedSnapshot = null;
|
|
648
|
+
refreshInFlight = null;
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const readOauthUsageSnapshot = async (
|
|
652
|
+
now: Date,
|
|
653
|
+
readCredentialsJson: () => Promise<unknown>,
|
|
654
|
+
fetchImpl: typeof fetch,
|
|
655
|
+
): Promise<ClaudeUsageSnapshot> => {
|
|
656
|
+
let credentialsJson: unknown;
|
|
657
|
+
try {
|
|
658
|
+
credentialsJson = await readCredentialsJson();
|
|
659
|
+
} catch (error) {
|
|
660
|
+
const errorCode =
|
|
661
|
+
typeof error === "object" && error && "code" in error ? String(error.code) : "";
|
|
662
|
+
if (errorCode === "ENOENT") {
|
|
663
|
+
return unavailableSnapshot(now, "Claude credentials not found. Run `claude login`.");
|
|
664
|
+
}
|
|
665
|
+
return unavailableSnapshot(now, "Unable to read Claude credentials.", "error");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const oauthCredentials = readClaudeOauthCredentials(credentialsJson);
|
|
669
|
+
if (!oauthCredentials) {
|
|
670
|
+
return unavailableSnapshot(now, "Claude OAuth access token is missing. Re-run `claude login`.");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (!oauthCredentials.scopes.includes("user:profile")) {
|
|
674
|
+
return unavailableSnapshot(
|
|
675
|
+
now,
|
|
676
|
+
"Claude OAuth credentials are missing the required `user:profile` scope. Re-run `claude login`.",
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
const usageResponse = await fetchImpl(CLAUDE_OAUTH_USAGE_URL, {
|
|
682
|
+
method: "GET",
|
|
683
|
+
headers: {
|
|
684
|
+
Authorization: `Bearer ${oauthCredentials.accessToken}`,
|
|
685
|
+
"anthropic-beta": CLAUDE_OAUTH_USAGE_BETA_HEADER,
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
if (usageResponse.status === 401 || usageResponse.status === 403) {
|
|
690
|
+
return unavailableSnapshot(
|
|
691
|
+
now,
|
|
692
|
+
"Claude OAuth token is expired or unauthorized. Re-run `claude login`.",
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (!usageResponse.ok) {
|
|
697
|
+
const usageErrorMessage = await readUsageErrorMessage(usageResponse);
|
|
698
|
+
if (usageResponse.status === 429) {
|
|
699
|
+
const retryAfterSeconds = asTrimmedString(usageResponse.headers.get("retry-after"));
|
|
700
|
+
const retrySuffix =
|
|
701
|
+
retryAfterSeconds && retryAfterSeconds.length > 0
|
|
702
|
+
? ` Retry after ${retryAfterSeconds}s.`
|
|
703
|
+
: "";
|
|
704
|
+
return unavailableSnapshot(
|
|
705
|
+
now,
|
|
706
|
+
usageErrorMessage ?? `Claude OAuth usage API is rate limited.${retrySuffix}`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return unavailableSnapshot(
|
|
711
|
+
now,
|
|
712
|
+
usageErrorMessage
|
|
713
|
+
? `${usageErrorMessage} (HTTP ${usageResponse.status}).`
|
|
714
|
+
: `Claude OAuth usage request failed (HTTP ${usageResponse.status}).`,
|
|
715
|
+
"error",
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const usageJson = (await usageResponse.json()) as unknown;
|
|
720
|
+
return mapUsageSnapshot(usageJson, now, oauthCredentials.rateLimitTier);
|
|
721
|
+
} catch {
|
|
722
|
+
return unavailableSnapshot(now, "Unable to read Claude usage from OAuth API.", "error");
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
const buildCliSnapshot = (parsed: ParsedCliUsage, now: Date): ClaudeUsageSnapshot => ({
|
|
727
|
+
status: "ok",
|
|
728
|
+
fetchedAt: now.toISOString(),
|
|
729
|
+
source: "cli-pty",
|
|
730
|
+
primaryUsedPercent: parsed.primaryUsedPercent,
|
|
731
|
+
secondaryUsedPercent: parsed.secondaryUsedPercent,
|
|
732
|
+
sonnetUsedPercent: parsed.sonnetUsedPercent,
|
|
733
|
+
primaryResetAt: null,
|
|
734
|
+
secondaryResetAt: null,
|
|
735
|
+
sonnetResetAt: null,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
const cliHasRealData = (parsed: ParsedCliUsage): boolean =>
|
|
739
|
+
parsed.primaryUsedPercent !== null ||
|
|
740
|
+
parsed.secondaryUsedPercent !== null ||
|
|
741
|
+
parsed.sonnetUsedPercent !== null;
|
|
742
|
+
|
|
743
|
+
export const readClaudeOauthUsageSnapshot = async (
|
|
744
|
+
dependencies: ClaudeUsageDependencies = {},
|
|
745
|
+
): Promise<ClaudeUsageSnapshot> => {
|
|
746
|
+
const now = dependencies.now?.() ?? new Date();
|
|
747
|
+
const readCredentialsJson = dependencies.readCredentialsJson ?? readDefaultCredentialsJson;
|
|
748
|
+
const fetchImpl = dependencies.fetchImpl ?? fetch;
|
|
749
|
+
const snapshot = await readOauthUsageSnapshot(now, readCredentialsJson, fetchImpl);
|
|
750
|
+
return snapshot.status === "ok"
|
|
751
|
+
? await cacheOkSnapshot(snapshot, dependencies.projectStateDir)
|
|
752
|
+
: snapshot;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
export const readClaudeCliUsageSnapshot = async (
|
|
756
|
+
dependencies: ClaudeUsageDependencies = {},
|
|
757
|
+
): Promise<ClaudeUsageSnapshot> => {
|
|
758
|
+
const now = dependencies.now?.() ?? new Date();
|
|
759
|
+
const spawnCliUsage = dependencies.spawnCliUsage ?? spawnDefaultCliUsage;
|
|
760
|
+
try {
|
|
761
|
+
const cliOutput = await spawnCliUsage();
|
|
762
|
+
if (cliOutput) {
|
|
763
|
+
const cleaned = stripAnsiCodes(cliOutput);
|
|
764
|
+
logVerbose(`[claude-usage] CLI PTY captured ${cleaned.length} chars`);
|
|
765
|
+
const parsed = parseCliUsageOutput(cliOutput);
|
|
766
|
+
if (cliHasRealData(parsed)) {
|
|
767
|
+
logVerbose(
|
|
768
|
+
`[claude-usage] CLI PTY parsed: session=${parsed.primaryUsedPercent}% week=${parsed.secondaryUsedPercent}% sonnet=${parsed.sonnetUsedPercent}%`,
|
|
769
|
+
);
|
|
770
|
+
return await cacheOkSnapshot(buildCliSnapshot(parsed, now), dependencies.projectStateDir);
|
|
771
|
+
}
|
|
772
|
+
logVerbose(
|
|
773
|
+
`[claude-usage] CLI PTY output had no parseable usage data. First 500 chars:\n${cleaned.slice(0, 500)}`,
|
|
774
|
+
);
|
|
775
|
+
} else {
|
|
776
|
+
logVerbose("[claude-usage] CLI PTY returned null (binary missing or node-pty unavailable)");
|
|
777
|
+
}
|
|
778
|
+
} catch (error) {
|
|
779
|
+
logVerbose(
|
|
780
|
+
`[claude-usage] CLI PTY error: ${error instanceof Error ? error.message : String(error)}`,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return unavailableSnapshot(now, "Claude CLI usage unavailable.", "error");
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const refreshClaudeUsageSnapshot = async (
|
|
788
|
+
dependencies: ClaudeUsageDependencies = {},
|
|
789
|
+
): Promise<ClaudeUsageSnapshot> => {
|
|
790
|
+
const now = dependencies.now?.() ?? new Date();
|
|
791
|
+
|
|
792
|
+
// Prefer the CLI PTY path when it works, since it reflects Claude Code
|
|
793
|
+
// usage directly and avoids OAuth API rate-limit failures.
|
|
794
|
+
const spawnCliUsage = dependencies.spawnCliUsage ?? spawnDefaultCliUsage;
|
|
795
|
+
try {
|
|
796
|
+
const cliOutput = await spawnCliUsage();
|
|
797
|
+
if (cliOutput) {
|
|
798
|
+
const cleaned = stripAnsiCodes(cliOutput);
|
|
799
|
+
logVerbose(`[claude-usage] CLI PTY captured ${cleaned.length} chars`);
|
|
800
|
+
const parsed = parseCliUsageOutput(cliOutput);
|
|
801
|
+
if (cliHasRealData(parsed)) {
|
|
802
|
+
logVerbose(
|
|
803
|
+
`[claude-usage] CLI PTY parsed: session=${parsed.primaryUsedPercent}% week=${parsed.secondaryUsedPercent}% sonnet=${parsed.sonnetUsedPercent}%`,
|
|
804
|
+
);
|
|
805
|
+
return await cacheOkSnapshot(buildCliSnapshot(parsed, now), dependencies.projectStateDir);
|
|
806
|
+
}
|
|
807
|
+
logVerbose(
|
|
808
|
+
`[claude-usage] CLI PTY output had no parseable usage data. First 500 chars:\n${cleaned.slice(0, 500)}`,
|
|
809
|
+
);
|
|
810
|
+
} else {
|
|
811
|
+
logVerbose("[claude-usage] CLI PTY returned null (binary missing or node-pty unavailable)");
|
|
812
|
+
}
|
|
813
|
+
} catch (error) {
|
|
814
|
+
logVerbose(
|
|
815
|
+
`[claude-usage] CLI PTY error: ${error instanceof Error ? error.message : String(error)}`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Fall back to OAuth API when CLI does not yield usable data.
|
|
820
|
+
const readCredentialsJson = dependencies.readCredentialsJson ?? readDefaultCredentialsJson;
|
|
821
|
+
const fetchImpl = dependencies.fetchImpl ?? fetch;
|
|
822
|
+
const oauthSnapshot = await readOauthUsageSnapshot(now, readCredentialsJson, fetchImpl);
|
|
823
|
+
|
|
824
|
+
if (oauthSnapshot.status === "ok") {
|
|
825
|
+
return await cacheOkSnapshot(oauthSnapshot, dependencies.projectStateDir);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const cachedOkSnapshot = getCachedOkSnapshot();
|
|
829
|
+
const oauthReachedApi =
|
|
830
|
+
oauthSnapshot.source === "none" &&
|
|
831
|
+
oauthSnapshot.message != null &&
|
|
832
|
+
!oauthSnapshot.message.includes("not found") &&
|
|
833
|
+
!oauthSnapshot.message.includes("missing") &&
|
|
834
|
+
!oauthSnapshot.message.includes("Re-run");
|
|
835
|
+
|
|
836
|
+
if (oauthReachedApi) {
|
|
837
|
+
logVerbose(`[claude-usage] OAuth API responded with error: ${oauthSnapshot.message}`);
|
|
838
|
+
if (cachedOkSnapshot) {
|
|
839
|
+
return { ...cachedOkSnapshot, fetchedAt: now.toISOString() };
|
|
840
|
+
}
|
|
841
|
+
return oauthSnapshot;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (cachedOkSnapshot) {
|
|
845
|
+
logVerbose(
|
|
846
|
+
`[claude-usage] OAuth unavailable (${oauthSnapshot.message}), serving stale cached snapshot`,
|
|
847
|
+
);
|
|
848
|
+
return { ...cachedOkSnapshot, fetchedAt: now.toISOString() };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return oauthSnapshot;
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const startBackgroundRefresh = (dependencies: ClaudeUsageDependencies = {}): void => {
|
|
855
|
+
if (refreshInFlight) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
refreshInFlight = refreshClaudeUsageSnapshot(dependencies)
|
|
860
|
+
.catch((error) => {
|
|
861
|
+
logVerbose(
|
|
862
|
+
`[claude-usage] background refresh error: ${error instanceof Error ? error.message : String(error)}`,
|
|
863
|
+
);
|
|
864
|
+
return unavailableSnapshot(
|
|
865
|
+
dependencies.now?.() ?? new Date(),
|
|
866
|
+
"Unable to refresh Claude usage in background.",
|
|
867
|
+
"error",
|
|
868
|
+
);
|
|
869
|
+
})
|
|
870
|
+
.finally(() => {
|
|
871
|
+
refreshInFlight = null;
|
|
872
|
+
});
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
export const readClaudeUsageSnapshot = async (
|
|
876
|
+
dependencies: ClaudeUsageDependencies = {},
|
|
877
|
+
): Promise<ClaudeUsageSnapshot> => {
|
|
878
|
+
const now = dependencies.now?.() ?? new Date();
|
|
879
|
+
const backgroundRefreshOnly = dependencies.backgroundRefreshOnly ?? false;
|
|
880
|
+
|
|
881
|
+
// Return cached snapshot if fresh enough (prevents rate-limit storms)
|
|
882
|
+
if (cachedSnapshot && Date.now() - cachedSnapshot.fetchedAt < CACHE_TTL_MS) {
|
|
883
|
+
return { ...cachedSnapshot.snapshot, fetchedAt: now.toISOString() };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (!cachedSnapshot) {
|
|
887
|
+
const persistedSnapshot = await readPersistedOkSnapshot(dependencies.projectStateDir);
|
|
888
|
+
if (persistedSnapshot) {
|
|
889
|
+
cachedSnapshot = { snapshot: persistedSnapshot, fetchedAt: 0 };
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const cachedOkSnapshot = getCachedOkSnapshot();
|
|
894
|
+
if (cachedOkSnapshot) {
|
|
895
|
+
startBackgroundRefresh(dependencies);
|
|
896
|
+
return { ...cachedOkSnapshot, fetchedAt: now.toISOString() };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (backgroundRefreshOnly) {
|
|
900
|
+
startBackgroundRefresh(dependencies);
|
|
901
|
+
return unavailableSnapshot(now, "Claude usage refresh in progress.");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (refreshInFlight) {
|
|
905
|
+
const snapshot = await refreshInFlight;
|
|
906
|
+
return snapshot.status === "ok" ? { ...snapshot, fetchedAt: now.toISOString() } : snapshot;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
refreshInFlight = refreshClaudeUsageSnapshot(dependencies)
|
|
910
|
+
.catch((error) => {
|
|
911
|
+
logVerbose(
|
|
912
|
+
`[claude-usage] refresh error: ${error instanceof Error ? error.message : String(error)}`,
|
|
913
|
+
);
|
|
914
|
+
return unavailableSnapshot(now, "Unable to refresh Claude usage.", "error");
|
|
915
|
+
})
|
|
916
|
+
.finally(() => {
|
|
917
|
+
refreshInFlight = null;
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
const snapshot = await refreshInFlight;
|
|
921
|
+
return snapshot.status === "ok" ? { ...snapshot, fetchedAt: now.toISOString() } : snapshot;
|
|
922
|
+
};
|