diff --git a/.github/github-repo-workflow.json b/.github/github-repo-workflow.json
index cb56c14..e647b9c 100644
--- a/.github/github-repo-workflow.json
+++ b/.github/github-repo-workflow.json
@@ -7,6 +7,7 @@
"productGoals": "docs/product-goals.md",
"architecture": "docs/architecture.md",
"designDirection": "docs/design-direction.md",
+ "localLimitProbe": "docs/local-limit-probe.md",
"providerUsageAccess": "docs/provider-usage-access.md",
"repoSettings": "docs/repo-settings.md"
},
diff --git a/.gitignore b/.gitignore
index 96556d8..ccb9d4a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ DerivedData/
*.xcworkspace/xcuserdata/
.swiftpm/
.idea/
+dist/
diff --git a/Config/ContextPanel.entitlements b/Config/ContextPanel.entitlements
new file mode 100644
index 0000000..5816106
--- /dev/null
+++ b/Config/ContextPanel.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ group.com.shinycomputers.contextpanel
+
+
+
diff --git a/Config/ContextPanelWidget-Info.plist b/Config/ContextPanelWidget-Info.plist
new file mode 100644
index 0000000..6ef3ac5
--- /dev/null
+++ b/Config/ContextPanelWidget-Info.plist
@@ -0,0 +1,27 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/Config/ContextPanelWidget.entitlements b/Config/ContextPanelWidget.entitlements
new file mode 100644
index 0000000..7320a3d
--- /dev/null
+++ b/Config/ContextPanelWidget.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ group.com.shinycomputers.contextpanel
+
+
+
diff --git a/ContextPanel.xcodeproj/project.pbxproj b/ContextPanel.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..459cb4a
--- /dev/null
+++ b/ContextPanel.xcodeproj/project.pbxproj
@@ -0,0 +1,651 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 0BBD1DF433F345E62C6A26EF /* LimitProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */; };
+ 10A2940E7E3FE6E253ECFEDE /* GeminiCodeAssistQuota.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */; };
+ 1DD89F5634070B75C09BDE99 /* ClaudeLocalStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */; };
+ 36A035769881D4A450BA3BD8 /* libContextPanelCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; };
+ 38E9E0A22491522FF4E0A8F4 /* ContextPanelPreviewApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */; };
+ 459C8EC82194CE1A950FE1B5 /* UsageLimit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D459054C90410A784EBD308B /* UsageLimit.swift */; };
+ 47414B1A42BE473BF1644296 /* CodexRateLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */; };
+ 64BA3F57751ADAB181558D58 /* SampleUsageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */; };
+ 85EBA57F3F1633F2D6E54103 /* ContextPanelWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 9C74713AEFFAC6D85E7ACF3F /* FastModeForecast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */; };
+ B6C35EBBE9327327BDCA324F /* SnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D8515D047F5A8117DB762C /* SnapshotStore.swift */; };
+ B83302BDC1EAC3EE45DBE8B8 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */; };
+ BE7F8E276624C8E6D93BC329 /* libContextPanelCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; };
+ BF04DB8945D058C7A63BEAB3 /* ClaudeWebUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */; };
+ C1DF7B59257060AC0A6AF44D /* ContextPanelLocations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */; };
+ C6CA96F5E75E32FCCD329736 /* ContextPanelWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */; };
+ E77C3B3D8985239A004E8CDC /* AccountConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */; };
+ F4B9B4FB75A285E38AE52B1D /* ProviderConnector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 350ED5589FB23C42ED32704A /* ProviderConnector.swift */; };
+ FE093A1996BA546A10A800DA /* ContextPanelWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 32954FE71C65F1FD1E590272 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = EE4B8035BB7A9BD03B668BB8;
+ remoteInfo = ContextPanelCore;
+ };
+ 3C3392E33451B979623C2996 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 92EF616FEBA46FCB02E887BE;
+ remoteInfo = ContextPanelWidgetExtension;
+ };
+ A81BFB53BF2A07F0375FCF7E /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = EE4B8035BB7A9BD03B668BB8;
+ remoteInfo = ContextPanelCore;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 09DAA1F320B84A007C14B253 /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 85EBA57F3F1633F2D6E54103 /* ContextPanelWidgetExtension.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ContextPanelWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiCodeAssistQuota.swift; sourceTree = ""; };
+ 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfigurationStore.swift; sourceTree = ""; };
+ 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleUsageData.swift; sourceTree = ""; };
+ 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelLocations.swift; sourceTree = ""; };
+ 32D8515D047F5A8117DB762C /* SnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotStore.swift; sourceTree = ""; };
+ 350ED5589FB23C42ED32704A /* ProviderConnector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderConnector.swift; sourceTree = ""; };
+ 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelWidgetViews.swift; sourceTree = ""; };
+ 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; };
+ 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeWebUsage.swift; sourceTree = ""; };
+ 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimitProbe.swift; sourceTree = ""; };
+ 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastModeForecast.swift; sourceTree = ""; };
+ 8D6350007C73BEF27D871AB0 /* ContextPanel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ContextPanel.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexRateLimits.swift; sourceTree = ""; };
+ A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelPreviewApp.swift; sourceTree = ""; };
+ D459054C90410A784EBD308B /* UsageLimit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageLimit.swift; sourceTree = ""; };
+ E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelWidget.swift; sourceTree = ""; };
+ E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeLocalStatus.swift; sourceTree = ""; };
+ F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libContextPanelCore.a; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 0A536561059AF7F95E2D3FD7 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 36A035769881D4A450BA3BD8 /* libContextPanelCore.a in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7BFE158EBBBAE213DE50F249 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ BE7F8E276624C8E6D93BC329 /* libContextPanelCore.a in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 45E9D2C527D1EF15E9811358 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 8D6350007C73BEF27D871AB0 /* ContextPanel.app */,
+ 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */,
+ F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 537271134D87C7167DEBA0C1 /* ContextPanelCore */ = {
+ isa = PBXGroup;
+ children = (
+ 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */,
+ E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */,
+ 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */,
+ 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */,
+ 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */,
+ 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */,
+ 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */,
+ 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */,
+ 350ED5589FB23C42ED32704A /* ProviderConnector.swift */,
+ 32D8515D047F5A8117DB762C /* SnapshotStore.swift */,
+ D459054C90410A784EBD308B /* UsageLimit.swift */,
+ 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */,
+ );
+ name = ContextPanelCore;
+ path = Sources/ContextPanelCore;
+ sourceTree = "";
+ };
+ 77BADA15D2FF6ABB6F666F43 = {
+ isa = PBXGroup;
+ children = (
+ 537271134D87C7167DEBA0C1 /* ContextPanelCore */,
+ F71E88199DC89A225C8F07FC /* ContextPanelPreview */,
+ A538E84B22D9D75DE0FD30D4 /* ContextPanelWidget */,
+ 45E9D2C527D1EF15E9811358 /* Products */,
+ );
+ sourceTree = "";
+ };
+ A538E84B22D9D75DE0FD30D4 /* ContextPanelWidget */ = {
+ isa = PBXGroup;
+ children = (
+ E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */,
+ 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */,
+ );
+ name = ContextPanelWidget;
+ path = Sources/ContextPanelWidget;
+ sourceTree = "";
+ };
+ F71E88199DC89A225C8F07FC /* ContextPanelPreview */ = {
+ isa = PBXGroup;
+ children = (
+ A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */,
+ 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */,
+ );
+ name = ContextPanelPreview;
+ path = Sources/ContextPanelPreview;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 6CB2601991081CFCCB5C6E04 /* Build configuration list for PBXNativeTarget "ContextPanelWidgetExtension" */;
+ buildPhases = (
+ 255CF9DC3E13CC5E897143A3 /* Sources */,
+ 7BFE158EBBBAE213DE50F249 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 8361FD30418655D673073A5A /* PBXTargetDependency */,
+ );
+ name = ContextPanelWidgetExtension;
+ packageProductDependencies = (
+ );
+ productName = ContextPanelWidgetExtension;
+ productReference = 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+ EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 9CBD94C5FBD8FC3E4201DB61 /* Build configuration list for PBXNativeTarget "ContextPanelCore" */;
+ buildPhases = (
+ 5CBBB511F795B6E330EACF99 /* Sources */,
+ A6FD12E22FB2385AD98885D1 /* Copy Swift Objective-C Interface Header */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = ContextPanelCore;
+ packageProductDependencies = (
+ );
+ productName = ContextPanelCore;
+ productReference = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */;
+ productType = "com.apple.product-type.library.static";
+ };
+ FF80548BA14604BB9B1E9115 /* ContextPanel */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 54E2B892F7409618FAE8F040 /* Build configuration list for PBXNativeTarget "ContextPanel" */;
+ buildPhases = (
+ 8BE7305E45C25AEF658A9E47 /* Sources */,
+ 0A536561059AF7F95E2D3FD7 /* Frameworks */,
+ 09DAA1F320B84A007C14B253 /* Embed Foundation Extensions */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ D29DE5B6DB7D5B324359BCA0 /* PBXTargetDependency */,
+ 4A468DA4B7F0B61AAE457FF9 /* PBXTargetDependency */,
+ );
+ name = ContextPanel;
+ packageProductDependencies = (
+ );
+ productName = ContextPanel;
+ productReference = 8D6350007C73BEF27D871AB0 /* ContextPanel.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 194387DBD3D0F10E17F284F9 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ 92EF616FEBA46FCB02E887BE = {
+ DevelopmentTeam = MM5YXC7T6E;
+ ProvisioningStyle = Automatic;
+ };
+ EE4B8035BB7A9BD03B668BB8 = {
+ DevelopmentTeam = MM5YXC7T6E;
+ ProvisioningStyle = Automatic;
+ };
+ FF80548BA14604BB9B1E9115 = {
+ DevelopmentTeam = MM5YXC7T6E;
+ ProvisioningStyle = Automatic;
+ };
+ };
+ };
+ buildConfigurationList = 9B6F6DC10F1BF3A02285845F /* Build configuration list for PBXProject "ContextPanel" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = 77BADA15D2FF6ABB6F666F43;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 45E9D2C527D1EF15E9811358 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ FF80548BA14604BB9B1E9115 /* ContextPanel */,
+ EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */,
+ 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ A6FD12E22FB2385AD98885D1 /* Copy Swift Objective-C Interface Header */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "$(DERIVED_SOURCES_DIR)/$(SWIFT_OBJC_INTERFACE_HEADER_NAME)",
+ );
+ name = "Copy Swift Objective-C Interface Header";
+ outputPaths = (
+ "$(BUILT_PRODUCTS_DIR)/include/$(PRODUCT_MODULE_NAME)/$(SWIFT_OBJC_INTERFACE_HEADER_NAME)",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "ditto \"${SCRIPT_INPUT_FILE_0}\" \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 255CF9DC3E13CC5E897143A3 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ FE093A1996BA546A10A800DA /* ContextPanelWidget.swift in Sources */,
+ C6CA96F5E75E32FCCD329736 /* ContextPanelWidgetViews.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 5CBBB511F795B6E330EACF99 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E77C3B3D8985239A004E8CDC /* AccountConfigurationStore.swift in Sources */,
+ 1DD89F5634070B75C09BDE99 /* ClaudeLocalStatus.swift in Sources */,
+ BF04DB8945D058C7A63BEAB3 /* ClaudeWebUsage.swift in Sources */,
+ 47414B1A42BE473BF1644296 /* CodexRateLimits.swift in Sources */,
+ C1DF7B59257060AC0A6AF44D /* ContextPanelLocations.swift in Sources */,
+ 9C74713AEFFAC6D85E7ACF3F /* FastModeForecast.swift in Sources */,
+ 10A2940E7E3FE6E253ECFEDE /* GeminiCodeAssistQuota.swift in Sources */,
+ 0BBD1DF433F345E62C6A26EF /* LimitProbe.swift in Sources */,
+ F4B9B4FB75A285E38AE52B1D /* ProviderConnector.swift in Sources */,
+ B6C35EBBE9327327BDCA324F /* SnapshotStore.swift in Sources */,
+ 459C8EC82194CE1A950FE1B5 /* UsageLimit.swift in Sources */,
+ B83302BDC1EAC3EE45DBE8B8 /* WidgetSnapshot.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 8BE7305E45C25AEF658A9E47 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 38E9E0A22491522FF4E0A8F4 /* ContextPanelPreviewApp.swift in Sources */,
+ 64BA3F57751ADAB181558D58 /* SampleUsageData.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 4A468DA4B7F0B61AAE457FF9 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */;
+ targetProxy = 3C3392E33451B979623C2996 /* PBXContainerItemProxy */;
+ };
+ 8361FD30418655D673073A5A /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */;
+ targetProxy = 32954FE71C65F1FD1E590272 /* PBXContainerItemProxy */;
+ };
+ D29DE5B6DB7D5B324359BCA0 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */;
+ targetProxy = A81BFB53BF2A07F0375FCF7E /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ 04634A97AA8175852ED3CA9D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = Config/ContextPanel.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_CFBundleDisplayName = "Context Panel";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel;
+ PRODUCT_NAME = "Context Panel";
+ SDKROOT = macosx;
+ };
+ name = Release;
+ };
+ 58ADB2EF94022C1803CA1F30 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ COMBINE_HIDPI_IMAGES = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.ContextPanelCore;
+ PRODUCT_NAME = ContextPanelCore;
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ };
+ name = Debug;
+ };
+ 7FFB726DFDD92831614DCE30 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = Config/ContextPanel.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_CFBundleDisplayName = "Context Panel";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel;
+ PRODUCT_NAME = "Context Panel";
+ SDKROOT = macosx;
+ };
+ name = Debug;
+ };
+ 8A5FA37DF23FC4E3C4201B12 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ COMBINE_HIDPI_IMAGES = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.ContextPanelCore;
+ PRODUCT_NAME = ContextPanelCore;
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ };
+ name = Release;
+ };
+ 8FBB69A1251FDC3C8BC250F6 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = Config/ContextPanelWidget.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = "Config/ContextPanelWidget-Info.plist";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel.widget;
+ PRODUCT_NAME = ContextPanelWidgetExtension;
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ };
+ name = Debug;
+ };
+ AF229A6A5953793A6011AFFA /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = MM5YXC7T6E;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 6.0;
+ };
+ name = Debug;
+ };
+ C37F715DCB05E50AB0896150 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = Config/ContextPanelWidget.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = "Config/ContextPanelWidget-Info.plist";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel.widget;
+ PRODUCT_NAME = ContextPanelWidgetExtension;
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ };
+ name = Release;
+ };
+ D8D2A31D229BB9A52739BE36 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = MM5YXC7T6E;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 6.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 54E2B892F7409618FAE8F040 /* Build configuration list for PBXNativeTarget "ContextPanel" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7FFB726DFDD92831614DCE30 /* Debug */,
+ 04634A97AA8175852ED3CA9D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 6CB2601991081CFCCB5C6E04 /* Build configuration list for PBXNativeTarget "ContextPanelWidgetExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8FBB69A1251FDC3C8BC250F6 /* Debug */,
+ C37F715DCB05E50AB0896150 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 9B6F6DC10F1BF3A02285845F /* Build configuration list for PBXProject "ContextPanel" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AF229A6A5953793A6011AFFA /* Debug */,
+ D8D2A31D229BB9A52739BE36 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 9CBD94C5FBD8FC3E4201DB61 /* Build configuration list for PBXNativeTarget "ContextPanelCore" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 58ADB2EF94022C1803CA1F30 /* Debug */,
+ 8A5FA37DF23FC4E3C4201B12 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 194387DBD3D0F10E17F284F9 /* Project object */;
+}
diff --git a/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme b/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme
new file mode 100644
index 0000000..1d06c79
--- /dev/null
+++ b/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Package.swift b/Package.swift
index f771036..38c6cff 100644
--- a/Package.swift
+++ b/Package.swift
@@ -15,6 +15,34 @@ let package = Package(
.executable(
name: "ContextPanelPreview",
targets: ["ContextPanelPreview"]
+ ),
+ .executable(
+ name: "OpenAILimitProbe",
+ targets: ["OpenAILimitProbe"]
+ ),
+ .executable(
+ name: "CodexRateLimitProbe",
+ targets: ["CodexRateLimitProbe"]
+ ),
+ .executable(
+ name: "GeminiQuotaProbe",
+ targets: ["GeminiQuotaProbe"]
+ ),
+ .executable(
+ name: "ClaudeLimitProbe",
+ targets: ["ClaudeLimitProbe"]
+ ),
+ .executable(
+ name: "ClaudeWebUsageProbe",
+ targets: ["ClaudeWebUsageProbe"]
+ ),
+ .executable(
+ name: "SnapshotStoreProbe",
+ targets: ["SnapshotStoreProbe"]
+ ),
+ .executable(
+ name: "ContextPanelWidget",
+ targets: ["ContextPanelWidget"]
)
],
targets: [
@@ -23,6 +51,34 @@ let package = Package(
name: "ContextPanelPreview",
dependencies: ["ContextPanelCore"]
),
+ .executableTarget(
+ name: "OpenAILimitProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "CodexRateLimitProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "GeminiQuotaProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "ClaudeLimitProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "ClaudeWebUsageProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "SnapshotStoreProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "ContextPanelWidget",
+ dependencies: ["ContextPanelCore"]
+ ),
.testTarget(
name: "ContextPanelCoreTests",
dependencies: ["ContextPanelCore"]
diff --git a/README.md b/README.md
index a85a7d6..e1dabde 100644
--- a/README.md
+++ b/README.md
@@ -50,4 +50,82 @@ Useful entry points:
- [Product Goals](docs/product-goals.md)
- [Architecture](docs/architecture.md)
+- [macOS Release Path](docs/release.md)
- [Repository Settings](docs/repo-settings.md)
+
+## Local App Bundle
+
+To build the native macOS app with the embedded WidgetKit extension:
+
+```sh
+xcodegen generate --spec project.yml
+xcodebuild \
+ -project ContextPanel.xcodeproj \
+ -scheme ContextPanel \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ -allowProvisioningUpdates \
+ build
+```
+
+To build a quick launchable macOS app bundle from the SwiftPM app shell:
+
+```sh
+scripts/package-macos-app.sh --output dist --identity auto
+open "dist/Context Panel.app"
+```
+
+When a Developer ID Application identity is available in Keychain, the script
+uses it through `codesign`; otherwise it falls back to ad-hoc signing. This is
+the interim friend-installable path for the app shell only; use the Xcode build
+when testing the widget extension.
+
+## Local Provider Probes
+
+The package includes development probes for validating provider limit signals
+without printing secrets or raw provider responses:
+
+```sh
+swift run CodexRateLimitProbe --auth ~/.codex/auth.json
+GEMINI_OAUTH_CLIENT_ID=... GEMINI_OAUTH_CLIENT_SECRET=... \
+ swift run GeminiQuotaProbe --auth ~/.gemini/oauth_creds.json
+swift run ClaudeLimitProbe
+swift run SnapshotStoreProbe --codex-auth ~/.codex/auth.json --include-claude
+```
+
+The Codex and Gemini probes can return live percent-window quota buckets for
+their respective CLI-backed accounts. The Claude probe intentionally reports
+local auth/subscription metadata and local stats-cache freshness until a Claude
+Code status-line cache has been populated.
+
+To capture Claude subscription usage percentages, configure Claude Code's
+status line to call the helper in this repo. Claude Code sends the helper a JSON
+payload after session responses; the helper stores only five-hour and weekly
+used percentages plus reset timestamps under Context Panel's Application
+Support directory.
+
+```json
+{
+ "statusLine": {
+ "type": "command",
+ "command": "/absolute/path/to/context-panel/scripts/claude-statusline-cache.sh"
+ }
+}
+```
+
+The helper does not store auth tokens, prompts, transcript contents, emails,
+organization IDs, or raw Claude session JSON. Claude Code's non-interactive
+`claude -p` path does not appear to run the status-line hook, so Context Panel
+marks old Claude subscription readings stale instead of treating them as live.
+For Every Code-driven Claude usage, Context Panel also reads `ccusage` aggregate
+block output when available and shows a clearly marked estimated 5-hour token
+window; that estimate is useful for "am I likely to run out soon?" but is not
+Anthropic's official subscription percentage.
+
+For Gemini, use the OAuth client values from the locally installed Gemini CLI;
+they are intentionally not checked into this repository.
+
+The probes call the same `ContextPanelCore` connectors the app will use, so
+passing probe output is also a smoke test for the production connector runtime.
+`SnapshotStoreProbe` additionally writes and reloads the local JSON cache shape
+that the app and widget will consume.
diff --git a/Sources/ClaudeLimitProbe/main.swift b/Sources/ClaudeLimitProbe/main.swift
new file mode 100644
index 0000000..b471d46
--- /dev/null
+++ b/Sources/ClaudeLimitProbe/main.swift
@@ -0,0 +1,104 @@
+import ContextPanelCore
+import Foundation
+
+struct ClaudeProbeError: LocalizedError {
+ let message: String
+
+ var errorDescription: String? { message }
+}
+
+struct ProbeConfiguration {
+ let claudeBinary: String
+ let statsPath: String
+
+ static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration {
+ var claudeBinary = "claude"
+ var statsPath: String?
+ var iterator = arguments.dropFirst().makeIterator()
+
+ while let argument = iterator.next() {
+ switch argument {
+ case "--claude-bin":
+ guard let value = iterator.next() else {
+ throw ClaudeProbeError(message: "--claude-bin requires a path or executable name")
+ }
+ claudeBinary = value
+ case "--stats":
+ guard let value = iterator.next() else {
+ throw ClaudeProbeError(message: "--stats requires a path")
+ }
+ statsPath = value
+ case "--help", "-h":
+ printHelp()
+ Foundation.exit(0)
+ default:
+ throw ClaudeProbeError(message: "unknown argument: \(argument)")
+ }
+ }
+
+ return ProbeConfiguration(
+ claudeBinary: claudeBinary,
+ statsPath: statsPath ?? defaultStatsPath()
+ )
+ }
+
+ private static func defaultStatsPath() -> String {
+ let environment = ProcessInfo.processInfo.environment
+ let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path
+ return "\(home)/.claude/stats-cache.json"
+ }
+
+ private static func printHelp() {
+ print("""
+ Usage: swift run ClaudeLimitProbe [--claude-bin claude] [--stats ~/.claude/stats-cache.json]
+
+ Prints a redacted Claude local status summary. This probe intentionally
+ does not read Keychain secrets, token files, raw transcripts, emails,
+ account IDs, org IDs, or provider response bodies.
+ """)
+ }
+}
+
+@main
+struct ClaudeLimitProbe {
+ static func main() async {
+ do {
+ let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments)
+ let connector = ClaudeLocalStatusConnector(accounts: [
+ ClaudeAccountConfiguration(
+ claudeBinary: configuration.claudeBinary,
+ statsPath: configuration.statsPath
+ )
+ ])
+ let result = await connector.refresh(now: Date())
+ printSummary(result: result, statsPath: configuration.statsPath)
+ } catch {
+ fputs("ClaudeLimitProbe failed: \(error.localizedDescription)\n", stderr)
+ Foundation.exit(1)
+ }
+ }
+
+ private static func printSummary(result: ConnectorRefreshResult, statsPath: String) {
+ print("Claude local status probe")
+ print("stats cache: \(ConnectorRedactor.redactedPath(statsPath))")
+ print("accounts: \(result.reports.count)")
+ print("limits: \(result.snapshot.limits.count)")
+ print("redacted: tokens, Keychain secrets, account identifiers, org identifiers, emails, raw transcripts, raw provider responses")
+ print("")
+
+ for report in result.reports {
+ print("- \(report.accountName): \(report.status.rawValue)")
+ if let errorMessage = report.errorMessage {
+ print(" error: \(errorMessage)")
+ }
+ for limit in report.limits {
+ print(" - \(limit.label): \(limit.status.rawValue)")
+ if let note = limit.note {
+ print(" \(note)")
+ }
+ }
+ }
+ print("official non-interactive subscription percentage: not exposed by Claude Code")
+ print("Every Code estimate: shown when ccusage aggregate block data is available")
+ }
+}
diff --git a/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift
new file mode 100644
index 0000000..aa91d20
--- /dev/null
+++ b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift
@@ -0,0 +1,583 @@
+import ContextPanelCore
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+
+@main
+struct ClaudeWebUsageProbeApp: App {
+ init() {
+ NSApplication.shared.setActivationPolicy(.regular)
+ DispatchQueue.main.async {
+ NSApplication.shared.activate(ignoringOtherApps: true)
+ }
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ ClaudeUsageProbeRootView()
+ .frame(minWidth: 1180, minHeight: 760)
+ }
+ }
+}
+
+struct ClaudeUsageProbeRootView: View {
+ @StateObject private var model = ClaudeUsageProbeModel()
+
+ var body: some View {
+ HStack(spacing: 0) {
+ sidebar
+ .frame(width: 390)
+ .padding(18)
+ .background(Color(nsColor: .controlBackgroundColor))
+
+ Divider()
+
+ ClaudeProbeWebView(model: model)
+ }
+ .fileExporter(
+ isPresented: $model.isExportingReport,
+ document: ProbeReportDocument(report: model.reportMarkdown),
+ contentType: .plainText,
+ defaultFilename: "claude-web-usage-probe-report.md"
+ ) { _ in }
+ }
+
+ private var sidebar: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ header
+ controls
+ status
+ Divider()
+ capturedLimits
+ sanitizedFields
+ Spacer()
+ safetyFooter
+ }
+ }
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Claude Usage Probe")
+ .font(.system(size: 24, weight: .semibold))
+ Text("Log in to Claude in this window, open Usage, then capture official subscription windows from the authenticated page context.")
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var controls: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Button("Open Usage") { model.openUsagePage() }
+ Button("Reload") { model.reload() }
+ Button("Export") { model.exportReport() }
+ }
+
+ HStack {
+ Button("Save Snapshot") { model.saveSnapshot() }
+ .disabled(model.limits.isEmpty)
+ Button("Clear") { model.clear() }
+ }
+ }
+ }
+
+ private var status: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Label(model.statusText, systemImage: model.statusIcon)
+ .font(.system(size: 12, weight: .medium))
+ Text(model.currentURLText)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ .lineLimit(2)
+ }
+ .foregroundStyle(model.hasCapturedUsage ? .primary : .secondary)
+ }
+
+ private var capturedLimits: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Captured windows")
+ .font(.system(size: 12, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(model.limits.count)")
+ .font(.system(.caption, design: .monospaced, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ if model.limits.isEmpty {
+ ContentUnavailableView(
+ "No usage windows yet",
+ systemImage: "gauge.with.dots.needle.67percent",
+ description: Text("Complete Claude login or verification, then wait for the Usage page to load.")
+ )
+ .frame(maxHeight: 220)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(model.limits) { limit in
+ ClaudeUsageLimitRow(limit: limit)
+ }
+ }
+ }
+ .frame(maxHeight: 280)
+ }
+ }
+ }
+
+ private var sanitizedFields: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Sanitized fields")
+ .font(.system(size: 12, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(model.fieldPaths.count)")
+ .font(.system(.caption, design: .monospaced, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ if model.fieldPaths.isEmpty {
+ Text("No Claude usage response fields captured yet.")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(model.fieldPaths.prefix(18), id: \.self) { field in
+ Text(field)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(maxHeight: 160)
+ }
+ }
+ }
+
+ private var safetyFooter: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Label("Login stays inside the visible web session.", systemImage: "person.crop.circle.badge.checkmark")
+ Label("Only percent windows, reset times, and field paths leave the page.", systemImage: "lock.shield")
+ Label("No cookies, auth headers, tokens, local storage, emails, org IDs, or raw bodies are stored.", systemImage: "eye.slash")
+ }
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+}
+
+struct ClaudeUsageLimitRow: View {
+ let limit: UsageLimit
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ HStack(alignment: .firstTextBaseline) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(limit.displayLabel)
+ .font(.system(size: 13, weight: .semibold))
+ Text(limit.contextLabel)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text(percentText)
+ .font(.system(size: 18, weight: .semibold, design: .rounded))
+ }
+
+ ProgressView(value: limit.usageRatio ?? 0)
+ .tint(tint)
+
+ Text(resetText)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(nsColor: .textBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+
+ private var percentText: String {
+ guard let ratio = limit.usageRatio else { return "?" }
+ return "\(Int((ratio * 100).rounded()))%"
+ }
+
+ private var resetText: String {
+ guard let resetsAt = limit.resetsAt else { return "reset unknown" }
+ return "resets " + resetsAt.formatted(date: .abbreviated, time: .shortened)
+ }
+
+ private var tint: Color {
+ switch limit.status {
+ case .limited:
+ .red
+ case .close:
+ .orange
+ case .healthy:
+ .green
+ default:
+ .secondary
+ }
+ }
+}
+
+@MainActor
+final class ClaudeUsageProbeModel: ObservableObject {
+ @Published var limits: [UsageLimit] = []
+ @Published var fieldPaths: [String] = []
+ @Published var statusText = "Waiting for Claude usage response"
+ @Published var statusIcon = "clock"
+ @Published var currentURLText = ""
+ @Published var isExportingReport = false
+
+ private let snapshotStore = JSONSnapshotStore(rootDirectory: ContextPanelLocations.snapshotDirectory())
+
+ private lazy var navigationDelegate = ClaudeUsageNavigationDelegate(owner: self)
+
+ lazy var webView: WKWebView = {
+ let configuration = WKWebViewConfiguration()
+ configuration.websiteDataStore = .default()
+ configuration.userContentController.add(ClaudeUsageScriptHandler(owner: self), name: "claudeUsageProbe")
+ configuration.userContentController.addUserScript(
+ WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false)
+ )
+
+ let view = WKWebView(frame: .zero, configuration: configuration)
+ view.navigationDelegate = navigationDelegate
+ return view
+ }()
+
+ init() {
+ openUsagePage()
+ }
+
+ var hasCapturedUsage: Bool { !limits.isEmpty }
+
+ var reportMarkdown: String {
+ var lines = [
+ "# Claude Web Usage Probe Report",
+ "",
+ "- Captured: \(Date().ISO8601Format())",
+ "- Windows: \(limits.count)",
+ "- Sanitized fields: \(fieldPaths.count)",
+ "",
+ "## Captured Windows",
+ ]
+
+ if limits.isEmpty {
+ lines.append("- No usage windows captured.")
+ } else {
+ for limit in limits {
+ let percent = limit.used.map { "\($0)%" } ?? "unknown"
+ let reset = limit.resetsAt?.ISO8601Format() ?? "unknown reset"
+ lines.append("- \(limit.displayLabel) / \(limit.contextLabel): \(percent), resets \(reset)")
+ }
+ }
+
+ lines.append(contentsOf: [
+ "",
+ "## Sanitized Fields",
+ ])
+
+ if fieldPaths.isEmpty {
+ lines.append("- No field paths captured.")
+ } else {
+ lines.append(contentsOf: fieldPaths.map { "- `\($0)`" })
+ }
+
+ lines.append(contentsOf: [
+ "",
+ "## Redactions",
+ "- cookies",
+ "- authorization headers",
+ "- bearer tokens",
+ "- Keychain credentials",
+ "- OAuth tokens",
+ "- local storage",
+ "- account and organization identifiers",
+ "- emails",
+ "- raw response bodies",
+ "- transcripts and prompt/response content",
+ ])
+
+ return lines.joined(separator: "\n")
+ }
+
+ func openUsagePage() {
+ load("https://claude.ai/settings/usage")
+ }
+
+ func reload() {
+ statusText = "Reloading Claude usage page"
+ statusIcon = "arrow.clockwise"
+ webView.reload()
+ }
+
+ func clear() {
+ limits = []
+ fieldPaths = []
+ statusText = "Waiting for Claude usage response"
+ statusIcon = "clock"
+ }
+
+ func exportReport() {
+ isExportingReport = true
+ }
+
+ func saveSnapshot() {
+ guard !limits.isEmpty else { return }
+ do {
+ try saveCurrentSnapshot()
+ statusText = "Saved sanitized Claude usage snapshot"
+ statusIcon = "checkmark.circle"
+ } catch {
+ statusText = "Save failed: \(error.localizedDescription)"
+ statusIcon = "exclamationmark.triangle"
+ }
+ }
+
+ fileprivate func record(payload: [String: Any]) {
+ let windows = payload["windows"] as? [String: Any] ?? [:]
+ let fields = payload["fields"] as? [String] ?? []
+ let wrapped = ["rate_limits": windows]
+
+ do {
+ let data = try JSONSerialization.data(withJSONObject: wrapped)
+ let parsedLimits = try ClaudeWebUsageParser.usageLimits(
+ from: data,
+ accountID: "claude-web",
+ accountName: "Claude Web",
+ observedAt: Date()
+ )
+ guard !parsedLimits.isEmpty else {
+ statusText = "Usage response found, but no percent windows were present"
+ statusIcon = "questionmark.circle"
+ return
+ }
+
+ limits = parsedLimits
+ fieldPaths = Array(Set(fields)).sorted()
+ saveSnapshotAfterCapture()
+ } catch {
+ statusText = "Capture failed: \(error.localizedDescription)"
+ statusIcon = "exclamationmark.triangle"
+ }
+ }
+
+ private func saveSnapshotAfterCapture() {
+ do {
+ try saveCurrentSnapshot()
+ statusText = "Captured and saved Claude subscription usage"
+ statusIcon = "checkmark.circle.fill"
+ } catch {
+ statusText = "Captured Claude usage; save failed: \(error.localizedDescription)"
+ statusIcon = "exclamationmark.triangle"
+ }
+ }
+
+ private func saveCurrentSnapshot() throws {
+ let report = ProviderConnectorReport(
+ provider: .anthropic,
+ accountID: "claude-web",
+ accountName: "Claude Web",
+ generatedAt: Date(),
+ limits: limits,
+ status: .healthy
+ )
+ try snapshotStore.saveMerged(
+ refreshResult: ConnectorRefreshResult(generatedAt: Date(), reports: [report]),
+ savedAt: Date()
+ )
+ }
+
+ fileprivate func updateCurrentURL(_ url: URL?) {
+ currentURLText = url?.absoluteString ?? ""
+ if let host = url?.host, host.contains("claude.ai"), limits.isEmpty {
+ statusText = "Claude page loaded; waiting for usage API"
+ statusIcon = "network"
+ }
+ }
+
+ private func load(_ rawURL: String) {
+ guard let url = URL(string: rawURL) else { return }
+ statusText = "Opening Claude usage page"
+ statusIcon = "safari"
+ webView.load(URLRequest(url: url))
+ }
+
+ private static let networkProbeScript = #"""
+ (() => {
+ if (window.__contextPanelClaudeUsageProbeInstalled) return;
+ window.__contextPanelClaudeUsageProbeInstalled = true;
+
+ const windowKeys = new Set([
+ 'five_hour',
+ 'seven_day',
+ 'seven_day_opus',
+ 'seven_day_sonnet',
+ 'seven_day_oauth_apps'
+ ]);
+ const fieldKeys = new Set([
+ 'used_percentage',
+ 'remaining_percentage',
+ 'utilization',
+ 'resets_at',
+ 'reset_at'
+ ]);
+
+ function isUsageURL(rawUrl) {
+ try {
+ const url = new URL(rawUrl, window.location.href);
+ return /^\/api\/organizations\/[^/]+\/usage$/.test(url.pathname);
+ } catch (_) {
+ return false;
+ }
+ }
+
+ function sanitizeWindow(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const sanitized = {};
+ for (const key of fieldKeys) {
+ const raw = value[key];
+ if (typeof raw === 'number' || typeof raw === 'string') sanitized[key] = raw;
+ }
+ return Object.keys(sanitized).length ? sanitized : null;
+ }
+
+ function collectWindows(value, out = {}) {
+ if (!value || typeof value !== 'object') return out;
+ if (Array.isArray(value)) {
+ value.slice(0, 3).forEach(item => collectWindows(item, out));
+ return out;
+ }
+ for (const [key, child] of Object.entries(value)) {
+ if (windowKeys.has(key)) {
+ const sanitized = sanitizeWindow(child);
+ if (sanitized) out[key] = sanitized;
+ }
+ collectWindows(child, out);
+ }
+ return out;
+ }
+
+ function collectFields(value, prefix = '', out = new Set()) {
+ if (!value || typeof value !== 'object' || out.size > 80) return out;
+ if (Array.isArray(value)) {
+ value.slice(0, 3).forEach(item => collectFields(item, prefix, out));
+ return out;
+ }
+ for (const [key, child] of Object.entries(value)) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (windowKeys.has(key) || fieldKeys.has(key) || key === 'rate_limits' || key === 'usage') out.add(path);
+ collectFields(child, path, out);
+ }
+ return out;
+ }
+
+ function post(payload) {
+ try { window.webkit.messageHandlers.claudeUsageProbe.postMessage(payload); }
+ catch (_) {}
+ }
+
+ function inspect(url, contentType, text) {
+ if (!isUsageURL(url) || !/json/i.test(contentType || '')) return;
+ try {
+ const parsed = JSON.parse(String(text || ''));
+ const windows = collectWindows(parsed);
+ if (!Object.keys(windows).length) return;
+ post({ windows, fields: Array.from(collectFields(parsed)).slice(0, 80) });
+ } catch (_) {}
+ }
+
+ const originalFetch = window.fetch;
+ if (originalFetch) {
+ window.fetch = async function(input, init) {
+ const response = await originalFetch.apply(this, arguments);
+ try {
+ const clone = response.clone();
+ const url = typeof input === 'string' ? input : (input && input.url) || '';
+ clone.text().then(text => inspect(url, clone.headers.get('content-type') || '', text)).catch(() => {});
+ } catch (_) {}
+ return response;
+ };
+ }
+
+ const originalOpen = XMLHttpRequest.prototype.open;
+ const originalSend = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function(method, url) {
+ this.__cpClaudeUsageUrl = url;
+ return originalOpen.apply(this, arguments);
+ };
+ XMLHttpRequest.prototype.send = function() {
+ this.addEventListener('load', function() {
+ try { inspect(this.__cpClaudeUsageUrl || '', this.getResponseHeader('content-type') || '', this.responseText || ''); }
+ catch (_) {}
+ });
+ return originalSend.apply(this, arguments);
+ };
+ })();
+ """#
+}
+
+final class ClaudeUsageScriptHandler: NSObject, WKScriptMessageHandler {
+ weak var owner: ClaudeUsageProbeModel?
+
+ init(owner: ClaudeUsageProbeModel) {
+ self.owner = owner
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let payload = message.body as? [String: Any] else { return }
+ Task { @MainActor [weak owner = self.owner] in
+ owner?.record(payload: payload)
+ }
+ }
+}
+
+final class ClaudeUsageNavigationDelegate: NSObject, WKNavigationDelegate {
+ weak var owner: ClaudeUsageProbeModel?
+
+ init(owner: ClaudeUsageProbeModel) {
+ self.owner = owner
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ Task { @MainActor [weak webView, weak owner] in
+ owner?.updateCurrentURL(webView?.url)
+ }
+ }
+}
+
+struct ClaudeProbeWebView: NSViewRepresentable {
+ @ObservedObject var model: ClaudeUsageProbeModel
+
+ func makeNSView(context: Context) -> WKWebView {
+ model.webView
+ }
+
+ func updateNSView(_ nsView: WKWebView, context: Context) {}
+}
+
+struct ProbeReportDocument: FileDocument {
+ static var readableContentTypes: [UTType] { [.plainText] }
+
+ var report: String
+
+ init(report: String) {
+ self.report = report
+ }
+
+ init(configuration: ReadConfiguration) throws {
+ report = ""
+ }
+
+ func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
+ FileWrapper(regularFileWithContents: Data(report.utf8))
+ }
+}
diff --git a/Sources/CodexRateLimitProbe/main.swift b/Sources/CodexRateLimitProbe/main.swift
new file mode 100644
index 0000000..f82d9df
--- /dev/null
+++ b/Sources/CodexRateLimitProbe/main.swift
@@ -0,0 +1,114 @@
+import ContextPanelCore
+import Foundation
+
+struct CodexProbeError: LocalizedError {
+ let message: String
+
+ var errorDescription: String? { message }
+}
+
+struct ProbeConfiguration {
+ let authPath: String
+ let endpoint: URL
+
+ static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration {
+ var authPath: String?
+ var endpoint = URL(string: "https://chatgpt.com/backend-api/wham/usage")!
+ var iterator = arguments.dropFirst().makeIterator()
+
+ while let argument = iterator.next() {
+ switch argument {
+ case "--auth":
+ guard let value = iterator.next() else {
+ throw CodexProbeError(message: "--auth requires a path")
+ }
+ authPath = value
+ case "--endpoint":
+ guard let value = iterator.next(), let url = URL(string: value) else {
+ throw CodexProbeError(message: "--endpoint requires an absolute URL")
+ }
+ endpoint = url
+ case "--help", "-h":
+ printHelp()
+ Foundation.exit(0)
+ default:
+ throw CodexProbeError(message: "unknown argument: \(argument)")
+ }
+ }
+
+ return ProbeConfiguration(
+ authPath: authPath ?? defaultAuthPath(),
+ endpoint: endpoint
+ )
+ }
+
+ private static func defaultAuthPath() -> String {
+ let environment = ProcessInfo.processInfo.environment
+ let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path
+ let codexHome = environment["CODEX_HOME"] ?? "\(home)/.codex"
+ return "\(codexHome)/auth.json"
+ }
+
+ private static func printHelp() {
+ print("""
+ Usage: swift run CodexRateLimitProbe [--auth /path/to/auth.json] [--endpoint URL]
+
+ Calls the live Codex usage endpoint directly and prints only a redacted
+ summary of limit buckets, windows, plan type, and credits. Tokens,
+ account identifiers, emails, headers, and raw response bodies are never
+ printed.
+ """)
+ }
+}
+
+@main
+struct CodexRateLimitProbe {
+ static func main() async {
+ do {
+ let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments)
+ let connector = CodexRateLimitConnector(accounts: [
+ CodexAccountConfiguration(authPath: configuration.authPath, endpoint: configuration.endpoint)
+ ])
+ let result = await connector.refresh(now: Date())
+ printSummary(result: result, endpoint: configuration.endpoint, authPath: configuration.authPath)
+ } catch {
+ fputs("CodexRateLimitProbe failed: \(error.localizedDescription)\n", stderr)
+ Foundation.exit(1)
+ }
+ }
+
+ private static func printSummary(result: ConnectorRefreshResult, endpoint: URL, authPath: String) {
+ print("Codex live usage endpoint probe")
+ print("endpoint: \(endpoint.absoluteString)")
+ print("auth: \(redactedAuthPath(authPath))")
+ print("accounts: \(result.reports.count)")
+ print("limits: \(result.snapshot.limits.count)")
+ print("redacted: tokens, account identifiers, emails, headers, raw response bodies")
+ print("")
+
+ for report in result.reports {
+ print("- \(report.accountName): \(report.status.rawValue)")
+ if let errorMessage = report.errorMessage {
+ print(" error: \(errorMessage)")
+ }
+ for limit in report.limits {
+ print(" - \(limit.label): \(format(limit: limit))")
+ }
+ }
+ }
+
+ private static func format(limit: UsageLimit) -> String {
+ let used = limit.used.map { "\($0)% used" } ?? "unknown used"
+ let reset = limit.resetsAt.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown reset"
+ return "\(used) / resets \(reset)"
+ }
+
+ private static func redactedAuthPath(_ path: String) -> String {
+ let expanded = NSString(string: path).expandingTildeInPath
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ if expanded.hasPrefix(home) {
+ return "~" + expanded.dropFirst(home.count)
+ }
+ return URL(fileURLWithPath: expanded).lastPathComponent
+ }
+}
diff --git a/Sources/ContextPanelCore/AccountConfigurationStore.swift b/Sources/ContextPanelCore/AccountConfigurationStore.swift
new file mode 100644
index 0000000..45e0ba1
--- /dev/null
+++ b/Sources/ContextPanelCore/AccountConfigurationStore.swift
@@ -0,0 +1,214 @@
+import Foundation
+
+public enum AccountConnectorKind: String, Codable, Equatable, Sendable {
+ case codexRateLimits
+ case geminiCodeAssist
+ case claudeLocalStatus
+}
+
+public struct LocalProviderAccountConfiguration: Codable, Equatable, Identifiable, Sendable {
+ public let id: String
+ public let provider: Provider
+ public let connectorKind: AccountConnectorKind
+ public var displayName: String
+ public var isEnabled: Bool
+ public var authPath: String?
+ public var commandPath: String?
+ public var statsPath: String?
+ public var oauthClientIDEnvironmentName: String?
+ public var oauthClientSecretEnvironmentName: String?
+
+ public init(
+ id: String,
+ provider: Provider,
+ connectorKind: AccountConnectorKind,
+ displayName: String,
+ isEnabled: Bool = true,
+ authPath: String? = nil,
+ commandPath: String? = nil,
+ statsPath: String? = nil,
+ oauthClientIDEnvironmentName: String? = nil,
+ oauthClientSecretEnvironmentName: String? = nil
+ ) {
+ self.id = id
+ self.provider = provider
+ self.connectorKind = connectorKind
+ self.displayName = displayName
+ self.isEnabled = isEnabled
+ self.authPath = authPath
+ self.commandPath = commandPath
+ self.statsPath = statsPath
+ self.oauthClientIDEnvironmentName = oauthClientIDEnvironmentName
+ self.oauthClientSecretEnvironmentName = oauthClientSecretEnvironmentName
+ }
+}
+
+public struct AccountConfigurationDocument: Codable, Equatable, Sendable {
+ public let schemaVersion: Int
+ public var updatedAt: Date
+ public var accounts: [LocalProviderAccountConfiguration]
+
+ public init(updatedAt: Date, accounts: [LocalProviderAccountConfiguration]) {
+ schemaVersion = 1
+ self.updatedAt = updatedAt
+ self.accounts = accounts
+ }
+}
+
+public struct AccountConfigurationLoadResult: Equatable, Sendable {
+ public let document: AccountConfigurationDocument
+ public let status: UsageStatus
+ public let errorMessage: String?
+
+ public init(document: AccountConfigurationDocument, status: UsageStatus, errorMessage: String? = nil) {
+ self.document = document
+ self.status = status
+ self.errorMessage = errorMessage.map(ConnectorRedactor.redact)
+ }
+}
+
+public struct AccountConfigurationStore: Sendable {
+ public let configurationURL: URL
+
+ public init(configurationURL: URL) {
+ self.configurationURL = configurationURL
+ }
+
+ public func load(now: Date = Date()) -> AccountConfigurationLoadResult {
+ guard FileManager.default.fileExists(atPath: configurationURL.path) else {
+ return AccountConfigurationLoadResult(document: Self.defaultDocument(now: now), status: .unknown)
+ }
+
+ do {
+ let document = try Self.makeDecoder().decode(
+ AccountConfigurationDocument.self,
+ from: try Data(contentsOf: configurationURL)
+ )
+ guard document.schemaVersion == 1 else {
+ throw SnapshotStoreError.unsupportedSchema(version: document.schemaVersion)
+ }
+ return AccountConfigurationLoadResult(document: document, status: .healthy)
+ } catch {
+ return AccountConfigurationLoadResult(
+ document: Self.defaultDocument(now: now),
+ status: .failure,
+ errorMessage: error.localizedDescription
+ )
+ }
+ }
+
+ public func save(_ document: AccountConfigurationDocument) throws {
+ let directory = configurationURL.deletingLastPathComponent()
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let data = try Self.makeEncoder().encode(document)
+ try data.write(to: configurationURL, options: [.atomic])
+ }
+
+ public static func defaultDocument(now: Date = Date()) -> AccountConfigurationDocument {
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ return AccountConfigurationDocument(updatedAt: now, accounts: [
+ LocalProviderAccountConfiguration(
+ id: "openai-codex-default",
+ provider: .openAI,
+ connectorKind: .codexRateLimits,
+ displayName: "Codex",
+ authPath: "\(home)/.codex/auth.json"
+ ),
+ LocalProviderAccountConfiguration(
+ id: "claude-local-default",
+ provider: .anthropic,
+ connectorKind: .claudeLocalStatus,
+ displayName: "Claude",
+ commandPath: "claude",
+ statsPath: "\(home)/.claude/stats-cache.json"
+ ),
+ LocalProviderAccountConfiguration(
+ id: "gemini-code-assist-default",
+ provider: .google,
+ connectorKind: .geminiCodeAssist,
+ displayName: "Gemini",
+ isEnabled: false,
+ authPath: "\(home)/.gemini/oauth_creds.json",
+ oauthClientIDEnvironmentName: "GEMINI_OAUTH_CLIENT_ID",
+ oauthClientSecretEnvironmentName: "GEMINI_OAUTH_CLIENT_SECRET"
+ ),
+ ])
+ }
+
+ private static func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ encoder.dateEncodingStrategy = .iso8601
+ return encoder
+ }
+
+ private static func makeDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
+
+public enum AccountConnectorFactory {
+ public static func connectors(
+ from document: AccountConfigurationDocument,
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ geminiMetadataFileLoader: @escaping @Sendable (String) throws -> String = { path in
+ try String(contentsOfFile: NSString(string: path).expandingTildeInPath, encoding: .utf8)
+ },
+ geminiMetadataFileExists: @escaping @Sendable (String) -> Bool = { path in
+ FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath)
+ },
+ geminiMetadataDirectoryLister: @escaping @Sendable (String) -> [String] = { path in
+ let expanded = NSString(string: path).expandingTildeInPath
+ return (try? FileManager.default.contentsOfDirectory(atPath: expanded).map { "\(expanded)/\($0)" }) ?? []
+ }
+ ) -> [any ProviderConnector] {
+ document.accounts.compactMap { account in
+ guard account.isEnabled else { return nil }
+ switch account.connectorKind {
+ case .codexRateLimits:
+ guard let authPath = account.authPath else { return nil }
+ return CodexRateLimitConnector(accounts: [CodexAccountConfiguration(
+ authPath: authPath,
+ accountName: account.displayName
+ )])
+ case .geminiCodeAssist:
+ guard let authPath = account.authPath else { return nil }
+ let configuredMetadata = geminiMetadata(account: account, environment: environment)
+ let discoveredMetadata = GeminiOAuthClientMetadataDiscovery.discover(
+ environment: environment,
+ fileLoader: geminiMetadataFileLoader,
+ fileExists: geminiMetadataFileExists,
+ directoryLister: geminiMetadataDirectoryLister
+ )
+ guard let metadata = configuredMetadata ?? discoveredMetadata else { return nil }
+ return GeminiCodeAssistConnector(accounts: [GeminiAccountConfiguration(
+ authPath: authPath,
+ accountName: account.displayName,
+ clientID: metadata.clientID,
+ clientSecret: metadata.clientSecret
+ )])
+ case .claudeLocalStatus:
+ return ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration(
+ accountName: account.displayName,
+ claudeBinary: account.commandPath ?? "claude",
+ statsPath: account.statsPath
+ )])
+ }
+ }
+ }
+
+ private static func geminiMetadata(
+ account: LocalProviderAccountConfiguration,
+ environment: [String: String]
+ ) -> GeminiOAuthClientMetadata? {
+ guard
+ let clientIDName = account.oauthClientIDEnvironmentName,
+ let clientSecretName = account.oauthClientSecretEnvironmentName,
+ let clientID = environment[clientIDName], !clientID.isEmpty,
+ let clientSecret = environment[clientSecretName], !clientSecret.isEmpty
+ else { return nil }
+ return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret)
+ }
+}
diff --git a/Sources/ContextPanelCore/ClaudeLocalStatus.swift b/Sources/ContextPanelCore/ClaudeLocalStatus.swift
new file mode 100644
index 0000000..4d484ce
--- /dev/null
+++ b/Sources/ContextPanelCore/ClaudeLocalStatus.swift
@@ -0,0 +1,567 @@
+import Foundation
+
+public struct ClaudeAuthStatus: Codable, Equatable, Sendable {
+ public let loggedIn: Bool
+ public let authMethod: String
+ public let apiProvider: String?
+ public let subscriptionType: String?
+
+ public init(loggedIn: Bool, authMethod: String, apiProvider: String?, subscriptionType: String?) {
+ self.loggedIn = loggedIn
+ self.authMethod = authMethod
+ self.apiProvider = apiProvider
+ self.subscriptionType = subscriptionType
+ }
+
+ public var subscriptionDisplayName: String {
+ guard let subscriptionType, !subscriptionType.isEmpty else { return "Claude" }
+ return "Claude \(subscriptionType.capitalized)"
+ }
+}
+
+public struct ClaudeStatsCacheSummary: Codable, Equatable, Sendable {
+ public let version: Int?
+ public let lastComputedDate: Date?
+ public let totalSessions: Int?
+ public let totalMessages: Int?
+ public let firstSessionDate: Date?
+ public let modelUsageCount: Int
+ public let dailyActivityCount: Int
+ public let rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot?
+
+ public init(
+ version: Int?,
+ lastComputedDate: Date?,
+ totalSessions: Int?,
+ totalMessages: Int?,
+ firstSessionDate: Date?,
+ modelUsageCount: Int,
+ dailyActivityCount: Int,
+ rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? = nil
+ ) {
+ self.version = version
+ self.lastComputedDate = lastComputedDate
+ self.totalSessions = totalSessions
+ self.totalMessages = totalMessages
+ self.firstSessionDate = firstSessionDate
+ self.modelUsageCount = modelUsageCount
+ self.dailyActivityCount = dailyActivityCount
+ self.rateLimitSnapshot = rateLimitSnapshot
+ }
+}
+
+public struct ClaudeUsageBlock: Codable, Equatable, Sendable {
+ public let isActive: Bool
+ public let totalTokens: Int?
+ public let endTime: Date?
+ public let projectedTotalTokens: Int?
+ public let remainingMinutes: Int?
+ public let modelCount: Int
+
+ public init(
+ isActive: Bool,
+ totalTokens: Int?,
+ endTime: Date?,
+ projectedTotalTokens: Int?,
+ remainingMinutes: Int?,
+ modelCount: Int
+ ) {
+ self.isActive = isActive
+ self.totalTokens = totalTokens
+ self.endTime = endTime
+ self.projectedTotalTokens = projectedTotalTokens
+ self.remainingMinutes = remainingMinutes
+ self.modelCount = modelCount
+ }
+}
+
+public struct ClaudeUsageBlocksSummary: Codable, Equatable, Sendable {
+ public let activeBlock: ClaudeUsageBlock?
+ public let completedBlockTokenLimitEstimate: Int?
+
+ public init(activeBlock: ClaudeUsageBlock?, completedBlockTokenLimitEstimate: Int?) {
+ self.activeBlock = activeBlock
+ self.completedBlockTokenLimitEstimate = completedBlockTokenLimitEstimate
+ }
+}
+
+public enum ClaudeUsageBlocksParser {
+ public static func summary(from data: Data) throws -> ClaudeUsageBlocksSummary {
+ let payload = try JSONDecoder.contextPanelFlexibleDates.decode(ClaudeUsageBlocksPayload.self, from: data)
+ let active = payload.blocks.first { $0.isActive }?.usageBlock
+ let completedTokens = payload.blocks
+ .filter { !$0.isActive }
+ .compactMap(\.totalTokens)
+ .filter { $0 > 0 }
+ .sorted()
+ let estimate = completedTokens.isEmpty ? nil : completedTokens[Int(Double(completedTokens.count - 1) * 0.95)]
+ return ClaudeUsageBlocksSummary(activeBlock: active, completedBlockTokenLimitEstimate: estimate)
+ }
+}
+
+public struct ClaudeSubscriptionRateLimitWindow: Codable, Equatable, Sendable {
+ public let label: String
+ public let usedPercent: Double
+ public let resetsAt: Date?
+
+ public init(label: String, usedPercent: Double, resetsAt: Date?) {
+ self.label = label
+ self.usedPercent = max(0, min(usedPercent, 100))
+ self.resetsAt = resetsAt
+ }
+}
+
+public struct ClaudeSubscriptionRateLimitSnapshot: Codable, Equatable, Sendable {
+ public let observedAt: Date
+ public let windows: [ClaudeSubscriptionRateLimitWindow]
+
+ public init(observedAt: Date, windows: [ClaudeSubscriptionRateLimitWindow]) {
+ self.observedAt = observedAt
+ self.windows = windows
+ }
+}
+
+public enum ClaudeAuthStatusParser {
+ public static func status(from data: Data) throws -> ClaudeAuthStatus {
+ let payload = try JSONDecoder().decode(ClaudeAuthStatusPayload.self, from: data)
+ return ClaudeAuthStatus(
+ loggedIn: payload.loggedIn,
+ authMethod: payload.authMethod,
+ apiProvider: payload.apiProvider,
+ subscriptionType: payload.subscriptionType
+ )
+ }
+}
+
+public enum ClaudeStatsCacheParser {
+ public static func summary(from data: Data) throws -> ClaudeStatsCacheSummary {
+ let payload = try JSONDecoder.contextPanelFlexibleDates.decode(ClaudeStatsCachePayload.self, from: data)
+ return ClaudeStatsCacheSummary(
+ version: payload.version,
+ lastComputedDate: payload.lastComputedDate,
+ totalSessions: payload.totalSessions,
+ totalMessages: payload.totalMessages,
+ firstSessionDate: payload.firstSessionDate,
+ modelUsageCount: payload.modelUsage?.count ?? 0,
+ dailyActivityCount: payload.dailyActivity?.count ?? 0,
+ rateLimitSnapshot: payload.rateLimits?.snapshot(
+ observedAt: payload.lastComputedDate ?? Date(timeIntervalSince1970: 0)
+ )
+ )
+ }
+}
+
+public enum ClaudeSubscriptionRateLimitCacheParser {
+ public static func snapshot(from data: Data) throws -> ClaudeSubscriptionRateLimitSnapshot {
+ let payload = try JSONDecoder().decode(ClaudeStatuslineRateLimitPayload.self, from: data)
+ var windows: [ClaudeSubscriptionRateLimitWindow] = []
+ if let fiveHour = payload.rateLimits.fiveHour {
+ windows.append(fiveHour.window(label: "5-hour"))
+ }
+ if let sevenDay = payload.rateLimits.sevenDay {
+ windows.append(sevenDay.window(label: "Weekly"))
+ }
+ return ClaudeSubscriptionRateLimitSnapshot(
+ observedAt: Date(timeIntervalSince1970: TimeInterval(payload.observedAt)),
+ windows: windows
+ )
+ }
+}
+
+public struct ClaudeAccountConfiguration: Equatable, Sendable {
+ public let accountName: String
+ public let claudeBinary: String
+ public let statsPath: String
+ public let rateLimitSnapshotPath: String
+ public let rateLimitSnapshotMaximumAge: TimeInterval
+ public let usageBlocksPath: String?
+
+ public init(
+ accountName: String = "Claude",
+ claudeBinary: String = "claude",
+ statsPath: String? = nil,
+ rateLimitSnapshotPath: String? = nil,
+ rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60,
+ usageBlocksPath: String? = nil
+ ) {
+ self.accountName = accountName
+ self.claudeBinary = claudeBinary
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ self.statsPath = statsPath ?? "\(home)/.claude/stats-cache.json"
+ self.rateLimitSnapshotPath = rateLimitSnapshotPath
+ ?? "\(home)/Library/Application Support/Context Panel/ClaudeRateLimits/statusline-cache.json"
+ self.rateLimitSnapshotMaximumAge = rateLimitSnapshotMaximumAge
+ self.usageBlocksPath = usageBlocksPath
+ ?? "\(home)/Library/Application Support/Context Panel/ClaudeRateLimits/ccusage-blocks-cache.json"
+ }
+}
+
+public struct ClaudeLocalStatusConnector: ProviderConnector {
+ public let provider: Provider = .anthropic
+
+ private let accounts: [ClaudeAccountConfiguration]
+ private let processClient: any ConnectorProcessClient
+ private let fileLoader: @Sendable (String) throws -> Data
+ private let fileExists: @Sendable (String) -> Bool
+
+ public init(
+ accounts: [ClaudeAccountConfiguration],
+ processClient: any ConnectorProcessClient = DefaultConnectorProcessClient(),
+ fileLoader: @escaping @Sendable (String) throws -> Data = { path in
+ try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath))
+ },
+ fileExists: @escaping @Sendable (String) -> Bool = { path in
+ FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath)
+ }
+ ) {
+ self.accounts = accounts
+ self.processClient = processClient
+ self.fileLoader = fileLoader
+ self.fileExists = fileExists
+ }
+
+ public func refresh(now: Date) async -> ConnectorRefreshResult {
+ var reports: [ProviderConnectorReport] = []
+ reports.reserveCapacity(accounts.count)
+ for account in accounts {
+ reports.append(refresh(account: account, now: now))
+ }
+ return ConnectorRefreshResult(generatedAt: now, reports: reports)
+ }
+
+ private func refresh(account: ClaudeAccountConfiguration, now: Date) -> ProviderConnectorReport {
+ let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.rateLimitSnapshotPath)
+
+ do {
+ let authStatus = try loadAuthStatus(claudeBinary: account.claudeBinary)
+ let statsSummary = try loadStatsSummary(path: account.statsPath)
+ let rateLimitSnapshot = try loadRateLimitSnapshot(path: account.rateLimitSnapshotPath)
+ ?? statsSummary?.rateLimitSnapshot
+ let usageBlocksSummary = try loadUsageBlocksSummary(path: account.usageBlocksPath)
+ let limits = claudeLocalStatusLimits(
+ authStatus: authStatus,
+ statsSummary: statsSummary,
+ rateLimitSnapshot: rateLimitSnapshot,
+ rateLimitSnapshotMaximumAge: account.rateLimitSnapshotMaximumAge,
+ usageBlocksSummary: usageBlocksSummary,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ observedAt: now
+ )
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: limits,
+ status: authStatus.loggedIn ? limits.map(\.status).contextPanelWorstStatus : .failure,
+ errorMessage: authStatus.loggedIn ? nil : "Claude CLI is not logged in"
+ )
+ } catch {
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: [],
+ status: .failure,
+ errorMessage: error.localizedDescription
+ )
+ }
+ }
+
+ private func loadAuthStatus(claudeBinary: String) throws -> ClaudeAuthStatus {
+ let result = try processClient.run(executable: claudeBinary, arguments: ["auth", "status", "--json"])
+ guard result.exitCode == 0 else {
+ throw ConnectorError.processFailure(operation: "claude auth status", exitCode: result.exitCode)
+ }
+ return try ClaudeAuthStatusParser.status(from: result.stdout)
+ }
+
+ private func loadStatsSummary(path: String) throws -> ClaudeStatsCacheSummary? {
+ guard fileExists(path) else { return nil }
+ return try ClaudeStatsCacheParser.summary(from: try fileLoader(path))
+ }
+
+ private func loadRateLimitSnapshot(path: String) throws -> ClaudeSubscriptionRateLimitSnapshot? {
+ guard fileExists(path) else { return nil }
+ return try ClaudeSubscriptionRateLimitCacheParser.snapshot(from: try fileLoader(path))
+ }
+
+ private func loadUsageBlocksSummary(path: String?) throws -> ClaudeUsageBlocksSummary? {
+ guard let path, fileExists(path) else { return nil }
+ return try ClaudeUsageBlocksParser.summary(from: try fileLoader(path))
+ }
+}
+
+public func claudeLocalStatusLimits(
+ authStatus: ClaudeAuthStatus,
+ statsSummary: ClaudeStatsCacheSummary?,
+ rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? = nil,
+ rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60,
+ usageBlocksSummary: ClaudeUsageBlocksSummary? = nil,
+ accountID: String,
+ accountName: String,
+ observedAt: Date
+) -> [UsageLimit] {
+ if let estimatedLimit = claudeUsageBlockEstimate(
+ usageBlocksSummary: usageBlocksSummary,
+ authStatus: authStatus,
+ accountID: accountID,
+ accountName: accountName,
+ observedAt: observedAt
+ ) {
+ return [estimatedLimit]
+ }
+
+ if authStatus.loggedIn, let rateLimitSnapshot, !rateLimitSnapshot.windows.isEmpty {
+ let isStale = observedAt.timeIntervalSince(rateLimitSnapshot.observedAt) > rateLimitSnapshotMaximumAge
+ let sourceNote = isStale ? "source: stale Claude Code statusline" : "source: Claude Code statusline"
+ return rateLimitSnapshot.windows.map { window in
+ UsageLimit(
+ provider: .anthropic,
+ accountID: accountID,
+ accountName: accountName,
+ label: "Claude \(window.label)",
+ windowLabel: window.label,
+ modelLabel: authStatus.subscriptionDisplayName,
+ unit: .percent,
+ used: Int(window.usedPercent.rounded()),
+ limit: 100,
+ resetsAt: window.resetsAt,
+ lastUpdatedAt: rateLimitSnapshot.observedAt,
+ confidence: .observed,
+ statusOverride: isStale ? .stale : nil,
+ note: "\(sourceNote); subscription: \(authStatus.subscriptionType ?? "unknown")"
+ )
+ }
+ }
+
+ var noteParts = [
+ "auth: \(authStatus.authMethod)",
+ "provider: \(authStatus.apiProvider ?? "unknown")",
+ "subscription: \(authStatus.subscriptionType ?? "unknown")",
+ "allowance: not exposed by Claude Code",
+ ]
+ if let statsSummary {
+ noteParts.append("sessions: \(statsSummary.totalSessions.map(String.init) ?? "unknown")")
+ noteParts.append("messages: \(statsSummary.totalMessages.map(String.init) ?? "unknown")")
+ } else {
+ noteParts.append("stats cache: absent")
+ }
+
+ return [UsageLimit(
+ provider: .anthropic,
+ accountID: accountID,
+ accountName: accountName,
+ label: "\(authStatus.subscriptionDisplayName) status",
+ modelLabel: "Claude Code",
+ unit: .unknown,
+ used: nil,
+ limit: nil,
+ resetsAt: nil,
+ lastUpdatedAt: statsSummary?.lastComputedDate ?? observedAt,
+ confidence: .observed,
+ statusOverride: authStatus.loggedIn ? .unknown : .failure,
+ note: noteParts.joined(separator: "; ")
+ )]
+}
+
+private func claudeUsageBlockEstimate(
+ usageBlocksSummary: ClaudeUsageBlocksSummary?,
+ authStatus: ClaudeAuthStatus,
+ accountID: String,
+ accountName: String,
+ observedAt: Date
+) -> UsageLimit? {
+ guard
+ authStatus.loggedIn,
+ let activeBlock = usageBlocksSummary?.activeBlock,
+ activeBlock.isActive,
+ let totalTokens = activeBlock.totalTokens,
+ totalTokens > 0
+ else { return nil }
+
+ let estimatedLimit = usageBlocksSummary?.completedBlockTokenLimitEstimate
+ ?? activeBlock.projectedTotalTokens
+ let used: Int?
+ let limit: Int?
+ if let estimatedLimit, estimatedLimit > 0 {
+ used = min(totalTokens, estimatedLimit)
+ limit = estimatedLimit
+ } else {
+ used = nil
+ limit = nil
+ }
+
+ let resetsAt = activeBlock.remainingMinutes.map {
+ observedAt.addingTimeInterval(TimeInterval($0 * 60))
+ } ?? activeBlock.endTime
+ let modelMode = activeBlock.modelCount > 1 ? "mixed models" : "single model"
+
+ return UsageLimit(
+ provider: .anthropic,
+ accountID: accountID,
+ accountName: accountName,
+ label: "Claude 5-hour estimate",
+ windowLabel: "5-hour estimated",
+ modelLabel: authStatus.subscriptionDisplayName,
+ unit: .tokens,
+ used: used,
+ limit: limit,
+ resetsAt: resetsAt,
+ lastUpdatedAt: observedAt,
+ confidence: .estimated,
+ statusOverride: limit == nil ? .unknown : nil,
+ note: "source: ccusage local block estimate from Every Code/Claude sessions; \(modelMode); official subscription percentage unavailable in claude -p"
+ )
+}
+
+private struct ClaudeStatuslineRateLimitPayload: Decodable {
+ let observedAt: Int
+ let rateLimits: ClaudeStatuslineRateLimits
+
+ enum CodingKeys: String, CodingKey {
+ case observedAt = "observed_at"
+ case rateLimits = "rate_limits"
+ }
+}
+
+private struct ClaudeStatuslineRateLimits: Decodable {
+ let fiveHour: ClaudeStatuslineRateLimitWindow?
+ let sevenDay: ClaudeStatuslineRateLimitWindow?
+
+ enum CodingKeys: String, CodingKey {
+ case fiveHour = "five_hour"
+ case sevenDay = "seven_day"
+ }
+
+ func snapshot(observedAt: Date) -> ClaudeSubscriptionRateLimitSnapshot? {
+ var windows: [ClaudeSubscriptionRateLimitWindow] = []
+ if let fiveHour {
+ windows.append(fiveHour.window(label: "5-hour"))
+ }
+ if let sevenDay {
+ windows.append(sevenDay.window(label: "Weekly"))
+ }
+ guard !windows.isEmpty else { return nil }
+ return ClaudeSubscriptionRateLimitSnapshot(observedAt: observedAt, windows: windows)
+ }
+}
+
+private struct ClaudeStatuslineRateLimitWindow: Decodable {
+ let usedPercentage: Double
+ let resetsAt: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case usedPercentage = "used_percentage"
+ case resetsAt = "resets_at"
+ }
+
+ func window(label: String) -> ClaudeSubscriptionRateLimitWindow {
+ ClaudeSubscriptionRateLimitWindow(
+ label: label,
+ usedPercent: usedPercentage,
+ resetsAt: resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }
+ )
+ }
+}
+
+private struct ClaudeAuthStatusPayload: Decodable {
+ let loggedIn: Bool
+ let authMethod: String
+ let apiProvider: String?
+ let subscriptionType: String?
+}
+
+private struct ClaudeStatsCachePayload: Decodable {
+ let version: Int?
+ let lastComputedDate: Date?
+ let dailyActivity: ClaudeCountedCollection?
+ let modelUsage: [String: ClaudeDiscardedValue]?
+ let totalSessions: Int?
+ let totalMessages: Int?
+ let firstSessionDate: Date?
+ let rateLimits: ClaudeStatuslineRateLimits?
+
+ enum CodingKeys: String, CodingKey {
+ case version
+ case lastComputedDate
+ case dailyActivity
+ case modelUsage
+ case totalSessions
+ case totalMessages
+ case firstSessionDate
+ case rateLimits = "rate_limits"
+ }
+}
+
+private struct ClaudeUsageBlocksPayload: Decodable {
+ let blocks: [ClaudeUsageBlockPayload]
+}
+
+private struct ClaudeUsageBlockPayload: Decodable {
+ let isActive: Bool
+ let totalTokens: Int?
+ let endTime: Date?
+ let projection: ClaudeUsageBlockProjection?
+ let models: [String]?
+
+ var usageBlock: ClaudeUsageBlock {
+ ClaudeUsageBlock(
+ isActive: isActive,
+ totalTokens: totalTokens,
+ endTime: endTime,
+ projectedTotalTokens: projection?.totalTokens,
+ remainingMinutes: projection?.remainingMinutes,
+ modelCount: models?.filter { $0 != "" }.count ?? 0
+ )
+ }
+}
+
+private struct ClaudeUsageBlockProjection: Decodable {
+ let totalTokens: Int?
+ let remainingMinutes: Int?
+}
+
+private enum ClaudeCountedCollection: Decodable {
+ case dictionary([String: ClaudeDiscardedValue])
+ case array([ClaudeDiscardedValue])
+
+ var count: Int {
+ switch self {
+ case let .dictionary(values):
+ values.count
+ case let .array(values):
+ values.count
+ }
+ }
+
+ init(from decoder: Decoder) throws {
+ if let dictionary = try? [String: ClaudeDiscardedValue](from: decoder) {
+ self = .dictionary(dictionary)
+ return
+ }
+ self = .array(try [ClaudeDiscardedValue](from: decoder))
+ }
+}
+
+private struct ClaudeDiscardedValue: Decodable {}
+
+extension JSONDecoder {
+ static var contextPanelFlexibleDates: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .custom { decoder in
+ let container = try decoder.singleValueContainer()
+ let value = try container.decode(String.self)
+ if let date = ContextPanelDateFormatting.date(from: value) {
+ return date
+ }
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Expected ISO 8601 date string"
+ )
+ }
+ return decoder
+ }
+}
diff --git a/Sources/ContextPanelCore/ClaudeWebUsage.swift b/Sources/ContextPanelCore/ClaudeWebUsage.swift
new file mode 100644
index 0000000..6a46f04
--- /dev/null
+++ b/Sources/ContextPanelCore/ClaudeWebUsage.swift
@@ -0,0 +1,140 @@
+import Foundation
+
+public enum ClaudeWebUsageParser {
+ public static func usageLimits(
+ from data: Data,
+ accountID: String,
+ accountName: String,
+ observedAt: Date
+ ) throws -> [UsageLimit] {
+ let payload = try JSONSerialization.jsonObject(with: data)
+ guard let root = payload as? [String: Any] else { return [] }
+
+ let windows: [(key: String, label: String, model: String?)] = [
+ ("five_hour", "5-hour", nil),
+ ("seven_day", "7-day", nil),
+ ("seven_day_opus", "7-day", "Opus"),
+ ("seven_day_sonnet", "7-day", "Sonnet"),
+ ("seven_day_oauth_apps", "7-day", "OAuth apps"),
+ ]
+
+ return windows.compactMap { window in
+ guard let object = findObject(named: window.key, in: root) else { return nil }
+ let usedPercentage = percentValue(for: ["used_percentage", "utilization"], in: object)
+ let remainingPercentage = percentValue(for: ["remaining_percentage"], in: object)
+ let used = usedPercentage ?? remainingPercentage.map { max(0, 100 - $0) }
+ guard let used else { return nil }
+
+ let roundedUsed = min(max(Int(used.rounded()), 0), 100)
+ return UsageLimit(
+ id: "anthropic:\(accountID):claude-web:\(window.key)",
+ provider: .anthropic,
+ accountID: accountID,
+ accountName: accountName,
+ label: "Claude \(window.label)",
+ windowLabel: window.label,
+ modelLabel: window.model ?? "Claude subscription",
+ unit: .percent,
+ used: roundedUsed,
+ limit: 100,
+ resetsAt: resetDate(in: object),
+ lastUpdatedAt: observedAt,
+ confidence: .observed,
+ note: "source: Claude web usage endpoint; authenticated web session required"
+ )
+ }
+ }
+
+ public static func sanitizedUsageFields(from data: Data) throws -> [String] {
+ let payload = try JSONSerialization.jsonObject(with: data)
+ return Array(collectUsageFields(payload).sorted())
+ }
+
+ private static func findObject(named key: String, in value: Any) -> [String: Any]? {
+ if let dictionary = value as? [String: Any] {
+ if let found = dictionary[key] as? [String: Any] {
+ return found
+ }
+ for child in dictionary.values {
+ if let found = findObject(named: key, in: child) {
+ return found
+ }
+ }
+ } else if let array = value as? [Any] {
+ for child in array {
+ if let found = findObject(named: key, in: child) {
+ return found
+ }
+ }
+ }
+ return nil
+ }
+
+ private static func percentValue(for keys: [String], in object: [String: Any]) -> Double? {
+ for key in keys {
+ guard let raw = numericValue(object[key]) else { continue }
+ return raw <= 1 ? raw * 100 : raw
+ }
+ return nil
+ }
+
+ private static func resetDate(in object: [String: Any]) -> Date? {
+ guard let raw = object["resets_at"] ?? object["reset_at"] else { return nil }
+ if let number = numericValue(raw) {
+ return Date(timeIntervalSince1970: number > 10_000_000_000 ? number / 1000 : number)
+ }
+ if let string = raw as? String {
+ return ISO8601DateFormatter().date(from: string)
+ }
+ return nil
+ }
+
+ private static func numericValue(_ value: Any?) -> Double? {
+ switch value {
+ case let value as Double:
+ value
+ case let value as Int:
+ Double(value)
+ case let value as NSNumber:
+ value.doubleValue
+ case let value as String:
+ Double(value)
+ default:
+ nil
+ }
+ }
+
+ private static func collectUsageFields(_ value: Any, prefix: String = "", output: Set = []) -> Set {
+ var output = output
+ let allowed = [
+ "five_hour",
+ "seven_day",
+ "seven_day_opus",
+ "seven_day_sonnet",
+ "seven_day_oauth_apps",
+ "used_percentage",
+ "remaining_percentage",
+ "utilization",
+ "resets_at",
+ "reset_at",
+ "rate_limits",
+ "usage",
+ ]
+
+ if let dictionary = value as? [String: Any] {
+ for (key, child) in dictionary {
+ let path = prefix.isEmpty ? key : "\(prefix).\(key)"
+ if allowed.contains(key) {
+ output.insert(path)
+ }
+ output = collectUsageFields(child, prefix: path, output: output)
+ }
+ } else if let array = value as? [Any] {
+ for child in array.prefix(3) {
+ output = collectUsageFields(child, prefix: prefix, output: output)
+ }
+ }
+
+ return output
+ }
+}
diff --git a/Sources/ContextPanelCore/CodexRateLimits.swift b/Sources/ContextPanelCore/CodexRateLimits.swift
new file mode 100644
index 0000000..5c7b06e
--- /dev/null
+++ b/Sources/ContextPanelCore/CodexRateLimits.swift
@@ -0,0 +1,436 @@
+import Foundation
+
+public enum CodexRateLimitReachedType: String, Codable, Equatable, Sendable {
+ case rateLimitReached = "rate_limit_reached"
+ case workspaceOwnerCreditsDepleted = "workspace_owner_credits_depleted"
+ case workspaceMemberCreditsDepleted = "workspace_member_credits_depleted"
+ case workspaceOwnerUsageLimitReached = "workspace_owner_usage_limit_reached"
+ case workspaceMemberUsageLimitReached = "workspace_member_usage_limit_reached"
+ case unknown
+}
+
+public struct CodexRateLimitWindow: Codable, Equatable, Sendable {
+ public let usedPercent: Double
+ public let windowMinutes: Int?
+ public let resetsAt: Date?
+
+ public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?) {
+ self.usedPercent = max(0, min(usedPercent, 100))
+ self.windowMinutes = windowMinutes
+ self.resetsAt = resetsAt
+ }
+}
+
+public struct CodexCreditsSnapshot: Codable, Equatable, Sendable {
+ public let hasCredits: Bool
+ public let unlimited: Bool
+ public let balance: String?
+
+ public init(hasCredits: Bool, unlimited: Bool, balance: String?) {
+ self.hasCredits = hasCredits
+ self.unlimited = unlimited
+ self.balance = balance
+ }
+}
+
+public struct CodexRateLimitSnapshot: Codable, Equatable, Identifiable, Sendable {
+ public let id: String
+ public let limitName: String?
+ public let planType: String
+ public let primary: CodexRateLimitWindow?
+ public let secondary: CodexRateLimitWindow?
+ public let credits: CodexCreditsSnapshot?
+ public let rateLimitReachedType: CodexRateLimitReachedType?
+
+ public init(
+ id: String,
+ limitName: String?,
+ planType: String,
+ primary: CodexRateLimitWindow?,
+ secondary: CodexRateLimitWindow?,
+ credits: CodexCreditsSnapshot?,
+ rateLimitReachedType: CodexRateLimitReachedType?
+ ) {
+ self.id = id
+ self.limitName = limitName
+ self.planType = planType
+ self.primary = primary
+ self.secondary = secondary
+ self.credits = credits
+ self.rateLimitReachedType = rateLimitReachedType
+ }
+
+ public var displayName: String {
+ limitName ?? (id == "codex" ? "Codex" : id)
+ }
+}
+
+public struct CodexAuthTokens: Codable, Equatable, Sendable {
+ public let accessToken: String
+ public let accountID: String?
+ public let idToken: String?
+
+ public init(accessToken: String, accountID: String?, idToken: String?) {
+ self.accessToken = accessToken
+ self.accountID = accountID
+ self.idToken = idToken
+ }
+}
+
+public enum CodexAuthFileParser {
+ public static func tokens(from data: Data) throws -> CodexAuthTokens {
+ let payload = try JSONDecoder().decode(CodexAuthFilePayload.self, from: data)
+ guard let tokens = payload.tokens, !tokens.accessToken.isEmpty else {
+ throw ConnectorError.invalidAuth("auth file does not contain ChatGPT token auth")
+ }
+ return CodexAuthTokens(
+ accessToken: tokens.accessToken,
+ accountID: tokens.accountID,
+ idToken: tokens.idToken
+ )
+ }
+}
+
+public enum CodexAccountIDExtractor {
+ public static func accountID(fromIDToken token: String?) -> String? {
+ guard let token else { return nil }
+ let parts = token.split(separator: ".")
+ guard parts.count >= 2 else { return nil }
+ var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
+ while payload.count % 4 != 0 {
+ payload.append("=")
+ }
+ guard
+ let data = Data(base64Encoded: payload),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let auth = object["https://api.openai.com/auth"] as? [String: Any]
+ else {
+ return nil
+ }
+ return auth["chatgpt_account_id"] as? String
+ }
+}
+
+public struct CodexAccountConfiguration: Equatable, Sendable {
+ public let authPath: String
+ public let accountName: String
+ public let endpoint: URL
+
+ public init(
+ authPath: String,
+ accountName: String? = nil,
+ endpoint: URL = URL(string: "https://chatgpt.com/backend-api/wham/usage")!
+ ) {
+ self.authPath = authPath
+ self.accountName = accountName ?? ConnectorRedactor.redactedPath(authPath)
+ self.endpoint = endpoint
+ }
+}
+
+public struct CodexRateLimitConnector: ProviderConnector {
+ public let provider: Provider = .openAI
+
+ private let accounts: [CodexAccountConfiguration]
+ private let httpClient: any ConnectorHTTPClient
+ private let fileLoader: @Sendable (String) throws -> Data
+
+ public init(
+ accounts: [CodexAccountConfiguration],
+ httpClient: any ConnectorHTTPClient = URLSessionConnectorHTTPClient(),
+ fileLoader: @escaping @Sendable (String) throws -> Data = { path in
+ try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath))
+ }
+ ) {
+ self.accounts = accounts
+ self.httpClient = httpClient
+ self.fileLoader = fileLoader
+ }
+
+ public func refresh(now: Date) async -> ConnectorRefreshResult {
+ var reports: [ProviderConnectorReport] = []
+ reports.reserveCapacity(accounts.count)
+ for account in accounts {
+ reports.append(await refresh(account: account, now: now))
+ }
+ return ConnectorRefreshResult(generatedAt: now, reports: reports)
+ }
+
+ private func refresh(account: CodexAccountConfiguration, now: Date) async -> ProviderConnectorReport {
+ let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.authPath)
+
+ do {
+ let auth = try CodexAuthFileParser.tokens(from: try fileLoader(account.authPath))
+ let data = try await fetchUsage(endpoint: account.endpoint, auth: auth)
+ let snapshots = try CodexUsagePayloadParser.snapshots(from: data)
+ let limits = snapshots.flatMap { snapshot in
+ codexUsageLimits(
+ from: snapshot,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ observedAt: now
+ )
+ }
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: limits
+ )
+ } catch {
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: [],
+ status: .failure,
+ errorMessage: error.localizedDescription
+ )
+ }
+ }
+
+ private func fetchUsage(endpoint: URL, auth: CodexAuthTokens) async throws -> Data {
+ var headers = [
+ "Authorization": "Bearer \(auth.accessToken)",
+ "User-Agent": "context-panel",
+ "Accept": "application/json",
+ ]
+ if let accountID = auth.accountID ?? CodexAccountIDExtractor.accountID(fromIDToken: auth.idToken) {
+ headers["ChatGPT-Account-Id"] = accountID
+ }
+
+ let response = try await httpClient.data(for: ConnectorHTTPRequest(url: endpoint, method: "GET", headers: headers))
+ guard (200..<300).contains(response.statusCode) else {
+ throw ConnectorError.httpFailure(operation: "Codex usage endpoint", statusCode: response.statusCode)
+ }
+ return response.data
+ }
+}
+
+public func codexUsageLimits(
+ from snapshot: CodexRateLimitSnapshot,
+ accountID: String,
+ accountName: String,
+ observedAt: Date
+) -> [UsageLimit] {
+ var limits: [UsageLimit] = []
+ if let primary = snapshot.primary {
+ limits.append(codexUsageLimit(
+ snapshot: snapshot,
+ window: primary,
+ accountID: accountID,
+ accountName: accountName,
+ observedAt: observedAt
+ ))
+ }
+ if let secondary = snapshot.secondary {
+ limits.append(codexUsageLimit(
+ snapshot: snapshot,
+ window: secondary,
+ accountID: accountID,
+ accountName: accountName,
+ observedAt: observedAt
+ ))
+ }
+ return limits
+}
+
+public enum CodexUsagePayloadParser {
+ public static func snapshots(from data: Data) throws -> [CodexRateLimitSnapshot] {
+ let payload = try JSONDecoder().decode(CodexUsagePayload.self, from: data)
+ return snapshots(from: payload)
+ }
+
+ private static func snapshots(from payload: CodexUsagePayload) -> [CodexRateLimitSnapshot] {
+ var snapshots = [
+ CodexRateLimitSnapshot(
+ id: "codex",
+ limitName: nil,
+ planType: payload.planType,
+ primary: payload.rateLimit?.primaryWindow?.normalizedWindow,
+ secondary: payload.rateLimit?.secondaryWindow?.normalizedWindow,
+ credits: payload.credits?.normalizedCredits,
+ rateLimitReachedType: payload.rateLimitReachedType?.normalizedKind
+ )
+ ]
+
+ snapshots.append(contentsOf: payload.additionalRateLimits.map { additional in
+ CodexRateLimitSnapshot(
+ id: additional.meteredFeature,
+ limitName: additional.limitName,
+ planType: payload.planType,
+ primary: additional.rateLimit?.primaryWindow?.normalizedWindow,
+ secondary: additional.rateLimit?.secondaryWindow?.normalizedWindow,
+ credits: nil,
+ rateLimitReachedType: nil
+ )
+ })
+
+ return snapshots
+ }
+}
+
+private struct CodexUsagePayload: Decodable {
+ let planType: String
+ let rateLimit: CodexRateLimitDetails?
+ let credits: CodexCreditsDetails?
+ let additionalRateLimits: [CodexAdditionalRateLimitDetails]
+ let rateLimitReachedType: CodexReachedType?
+
+ enum CodingKeys: String, CodingKey {
+ case planType = "plan_type"
+ case rateLimit = "rate_limit"
+ case credits
+ case additionalRateLimits = "additional_rate_limits"
+ case rateLimitReachedType = "rate_limit_reached_type"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ planType = try container.decodeIfPresent(String.self, forKey: .planType) ?? "unknown"
+ rateLimit = try container.decodeIfPresent(CodexRateLimitDetails.self, forKey: .rateLimit)
+ credits = try container.decodeIfPresent(CodexCreditsDetails.self, forKey: .credits)
+ additionalRateLimits = try container.decodeIfPresent([CodexAdditionalRateLimitDetails].self, forKey: .additionalRateLimits) ?? []
+ rateLimitReachedType = try container.decodeIfPresent(CodexReachedType.self, forKey: .rateLimitReachedType)
+ }
+}
+
+private struct CodexAuthFilePayload: Decodable {
+ let tokens: CodexAuthTokenPayload?
+}
+
+private struct CodexAuthTokenPayload: Decodable {
+ let accessToken: String
+ let accountID: String?
+ let idToken: String?
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case accountID = "account_id"
+ case idToken = "id_token"
+ }
+}
+
+private struct CodexRateLimitDetails: Decodable {
+ let primaryWindow: CodexWindowSnapshot?
+ let secondaryWindow: CodexWindowSnapshot?
+
+ enum CodingKeys: String, CodingKey {
+ case primaryWindow = "primary_window"
+ case secondaryWindow = "secondary_window"
+ }
+}
+
+private struct CodexWindowSnapshot: Decodable {
+ let usedPercent: Double
+ let limitWindowSeconds: Int?
+ let resetAt: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case usedPercent = "used_percent"
+ case limitWindowSeconds = "limit_window_seconds"
+ case resetAt = "reset_at"
+ }
+
+ var normalizedWindow: CodexRateLimitWindow {
+ CodexRateLimitWindow(
+ usedPercent: usedPercent,
+ windowMinutes: limitWindowSeconds.map { max(($0 + 59) / 60, 0) },
+ resetsAt: resetAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }
+ )
+ }
+}
+
+private struct CodexCreditsDetails: Decodable {
+ let hasCredits: Bool
+ let unlimited: Bool
+ let balance: String?
+
+ enum CodingKeys: String, CodingKey {
+ case hasCredits = "has_credits"
+ case unlimited
+ case balance
+ }
+
+ var normalizedCredits: CodexCreditsSnapshot {
+ CodexCreditsSnapshot(hasCredits: hasCredits, unlimited: unlimited, balance: balance)
+ }
+}
+
+private struct CodexAdditionalRateLimitDetails: Decodable {
+ let limitName: String
+ let meteredFeature: String
+ let rateLimit: CodexRateLimitDetails?
+
+ enum CodingKeys: String, CodingKey {
+ case limitName = "limit_name"
+ case meteredFeature = "metered_feature"
+ case rateLimit = "rate_limit"
+ }
+}
+
+private struct CodexReachedType: Decodable {
+ let kind: CodexRateLimitReachedType
+
+ enum CodingKeys: String, CodingKey {
+ case kind = "type"
+ }
+
+ var normalizedKind: CodexRateLimitReachedType {
+ kind
+ }
+}
+
+private func codexUsageLimit(
+ snapshot: CodexRateLimitSnapshot,
+ window: CodexRateLimitWindow,
+ accountID: String,
+ accountName: String,
+ observedAt: Date
+) -> UsageLimit {
+ let windowLabel = window.windowMinutes.map(codexWindowLabel(minutes:)) ?? "Rolling"
+ return UsageLimit(
+ provider: .openAI,
+ accountID: accountID,
+ accountName: accountName,
+ label: "\(snapshot.displayName) \(windowLabel)",
+ windowLabel: windowLabel,
+ modelLabel: snapshot.displayName,
+ unit: .percent,
+ used: Int(window.usedPercent.rounded()),
+ limit: 100,
+ resetsAt: window.resetsAt,
+ lastUpdatedAt: observedAt,
+ confidence: .observed,
+ note: "plan: \(snapshot.planType)"
+ )
+}
+
+private func codexWindowLabel(minutes: Int) -> String {
+ switch minutes {
+ case 0..<60:
+ return "\(minutes)m"
+ case 60:
+ return "Hourly"
+ case 300:
+ return "5-hour"
+ case 1_440:
+ return "Daily"
+ case 7_200:
+ return "5-day"
+ case 10_080:
+ return "Weekly"
+ default:
+ if minutes.isMultiple(of: 10_080) {
+ return "\(minutes / 10_080)-week"
+ }
+ if minutes.isMultiple(of: 1_440) {
+ return "\(minutes / 1_440)-day"
+ }
+ if minutes.isMultiple(of: 60) {
+ return "\(minutes / 60)-hour"
+ }
+ return "\(minutes)m"
+ }
+}
diff --git a/Sources/ContextPanelCore/ContextPanelLocations.swift b/Sources/ContextPanelCore/ContextPanelLocations.swift
new file mode 100644
index 0000000..85709f3
--- /dev/null
+++ b/Sources/ContextPanelCore/ContextPanelLocations.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+public enum ContextPanelLocations {
+ public static let appGroupID = "group.com.shinycomputers.contextpanel"
+
+ public static func applicationSupportDirectory() -> URL {
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support")
+ return base.appending(path: "Context Panel", directoryHint: .isDirectory)
+ }
+
+ public static func snapshotDirectory(appGroupID: String? = nil) -> URL {
+ if
+ let appGroupID,
+ let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
+ {
+ return containerURL
+ .appending(path: "Context Panel", directoryHint: .isDirectory)
+ .appending(path: "Snapshots", directoryHint: .isDirectory)
+ }
+
+ return applicationSupportDirectory()
+ .appending(path: "Snapshots", directoryHint: .isDirectory)
+ }
+
+ public static func accountConfigurationURL() -> URL {
+ applicationSupportDirectory().appending(path: "accounts.json")
+ }
+}
diff --git a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift
new file mode 100644
index 0000000..e134c98
--- /dev/null
+++ b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift
@@ -0,0 +1,446 @@
+import Foundation
+
+public struct GeminiQuotaBucket: Codable, Equatable, Identifiable, Sendable {
+ public let id: String
+ public let modelID: String
+ public let remainingFraction: Double?
+ public let remainingAmount: Int?
+ public let resetsAt: Date?
+
+ public init(modelID: String, remainingFraction: Double?, remainingAmount: Int?, resetsAt: Date?) {
+ self.id = modelID
+ self.modelID = modelID
+ self.remainingFraction = remainingFraction.map { max(0, min($0, 1)) }
+ self.remainingAmount = remainingAmount
+ self.resetsAt = resetsAt
+ }
+
+ public var usedPercent: Double? {
+ remainingFraction.map { max(0, min((1 - $0) * 100, 100)) }
+ }
+
+ public func usageLimit(accountID: String, accountName: String, observedAt: Date) -> UsageLimit {
+ UsageLimit(
+ provider: .google,
+ accountID: accountID,
+ accountName: accountName,
+ label: modelID,
+ windowLabel: resetWindowLabel,
+ modelLabel: modelID,
+ unit: .percent,
+ used: usedPercent.map { Int($0.rounded()) },
+ limit: usedPercent == nil ? nil : 100,
+ resetsAt: resetsAt,
+ lastUpdatedAt: observedAt,
+ confidence: .observed,
+ note: remainingAmount.map { "remaining amount: \($0)" }
+ )
+ }
+
+ private var resetWindowLabel: String? {
+ guard let resetsAt else { return nil }
+ let seconds = Int(resetsAt.timeIntervalSince(Date()))
+ if seconds <= 0 { return nil }
+ let hours = max(Int((Double(seconds) / 3_600).rounded()), 1)
+ if hours <= 2 { return "Hourly" }
+ if hours <= 7 { return "5-hour" }
+ if hours <= 30 { return "Daily" }
+ if hours <= 132 { return "5-day" }
+ if hours <= 180 { return "Weekly" }
+ return nil
+ }
+}
+
+public enum GeminiQuotaPayloadParser {
+ public static func buckets(from data: Data) throws -> [GeminiQuotaBucket] {
+ let payload = try JSONDecoder.contextPanelISO8601.decode(GeminiQuotaPayload.self, from: data)
+ return payload.buckets.map(\.normalizedBucket)
+ }
+}
+
+public struct GeminiOAuthCredentials: Codable, Equatable, Sendable {
+ public let accessToken: String?
+ public let refreshToken: String?
+
+ public init(accessToken: String?, refreshToken: String?) {
+ self.accessToken = accessToken
+ self.refreshToken = refreshToken
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case refreshToken = "refresh_token"
+ }
+}
+
+public struct GeminiRefreshResponse: Decodable, Equatable, Sendable {
+ public let accessToken: String
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ }
+}
+
+public struct GeminiCodeAssistTier: Decodable, Equatable, Sendable {
+ public let name: String?
+}
+
+public struct GeminiLoadCodeAssistResponse: Decodable, Equatable, Sendable {
+ public let cloudaicompanionProject: String?
+ public let currentTier: GeminiCodeAssistTier?
+ public let paidTier: GeminiCodeAssistTier?
+}
+
+public struct GeminiAccountConfiguration: Equatable, Sendable {
+ public let authPath: String
+ public let accountName: String
+ public let tokenEndpoint: URL
+ public let codeAssistEndpoint: URL
+ public let clientID: String
+ public let clientSecret: String
+
+ public init(
+ authPath: String,
+ accountName: String? = nil,
+ tokenEndpoint: URL = URL(string: "https://oauth2.googleapis.com/token")!,
+ codeAssistEndpoint: URL = URL(string: "https://cloudcode-pa.googleapis.com/v1internal")!,
+ clientID: String,
+ clientSecret: String
+ ) {
+ self.authPath = authPath
+ self.accountName = accountName ?? ConnectorRedactor.redactedPath(authPath)
+ self.tokenEndpoint = tokenEndpoint
+ self.codeAssistEndpoint = codeAssistEndpoint
+ self.clientID = clientID
+ self.clientSecret = clientSecret
+ }
+}
+
+public struct GeminiOAuthClientMetadata: Equatable, Sendable {
+ public let clientID: String
+ public let clientSecret: String
+
+ public init(clientID: String, clientSecret: String) {
+ self.clientID = clientID
+ self.clientSecret = clientSecret
+ }
+}
+
+public enum GeminiOAuthClientMetadataDiscovery {
+ public static func discover(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ fileLoader: @escaping @Sendable (String) throws -> String = { path in
+ try String(contentsOfFile: NSString(string: path).expandingTildeInPath, encoding: .utf8)
+ },
+ fileExists: @escaping @Sendable (String) -> Bool = { path in
+ FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath)
+ },
+ directoryLister: @escaping @Sendable (String) -> [String] = { path in
+ let expanded = NSString(string: path).expandingTildeInPath
+ return (try? FileManager.default.contentsOfDirectory(atPath: expanded).map { "\(expanded)/\($0)" }) ?? []
+ }
+ ) -> GeminiOAuthClientMetadata? {
+ if
+ let clientID = environment["GEMINI_OAUTH_CLIENT_ID"], !clientID.isEmpty,
+ let clientSecret = environment["GEMINI_OAUTH_CLIENT_SECRET"], !clientSecret.isEmpty
+ {
+ return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret)
+ }
+
+ for path in candidateBundlePaths(
+ environment: environment,
+ directoryLister: directoryLister
+ ) where fileExists(path) {
+ guard
+ let source = try? fileLoader(path),
+ let metadata = parseClientMetadata(from: source)
+ else { continue }
+ return metadata
+ }
+ return nil
+ }
+
+ static func parseClientMetadata(from source: String) -> GeminiOAuthClientMetadata? {
+ guard
+ let clientID = stringLiteral(named: "OAUTH_CLIENT_ID", in: source),
+ let clientSecret = stringLiteral(named: "OAUTH_CLIENT_SECRET", in: source)
+ else { return nil }
+ return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret)
+ }
+
+ private static func stringLiteral(named variableName: String, in source: String) -> String? {
+ let pattern = #"var\s+\#(variableName)\s*=\s*\"([^\"]+)\""#
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
+ let range = NSRange(source.startIndex..= 2,
+ let valueRange = Range(match.range(at: 1), in: source)
+ else { return nil }
+ return String(source[valueRange])
+ }
+
+ private static func candidateBundlePaths(
+ environment: [String: String],
+ directoryLister: @Sendable (String) -> [String]
+ ) -> [String] {
+ var paths: [String] = []
+ if let path = environment["GEMINI_CLI_BUNDLE_PATH"], !path.isEmpty {
+ paths.append(path)
+ }
+ paths.append(contentsOf: bundleChunkPaths(
+ root: "/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle",
+ directoryLister: directoryLister
+ ))
+ paths.append(contentsOf: bundleChunkPaths(
+ root: "/usr/local/lib/node_modules/@google/gemini-cli/bundle",
+ directoryLister: directoryLister
+ ))
+ return paths
+ }
+
+ private static func bundleChunkPaths(
+ root: String,
+ directoryLister: @Sendable (String) -> [String]
+ ) -> [String] {
+ directoryLister(root)
+ .filter { $0.hasSuffix(".js") }
+ .sorted()
+ }
+}
+
+public struct GeminiCodeAssistConnector: ProviderConnector {
+ public let provider: Provider = .google
+
+ private let accounts: [GeminiAccountConfiguration]
+ private let httpClient: any ConnectorHTTPClient
+ private let fileLoader: @Sendable (String) throws -> Data
+
+ public init(
+ accounts: [GeminiAccountConfiguration],
+ httpClient: any ConnectorHTTPClient = URLSessionConnectorHTTPClient(),
+ fileLoader: @escaping @Sendable (String) throws -> Data = { path in
+ try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath))
+ }
+ ) {
+ self.accounts = accounts
+ self.httpClient = httpClient
+ self.fileLoader = fileLoader
+ }
+
+ public func refresh(now: Date) async -> ConnectorRefreshResult {
+ var reports: [ProviderConnectorReport] = []
+ reports.reserveCapacity(accounts.count)
+ for account in accounts {
+ reports.append(await refresh(account: account, now: now))
+ }
+ return ConnectorRefreshResult(generatedAt: now, reports: reports)
+ }
+
+ private func refresh(account: GeminiAccountConfiguration, now: Date) async -> ProviderConnectorReport {
+ let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.authPath)
+
+ do {
+ let credentials = try JSONDecoder().decode(GeminiOAuthCredentials.self, from: try fileLoader(account.authPath))
+ let accessToken = try await refreshedAccessToken(credentials: credentials, account: account)
+ let loadResponse = try await loadCodeAssist(accessToken: accessToken, endpoint: account.codeAssistEndpoint)
+ guard let project = loadResponse.cloudaicompanionProject, !project.isEmpty else {
+ throw ConnectorError.decodingFailure("Code Assist did not return an active project; raw body redacted")
+ }
+ let quotaData = try await retrieveUserQuota(
+ accessToken: accessToken,
+ project: project,
+ endpoint: account.codeAssistEndpoint
+ )
+ let buckets = try GeminiQuotaPayloadParser.buckets(from: quotaData)
+ let limits = buckets.map {
+ $0.usageLimit(accountID: localAccountID, accountName: account.accountName, observedAt: now)
+ }
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: limits
+ )
+ } catch {
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: [],
+ status: .failure,
+ errorMessage: error.localizedDescription
+ )
+ }
+ }
+
+ private func refreshedAccessToken(credentials: GeminiOAuthCredentials, account: GeminiAccountConfiguration) async throws -> String {
+ guard let refreshToken = credentials.refreshToken, !refreshToken.isEmpty else {
+ throw ConnectorError.invalidAuth("Gemini OAuth file does not contain a refresh token")
+ }
+ let body = formEncoded([
+ "client_id": account.clientID,
+ "client_secret": account.clientSecret,
+ "refresh_token": refreshToken,
+ "grant_type": "refresh_token",
+ ])
+ let response = try await httpClient.data(for: ConnectorHTTPRequest(
+ url: account.tokenEndpoint,
+ method: "POST",
+ headers: [
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ ],
+ body: body
+ ))
+ guard (200..<300).contains(response.statusCode) else {
+ throw ConnectorError.httpFailure(operation: "Gemini OAuth refresh", statusCode: response.statusCode)
+ }
+ return try JSONDecoder().decode(GeminiRefreshResponse.self, from: response.data).accessToken
+ }
+
+ private func loadCodeAssist(accessToken: String, endpoint: URL) async throws -> GeminiLoadCodeAssistResponse {
+ let body = try JSONSerialization.data(withJSONObject: [
+ "cloudaicompanionProject": NSNull(),
+ "metadata": [
+ "ideType": "IDE_UNSPECIFIED",
+ "platform": "PLATFORM_UNSPECIFIED",
+ "pluginType": "GEMINI",
+ "duetProject": NSNull(),
+ ],
+ ])
+ let response = try await httpClient.data(for: ConnectorHTTPRequest(
+ url: endpoint.appending(path: ":loadCodeAssist"),
+ method: "POST",
+ headers: jsonHeaders(accessToken: accessToken),
+ body: body
+ ))
+ guard (200..<300).contains(response.statusCode) else {
+ throw ConnectorError.httpFailure(operation: "Gemini Code Assist load", statusCode: response.statusCode)
+ }
+ return try JSONDecoder().decode(GeminiLoadCodeAssistResponse.self, from: response.data)
+ }
+
+ private func retrieveUserQuota(accessToken: String, project: String, endpoint: URL) async throws -> Data {
+ let body = try JSONSerialization.data(withJSONObject: ["project": project])
+ let response = try await httpClient.data(for: ConnectorHTTPRequest(
+ url: endpoint.appending(path: ":retrieveUserQuota"),
+ method: "POST",
+ headers: jsonHeaders(accessToken: accessToken),
+ body: body
+ ))
+ guard (200..<300).contains(response.statusCode) else {
+ throw ConnectorError.httpFailure(operation: "Gemini Code Assist quota", statusCode: response.statusCode)
+ }
+ return response.data
+ }
+}
+
+private struct GeminiQuotaPayload: Decodable {
+ let buckets: [GeminiQuotaBucketPayload]
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ buckets = try container.decodeIfPresent([GeminiQuotaBucketPayload].self, forKey: .buckets) ?? []
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case buckets
+ }
+}
+
+private struct GeminiQuotaBucketPayload: Decodable {
+ let modelID: String
+ let remainingFraction: Double?
+ let remainingAmount: Int?
+ let resetTime: Date?
+
+ enum CodingKeys: String, CodingKey {
+ case modelID = "modelId"
+ case remainingFraction
+ case remainingAmount
+ case resetTime
+ }
+
+ var normalizedBucket: GeminiQuotaBucket {
+ GeminiQuotaBucket(
+ modelID: modelID,
+ remainingFraction: remainingFraction,
+ remainingAmount: remainingAmount,
+ resetsAt: resetTime
+ )
+ }
+}
+
+extension JSONDecoder {
+ static var contextPanelISO8601: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .custom { decoder in
+ let container = try decoder.singleValueContainer()
+ let value = try container.decode(String.self)
+ if let date = ContextPanelDateFormatting.date(from: value) {
+ return date
+ }
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Expected ISO 8601 date string"
+ )
+ }
+ return decoder
+ }
+}
+
+public enum ContextPanelDateFormatting {
+ public static func string(from date: Date) -> String {
+ internetDateFormatter().string(from: date)
+ }
+
+ public static func date(from value: String) -> Date? {
+ internetDateFormatterWithFractionalSeconds().date(from: value)
+ ?? internetDateFormatter().date(from: value)
+ ?? dateOnlyFormatter().date(from: value)
+ }
+
+ private static func internetDateFormatter() -> ISO8601DateFormatter {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter
+ }
+
+ private static func internetDateFormatterWithFractionalSeconds() -> ISO8601DateFormatter {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }
+
+ private static func dateOnlyFormatter() -> ISO8601DateFormatter {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withFullDate]
+ return formatter
+ }
+}
+
+func formEncoded(_ values: [String: String]) -> Data {
+ values
+ .map { key, value in
+ "\(urlFormEscape(key))=\(urlFormEscape(value))"
+ }
+ .joined(separator: "&")
+ .data(using: .utf8) ?? Data()
+}
+
+func urlFormEscape(_ value: String) -> String {
+ var allowed = CharacterSet.urlQueryAllowed
+ allowed.remove(charactersIn: "&+=")
+ return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
+}
+
+private func jsonHeaders(accessToken: String) -> [String: String] {
+ [
+ "Authorization": "Bearer \(accessToken)",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ ]
+}
diff --git a/Sources/ContextPanelCore/LimitProbe.swift b/Sources/ContextPanelCore/LimitProbe.swift
new file mode 100644
index 0000000..144c9e0
--- /dev/null
+++ b/Sources/ContextPanelCore/LimitProbe.swift
@@ -0,0 +1,260 @@
+import Foundation
+
+public enum ProbeSource: String, Codable, Equatable, Sendable {
+ case visibleText
+ case networkMetadata
+ case redactedResponseShape
+ case manualUserEntry
+}
+
+public enum ProbeSignalKind: String, Codable, Equatable, Sendable {
+ case resetLanguage
+ case relativeDuration
+ case usagePressure
+ case limitReached
+ case modelAvailability
+ case planLanguage
+ case candidateFieldName
+}
+
+public struct LimitProbeObservation: Codable, Equatable, Identifiable, Sendable {
+ public let id: UUID
+ public let provider: Provider
+ public let observedAt: Date
+ public let source: ProbeSource
+ public let signalKind: ProbeSignalKind
+ public let confidence: UsageConfidence
+ public let sanitizedEvidence: String
+
+ public init(
+ id: UUID = UUID(),
+ provider: Provider,
+ observedAt: Date,
+ source: ProbeSource,
+ signalKind: ProbeSignalKind,
+ confidence: UsageConfidence,
+ sanitizedEvidence: String
+ ) {
+ self.id = id
+ self.provider = provider
+ self.observedAt = observedAt
+ self.source = source
+ self.signalKind = signalKind
+ self.confidence = confidence
+ self.sanitizedEvidence = EvidenceRedactor.redact(sanitizedEvidence)
+ }
+}
+
+public struct LimitProbeReport: Codable, Equatable, Sendable {
+ public let schemaVersion: Int
+ public let provider: Provider
+ public let capturedAt: Date
+ public let observations: [LimitProbeObservation]
+ public let networkEvents: [NetworkProbeEvent]
+ public let redactions: [String]
+
+ public init(
+ provider: Provider,
+ capturedAt: Date,
+ observations: [LimitProbeObservation],
+ networkEvents: [NetworkProbeEvent] = []
+ ) {
+ self.schemaVersion = 1
+ self.provider = provider
+ self.capturedAt = capturedAt
+ self.observations = observations
+ self.networkEvents = networkEvents
+ self.redactions = [
+ "cookies",
+ "authorization headers",
+ "bearer tokens",
+ "session identifiers",
+ "emails",
+ "account identifiers",
+ "raw response bodies"
+ ]
+ }
+
+ public var markdownSummary: String {
+ var lines = [
+ "# Limit Probe Report",
+ "",
+ "- Provider: \(provider.displayName)",
+ "- Captured: \(capturedAt.ISO8601Format())",
+ "- Observations: \(observations.count)",
+ "",
+ "## Observations"
+ ]
+
+ if observations.isEmpty {
+ lines.append("- No candidate limit signals found.")
+ } else {
+ for observation in observations {
+ lines.append("- `\(observation.signalKind.rawValue)` from `\(observation.source.rawValue)`: \(observation.sanitizedEvidence)")
+ }
+ }
+
+ lines.append(contentsOf: [
+ "",
+ "## Network Candidates"
+ ])
+
+ if networkEvents.isEmpty {
+ lines.append("- No candidate response shapes found.")
+ } else {
+ for event in networkEvents {
+ let status = event.status.map(String.init) ?? "?"
+ let bodySize = event.bodySize.map { "\($0)b" } ?? "unknown size"
+ let fields = event.matchedFields.joined(separator: ", ")
+ lines.append("- `\(event.method) \(event.pathHint)` status `\(status)`, `\(bodySize)`: \(fields)")
+ }
+ }
+
+ lines.append(contentsOf: [
+ "",
+ "## Redactions",
+ redactions.map { "- \($0)" }.joined(separator: "\n")
+ ])
+
+ return lines.joined(separator: "\n")
+ }
+}
+
+public struct NetworkProbeEvent: Codable, Equatable, Identifiable, Sendable {
+ public let id: UUID
+ public let observedAt: Date
+ public let method: String
+ public let pathHint: String
+ public let status: Int?
+ public let contentType: String?
+ public let bodySize: Int?
+ public let matchedFields: [String]
+
+ public init(
+ id: UUID = UUID(),
+ observedAt: Date,
+ method: String,
+ pathHint: String,
+ status: Int?,
+ contentType: String?,
+ bodySize: Int?,
+ matchedFields: [String]
+ ) {
+ self.id = id
+ self.observedAt = observedAt
+ self.method = EvidenceRedactor.redact(method)
+ self.pathHint = EvidenceRedactor.redactPath(pathHint)
+ self.status = status
+ self.contentType = EvidenceRedactor.redact(contentType ?? "")
+ self.bodySize = bodySize
+ self.matchedFields = matchedFields.map(EvidenceRedactor.redact).sorted()
+ }
+}
+
+public enum LimitProbeScanner {
+ private static let patterns: [(ProbeSignalKind, NSRegularExpression)] = [
+ (.resetLanguage, regex(#"(?i)\b(reset|resets|refresh|refreshes|available again)\b.{0,80}"#)),
+ (.relativeDuration, regex(#"(?i)\b(in\s+)?\d+\s*(m|min|mins|minutes|h|hr|hrs|hour|hours|day|days|week|weeks)\b"#)),
+ (.usagePressure, regex(#"(?i)\b\d+[\d,]*\s*(%|percent|tokens?)\s*(used|every|per|/)?\s*\d*\s*(hours?|days?|weeks?)?\b"#)),
+ (.limitReached, regex(#"(?i)\b(limit reached|reached your limit|you.ve reached|unavailable|try again)\b.{0,80}"#)),
+ (.modelAvailability, regex(#"(?i)\b(GPT|Thinking|fast mode|model picker|available|unavailable)\b.{0,80}"#)),
+ (.planLanguage, regex(#"(?i)\b(Free|Plus|Pro|Team|Business|Enterprise|Go)\b.{0,80}"#))
+ ]
+
+ public static func scanVisibleText(
+ _ text: String,
+ provider: Provider,
+ observedAt: Date = Date()
+ ) -> [LimitProbeObservation] {
+ let normalized = text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ let range = NSRange(normalized.startIndex..()
+
+ for (kind, pattern) in patterns {
+ for match in pattern.matches(in: normalized, range: range).prefix(8) {
+ guard let matchRange = Range(match.range, in: normalized) else { continue }
+ let evidence = String(normalized[matchRange]).trimmingCharacters(in: .whitespacesAndNewlines)
+ let key = "\(kind.rawValue):\(evidence.lowercased())"
+ guard !seen.contains(key) else { continue }
+ seen.insert(key)
+ observations.append(
+ LimitProbeObservation(
+ provider: provider,
+ observedAt: observedAt,
+ source: .visibleText,
+ signalKind: kind,
+ confidence: .observed,
+ sanitizedEvidence: evidence
+ )
+ )
+ }
+ }
+
+ return observations
+ }
+
+ public static func scanResponseShape(
+ fieldNames: [String],
+ provider: Provider,
+ observedAt: Date = Date()
+ ) -> [LimitProbeObservation] {
+ let candidates = fieldNames.filter { field in
+ let lower = field.lowercased()
+ return ["limit", "usage", "percent", "token", "remaining", "reset", "quota", "model", "plan", "cap"].contains { lower.contains($0) }
+ }
+
+ return Array(Set(candidates)).sorted().map { field in
+ LimitProbeObservation(
+ provider: provider,
+ observedAt: observedAt,
+ source: .redactedResponseShape,
+ signalKind: .candidateFieldName,
+ confidence: .observed,
+ sanitizedEvidence: field
+ )
+ }
+ }
+
+ private static func regex(_ pattern: String) -> NSRegularExpression {
+ do {
+ return try NSRegularExpression(pattern: pattern)
+ } catch {
+ preconditionFailure("Invalid probe regex: \(pattern)")
+ }
+ }
+}
+
+public enum EvidenceRedactor {
+ private static let redactionPatterns: [(String, String)] = [
+ (#"(?i)bearer\s+[a-z0-9._\-]+"#, "bearer [redacted]"),
+ (#"(?i)(authorization|cookie|set-cookie|csrf|session|token)[:=]\s*[^\s,;]+"#, "$1=[redacted]"),
+ (#"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, "[email redacted]"),
+ (#"\bsk-[A-Za-z0-9_\-]{12,}\b"#, "[api key redacted]"),
+ (#"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"#, "[id]"),
+ (#"(?<=\.)[A-Za-z0-9_-]{20,}(?=\.)"#, "[id]")
+ ]
+
+ public static func redact(_ value: String) -> String {
+ var redacted = value
+ for (pattern, replacement) in redactionPatterns {
+ redacted = redacted.replacingOccurrences(
+ of: pattern,
+ with: replacement,
+ options: [.regularExpression, .caseInsensitive]
+ )
+ }
+ return redacted
+ }
+
+ public static func redactPath(_ value: String) -> String {
+ guard let components = URLComponents(string: value) else {
+ return redact(value)
+ }
+ let path = components.path
+ .replacingOccurrences(of: #"/[0-9a-fA-F-]{16,}"#, with: "/[id]", options: .regularExpression)
+ .replacingOccurrences(of: #"/[A-Za-z0-9_-]{24,}"#, with: "/[id]", options: .regularExpression)
+ return redact(path.isEmpty ? value : path)
+ }
+}
diff --git a/Sources/ContextPanelCore/ProviderConnector.swift b/Sources/ContextPanelCore/ProviderConnector.swift
new file mode 100644
index 0000000..b8b8978
--- /dev/null
+++ b/Sources/ContextPanelCore/ProviderConnector.swift
@@ -0,0 +1,198 @@
+import Foundation
+
+public enum ConnectorError: LocalizedError, Equatable, Sendable {
+ case missingAuth(String)
+ case invalidAuth(String)
+ case httpFailure(operation: String, statusCode: Int)
+ case nonHTTPResponse(String)
+ case processFailure(operation: String, exitCode: Int32)
+ case decodingFailure(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .missingAuth(message), let .invalidAuth(message), let .nonHTTPResponse(message), let .decodingFailure(message):
+ message
+ case let .httpFailure(operation, statusCode):
+ "\(operation) returned HTTP \(statusCode); raw body redacted"
+ case let .processFailure(operation, exitCode):
+ "\(operation) failed with exit code \(exitCode); stderr redacted"
+ }
+ }
+}
+
+public struct ProviderConnectorReport: Equatable, Sendable {
+ public let provider: Provider
+ public let accountID: String
+ public let accountName: String
+ public let generatedAt: Date
+ public let limits: [UsageLimit]
+ public let status: UsageStatus
+ public let errorMessage: String?
+
+ public init(
+ provider: Provider,
+ accountID: String,
+ accountName: String,
+ generatedAt: Date,
+ limits: [UsageLimit],
+ status: UsageStatus? = nil,
+ errorMessage: String? = nil
+ ) {
+ self.provider = provider
+ self.accountID = accountID
+ self.accountName = accountName
+ self.generatedAt = generatedAt
+ self.limits = limits
+ self.status = status ?? UsageSnapshot(generatedAt: generatedAt, limits: limits).aggregateStatus
+ self.errorMessage = errorMessage.map(ConnectorRedactor.redact)
+ }
+}
+
+public struct ConnectorRefreshResult: Equatable, Sendable {
+ public let generatedAt: Date
+ public let reports: [ProviderConnectorReport]
+
+ public init(generatedAt: Date, reports: [ProviderConnectorReport]) {
+ self.generatedAt = generatedAt
+ self.reports = reports
+ }
+
+ public var snapshot: UsageSnapshot {
+ UsageSnapshot(generatedAt: generatedAt, limits: reports.flatMap(\.limits))
+ }
+}
+
+public protocol ProviderConnector: Sendable {
+ var provider: Provider { get }
+
+ func refresh(now: Date) async -> ConnectorRefreshResult
+}
+
+public struct ProviderConnectorRuntime: Sendable {
+ private let connectors: [any ProviderConnector]
+
+ public init(connectors: [any ProviderConnector]) {
+ self.connectors = connectors
+ }
+
+ public func refreshAll(now: Date = Date()) async -> ConnectorRefreshResult {
+ var reports: [ProviderConnectorReport] = []
+ for connector in connectors {
+ let result = await connector.refresh(now: now)
+ reports.append(contentsOf: result.reports)
+ }
+ return ConnectorRefreshResult(generatedAt: now, reports: reports)
+ }
+}
+
+public struct ConnectorHTTPRequest: Sendable {
+ public let url: URL
+ public let method: String
+ public let headers: [String: String]
+ public let body: Data?
+
+ public init(url: URL, method: String, headers: [String: String] = [:], body: Data? = nil) {
+ self.url = url
+ self.method = method
+ self.headers = headers
+ self.body = body
+ }
+}
+
+public struct ConnectorHTTPResponse: Sendable {
+ public let statusCode: Int
+ public let data: Data
+
+ public init(statusCode: Int, data: Data) {
+ self.statusCode = statusCode
+ self.data = data
+ }
+}
+
+public protocol ConnectorHTTPClient: Sendable {
+ func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse
+}
+
+public struct URLSessionConnectorHTTPClient: ConnectorHTTPClient {
+ public init() {}
+
+ public func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse {
+ var urlRequest = URLRequest(url: request.url)
+ urlRequest.httpMethod = request.method
+ urlRequest.httpBody = request.body
+ for (key, value) in request.headers {
+ urlRequest.setValue(value, forHTTPHeaderField: key)
+ }
+
+ let (data, response) = try await URLSession.shared.data(for: urlRequest)
+ guard let http = response as? HTTPURLResponse else {
+ throw ConnectorError.nonHTTPResponse("provider request returned a non-HTTP response")
+ }
+ return ConnectorHTTPResponse(statusCode: http.statusCode, data: data)
+ }
+}
+
+public struct ConnectorProcessResult: Sendable {
+ public let exitCode: Int32
+ public let stdout: Data
+
+ public init(exitCode: Int32, stdout: Data) {
+ self.exitCode = exitCode
+ self.stdout = stdout
+ }
+}
+
+public protocol ConnectorProcessClient: Sendable {
+ func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult
+}
+
+public struct DefaultConnectorProcessClient: ConnectorProcessClient {
+ public init() {}
+
+ public func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ process.arguments = [executable] + arguments
+
+ let output = Pipe()
+ let error = Pipe()
+ process.standardOutput = output
+ process.standardError = error
+
+ try process.run()
+ process.waitUntilExit()
+
+ return ConnectorProcessResult(
+ exitCode: process.terminationStatus,
+ stdout: output.fileHandleForReading.readDataToEndOfFile()
+ )
+ }
+}
+
+public enum ConnectorRedactor {
+ public static func redact(_ value: String) -> String {
+ EvidenceRedactor.redact(value)
+ }
+
+ public static func redactedPath(_ path: String) -> String {
+ let expanded = NSString(string: path).expandingTildeInPath
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ if expanded.hasPrefix(home) {
+ return "~" + expanded.dropFirst(home.count)
+ }
+ return URL(fileURLWithPath: expanded).lastPathComponent
+ }
+
+ public static func localAccountID(provider: Provider, path: String) -> String {
+ "\(provider.rawValue)-\(fnv1a(path))"
+ }
+
+ private static func fnv1a(_ value: String) -> String {
+ var hash: UInt64 = 14_695_981_039_346_656_037
+ for byte in value.utf8 {
+ hash ^= UInt64(byte)
+ hash &*= 1_099_511_628_211
+ }
+ return String(hash, radix: 16)
+ }
+}
diff --git a/Sources/ContextPanelCore/SnapshotStore.swift b/Sources/ContextPanelCore/SnapshotStore.swift
new file mode 100644
index 0000000..d76dd06
--- /dev/null
+++ b/Sources/ContextPanelCore/SnapshotStore.swift
@@ -0,0 +1,263 @@
+import Foundation
+
+public struct StoredUsageSnapshot: Codable, Equatable, Sendable {
+ public let schemaVersion: Int
+ public let savedAt: Date
+ public let snapshot: UsageSnapshot
+ public let reports: [StoredProviderReport]
+
+ public init(savedAt: Date, snapshot: UsageSnapshot, reports: [StoredProviderReport] = []) {
+ self.schemaVersion = 1
+ self.savedAt = savedAt
+ self.snapshot = snapshot
+ self.reports = reports
+ }
+
+ public init(savedAt: Date, refreshResult: ConnectorRefreshResult) {
+ self.init(
+ savedAt: savedAt,
+ snapshot: refreshResult.snapshot,
+ reports: refreshResult.reports.map(StoredProviderReport.init(report:))
+ )
+ }
+}
+
+public struct StoredProviderReport: Codable, Equatable, Sendable {
+ public let provider: Provider
+ public let accountID: String
+ public let accountName: String
+ public let generatedAt: Date
+ public let status: UsageStatus
+ public let errorMessage: String?
+
+ public init(
+ provider: Provider,
+ accountID: String,
+ accountName: String,
+ generatedAt: Date,
+ status: UsageStatus,
+ errorMessage: String?
+ ) {
+ self.provider = provider
+ self.accountID = accountID
+ self.accountName = accountName
+ self.generatedAt = generatedAt
+ self.status = status
+ self.errorMessage = errorMessage.map(ConnectorRedactor.redact)
+ }
+
+ public init(report: ProviderConnectorReport) {
+ self.init(
+ provider: report.provider,
+ accountID: report.accountID,
+ accountName: report.accountName,
+ generatedAt: report.generatedAt,
+ status: report.status,
+ errorMessage: report.errorMessage
+ )
+ }
+}
+
+public enum SnapshotStoreError: LocalizedError, Equatable, Sendable {
+ case unsupportedSchema(version: Int)
+ case corruptStore(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .unsupportedSchema(version):
+ "Unsupported snapshot schema version \(version)"
+ case let .corruptStore(message):
+ message
+ }
+ }
+}
+
+public struct SnapshotStoreLoadResult: Equatable, Sendable {
+ public let snapshot: StoredUsageSnapshot?
+ public let status: UsageStatus
+ public let errorMessage: String?
+
+ public init(snapshot: StoredUsageSnapshot?, status: UsageStatus, errorMessage: String? = nil) {
+ self.snapshot = snapshot
+ self.status = status
+ self.errorMessage = errorMessage.map(ConnectorRedactor.redact)
+ }
+}
+
+public struct SnapshotStoreQuery: Equatable, Sendable {
+ public let provider: Provider?
+ public let accountID: String?
+ public let since: Date?
+ public let limit: Int?
+
+ public init(provider: Provider? = nil, accountID: String? = nil, since: Date? = nil, limit: Int? = nil) {
+ self.provider = provider
+ self.accountID = accountID
+ self.since = since
+ self.limit = limit
+ }
+}
+
+public struct SnapshotStoreStalenessPolicy: Equatable, Sendable {
+ public let maximumAge: TimeInterval
+
+ public init(maximumAge: TimeInterval = 15 * 60) {
+ precondition(maximumAge >= 0, "maximumAge must not be negative")
+ self.maximumAge = maximumAge
+ }
+
+ public func status(for storedSnapshot: StoredUsageSnapshot?, now: Date) -> UsageStatus {
+ guard let storedSnapshot else { return .unknown }
+ if now.timeIntervalSince(storedSnapshot.savedAt) > maximumAge {
+ return .stale
+ }
+ return storedSnapshot.snapshot.aggregateStatus
+ }
+}
+
+public struct JSONSnapshotStore: Sendable {
+ public let rootDirectory: URL
+
+ public init(rootDirectory: URL) {
+ self.rootDirectory = rootDirectory
+ }
+
+ public var currentSnapshotURL: URL {
+ rootDirectory.appending(path: "current-snapshot.json")
+ }
+
+ public var historyDirectoryURL: URL {
+ rootDirectory.appending(path: "history", directoryHint: .isDirectory)
+ }
+
+ public func save(_ storedSnapshot: StoredUsageSnapshot) throws {
+ try ensureDirectories()
+ let data = try Self.makeEncoder().encode(storedSnapshot)
+ try data.write(to: currentSnapshotURL, options: [.atomic])
+ let historyURL = historyURL(for: storedSnapshot.savedAt)
+ try data.write(to: historyURL, options: [.atomic])
+ }
+
+ public func saveMerged(refreshResult: ConnectorRefreshResult, savedAt: Date) throws {
+ let replacementAccounts = Set(
+ refreshResult.reports.map { ProviderAccountKey(provider: $0.provider, accountID: $0.accountID) }
+ )
+ let current = loadCurrent().snapshot
+ let preservedLimits = current?.snapshot.limits.filter { limit in
+ !replacementAccounts.contains(ProviderAccountKey(provider: limit.provider, accountID: limit.accountID))
+ } ?? []
+ let preservedReports = current?.reports.filter { report in
+ !replacementAccounts.contains(ProviderAccountKey(provider: report.provider, accountID: report.accountID))
+ } ?? []
+
+ let mergedSnapshot = UsageSnapshot(
+ generatedAt: refreshResult.generatedAt,
+ limits: preservedLimits + refreshResult.snapshot.limits
+ )
+ let mergedReports = preservedReports + refreshResult.reports.map(StoredProviderReport.init(report:))
+
+ try save(StoredUsageSnapshot(savedAt: savedAt, snapshot: mergedSnapshot, reports: mergedReports))
+ }
+
+ public func loadCurrent() -> SnapshotStoreLoadResult {
+ guard FileManager.default.fileExists(atPath: currentSnapshotURL.path) else {
+ return SnapshotStoreLoadResult(snapshot: nil, status: .unknown)
+ }
+
+ do {
+ let snapshot = try loadSnapshot(from: currentSnapshotURL)
+ return SnapshotStoreLoadResult(snapshot: snapshot, status: snapshot.snapshot.aggregateStatus)
+ } catch {
+ return SnapshotStoreLoadResult(
+ snapshot: nil,
+ status: .failure,
+ errorMessage: error.localizedDescription
+ )
+ }
+ }
+
+ public func loadCurrent(policy: SnapshotStoreStalenessPolicy, now: Date = Date()) -> SnapshotStoreLoadResult {
+ let result = loadCurrent()
+ guard result.status != .failure else { return result }
+ return SnapshotStoreLoadResult(
+ snapshot: result.snapshot,
+ status: policy.status(for: result.snapshot, now: now),
+ errorMessage: result.errorMessage
+ )
+ }
+
+ public func loadHistory(query: SnapshotStoreQuery = SnapshotStoreQuery()) -> [StoredUsageSnapshot] {
+ guard let urls = try? FileManager.default.contentsOfDirectory(
+ at: historyDirectoryURL,
+ includingPropertiesForKeys: nil
+ ) else {
+ return []
+ }
+
+ let snapshots = urls
+ .filter { $0.pathExtension == "json" }
+ .compactMap { try? loadSnapshot(from: $0) }
+ .filter { snapshot in
+ if let since = query.since, snapshot.savedAt < since { return false }
+ if let provider = query.provider, !snapshot.snapshot.limits.contains(where: { $0.provider == provider }) {
+ return false
+ }
+ if let accountID = query.accountID, !snapshot.snapshot.limits.contains(where: { $0.accountID == accountID }) {
+ return false
+ }
+ return true
+ }
+ .sorted { $0.savedAt > $1.savedAt }
+
+ if let limit = query.limit {
+ return Array(snapshots.prefix(max(limit, 0)))
+ }
+ return snapshots
+ }
+
+ private func ensureDirectories() throws {
+ try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: historyDirectoryURL, withIntermediateDirectories: true)
+ }
+
+ private func loadSnapshot(from url: URL) throws -> StoredUsageSnapshot {
+ let data = try Data(contentsOf: url)
+ let snapshot = try Self.makeDecoder().decode(StoredUsageSnapshot.self, from: data)
+ guard snapshot.schemaVersion == 1 else {
+ throw SnapshotStoreError.unsupportedSchema(version: snapshot.schemaVersion)
+ }
+ return snapshot
+ }
+
+ private func historyURL(for date: Date) -> URL {
+ let timestamp = ContextPanelDateFormatting.historyFileTimestamp(from: date)
+ return historyDirectoryURL.appending(path: "\(timestamp).json")
+ }
+
+ private static func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ encoder.dateEncodingStrategy = .iso8601
+ return encoder
+ }
+
+ private static func makeDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
+
+private struct ProviderAccountKey: Hashable {
+ let provider: Provider
+ let accountID: String
+}
+
+extension ContextPanelDateFormatting {
+ static func historyFileTimestamp(from date: Date) -> String {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.string(from: date)
+ .replacingOccurrences(of: ":", with: "-")
+ }
+}
diff --git a/Sources/ContextPanelCore/UsageLimit.swift b/Sources/ContextPanelCore/UsageLimit.swift
index 027bdee..21cc432 100644
--- a/Sources/ContextPanelCore/UsageLimit.swift
+++ b/Sources/ContextPanelCore/UsageLimit.swift
@@ -48,6 +48,15 @@ public enum UsageConfidence: String, Codable, Equatable, Sendable {
case unknown
}
+public enum UsageUnit: String, Codable, Equatable, Sendable {
+ case percent
+ case tokens
+ case requests
+ case credits
+ case units
+ case unknown
+}
+
public struct ProviderAccount: Codable, Equatable, Identifiable, Sendable {
public let id: String
public let provider: Provider
@@ -68,6 +77,9 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable {
public let accountID: String
public let accountName: String
public let label: String
+ public let windowLabel: String?
+ public let modelLabel: String?
+ public let unit: UsageUnit
public let used: Int?
public let limit: Int?
public let resetsAt: Date?
@@ -76,12 +88,33 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable {
public let statusOverride: UsageStatus?
public let note: String?
+ enum CodingKeys: String, CodingKey {
+ case id
+ case provider
+ case accountID
+ case accountName
+ case label
+ case windowLabel
+ case modelLabel
+ case unit
+ case used
+ case limit
+ case resetsAt
+ case lastUpdatedAt
+ case confidence
+ case statusOverride
+ case note
+ }
+
public init(
id: String? = nil,
provider: Provider,
accountID: String,
accountName: String,
label: String,
+ windowLabel: String? = nil,
+ modelLabel: String? = nil,
+ unit: UsageUnit = .units,
used: Int?,
limit: Int?,
resetsAt: Date? = nil,
@@ -102,6 +135,9 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable {
self.accountID = accountID
self.accountName = accountName
self.label = label
+ self.windowLabel = windowLabel
+ self.modelLabel = modelLabel
+ self.unit = unit
self.used = used
self.limit = limit
self.resetsAt = resetsAt
@@ -111,12 +147,32 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable {
self.note = note
}
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(String.self, forKey: .id)
+ provider = try container.decode(Provider.self, forKey: .provider)
+ accountID = try container.decode(String.self, forKey: .accountID)
+ accountName = try container.decode(String.self, forKey: .accountName)
+ label = try container.decode(String.self, forKey: .label)
+ windowLabel = try container.decodeIfPresent(String.self, forKey: .windowLabel)
+ modelLabel = try container.decodeIfPresent(String.self, forKey: .modelLabel)
+ unit = try container.decode(UsageUnit.self, forKey: .unit)
+ used = try container.decodeIfPresent(Int.self, forKey: .used)
+ limit = try container.decodeIfPresent(Int.self, forKey: .limit)
+ resetsAt = try container.decodeIfPresent(Date.self, forKey: .resetsAt)
+ lastUpdatedAt = try container.decodeIfPresent(Date.self, forKey: .lastUpdatedAt)
+ confidence = try container.decode(UsageConfidence.self, forKey: .confidence)
+ statusOverride = try container.decodeIfPresent(UsageStatus.self, forKey: .statusOverride)
+ note = try container.decodeIfPresent(String.self, forKey: .note)
+ }
+
public init(provider: Provider, label: String, used: Int, limit: Int, resetsAt: Date? = nil) {
self.init(
provider: provider,
accountID: "default",
accountName: provider.displayName,
label: label,
+ unit: .units,
used: used,
limit: limit,
resetsAt: resetsAt,
@@ -134,6 +190,19 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable {
return min(Double(used) / Double(limit), 1)
}
+ public var displayLabel: String {
+ windowLabel ?? label
+ }
+
+ public var contextLabel: String {
+ [modelLabel, accountName]
+ .compactMap { value in
+ guard let value, !value.isEmpty else { return nil }
+ return value
+ }
+ .joined(separator: " ยท ")
+ }
+
public var status: UsageStatus {
if let statusOverride {
return statusOverride
@@ -174,8 +243,7 @@ public struct UsageSnapshot: Codable, Equatable, Sendable {
public var aggregateCapacityRatio: Double {
let ratios = limits.compactMap(\.usageRatio)
guard !ratios.isEmpty else { return 0 }
- let averageUsed = ratios.reduce(0, +) / Double(ratios.count)
- return max(1 - averageUsed, 0)
+ return max(1 - (ratios.max() ?? 0), 0)
}
public var aggregateStatus: UsageStatus {
diff --git a/Sources/ContextPanelCore/WidgetSnapshot.swift b/Sources/ContextPanelCore/WidgetSnapshot.swift
new file mode 100644
index 0000000..5f0cd8d
--- /dev/null
+++ b/Sources/ContextPanelCore/WidgetSnapshot.swift
@@ -0,0 +1,139 @@
+import Foundation
+
+public enum WidgetSnapshotState: String, Codable, Equatable, Sendable {
+ case ready
+ case setupNeeded
+ case stale
+ case failure
+}
+
+public struct WidgetSnapshot: Codable, Equatable, Sendable {
+ public let state: WidgetSnapshotState
+ public let generatedAt: Date
+ public let limits: [UsageLimit]
+ public let reports: [StoredProviderReport]
+ public let status: UsageStatus
+ public let message: String
+
+ public init(
+ state: WidgetSnapshotState,
+ generatedAt: Date,
+ limits: [UsageLimit],
+ reports: [StoredProviderReport] = [],
+ status: UsageStatus,
+ message: String
+ ) {
+ self.state = state
+ self.generatedAt = generatedAt
+ self.limits = limits
+ self.reports = reports
+ self.status = status
+ self.message = message
+ }
+
+ public var usageSnapshot: UsageSnapshot {
+ UsageSnapshot(generatedAt: generatedAt, limits: limits)
+ }
+
+ public var mostConstrainedLimits: [UsageLimit] {
+ usageSnapshot.mostConstrainedLimits
+ }
+
+ public var aggregateCapacityRatio: Double {
+ usageSnapshot.aggregateCapacityRatio
+ }
+
+ public var providerSummaries: [ProviderSummary] {
+ Provider.allCases.map { provider in
+ let providerLimits = limits.filter { $0.provider == provider }
+ return ProviderSummary(
+ provider: provider,
+ limitCount: providerLimits.count,
+ status: providerLimits.map(\.status).contextPanelWorstStatus,
+ capacityRatio: capacityRatio(for: providerLimits)
+ )
+ }
+ }
+
+ public static func fromStore(
+ _ result: SnapshotStoreLoadResult,
+ now: Date = Date()
+ ) -> WidgetSnapshot {
+ guard let stored = result.snapshot else {
+ return WidgetSnapshot(
+ state: result.status == .failure ? .failure : .setupNeeded,
+ generatedAt: now,
+ limits: [],
+ status: result.status,
+ message: result.errorMessage ?? "Set up Context Panel in the app."
+ )
+ }
+
+ let state: WidgetSnapshotState = switch result.status {
+ case .failure:
+ .failure
+ case .stale:
+ .stale
+ default:
+ .ready
+ }
+
+ return WidgetSnapshot(
+ state: state,
+ generatedAt: stored.snapshot.generatedAt,
+ limits: stored.snapshot.limits,
+ reports: stored.reports,
+ status: result.status,
+ message: message(state: state, stored: stored)
+ )
+ }
+
+ private static func message(state: WidgetSnapshotState, stored: StoredUsageSnapshot) -> String {
+ switch state {
+ case .ready:
+ let limitedCount = stored.snapshot.limits.filter { $0.status == .limited }.count
+ if limitedCount > 0 {
+ return "\(limitedCount) limit needs attention."
+ }
+ return "You're good to keep working."
+ case .setupNeeded:
+ return "Set up Context Panel in the app."
+ case .stale:
+ return "Last snapshot is stale."
+ case .failure:
+ return "Refresh failed."
+ }
+ }
+
+ private func capacityRatio(for limits: [UsageLimit]) -> Double {
+ let ratios = limits.compactMap(\.usageRatio)
+ guard !ratios.isEmpty else { return 0 }
+ return max(1 - (ratios.max() ?? 0), 0)
+ }
+}
+
+public struct ProviderSummary: Codable, Equatable, Sendable {
+ public let provider: Provider
+ public let limitCount: Int
+ public let status: UsageStatus
+ public let capacityRatio: Double
+
+ public init(provider: Provider, limitCount: Int, status: UsageStatus, capacityRatio: Double) {
+ self.provider = provider
+ self.limitCount = limitCount
+ self.status = status
+ self.capacityRatio = capacityRatio
+ }
+}
+
+extension Array where Element == UsageStatus {
+ public var contextPanelWorstStatus: UsageStatus {
+ if contains(.limited) { return .limited }
+ if contains(.failure) { return .failure }
+ if contains(.close) { return .close }
+ if contains(.stale) { return .stale }
+ if contains(.unknown) { return .unknown }
+ if contains(.loading) { return .loading }
+ return .healthy
+ }
+}
diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift
index 043f07f..5ea5bb5 100644
--- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift
+++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift
@@ -1,46 +1,57 @@
import ContextPanelCore
import SwiftUI
+import WebKit
@main
struct ContextPanelPreviewApp: App {
var body: some Scene {
WindowGroup {
- AppRoot(snapshot: SampleUsageData.snapshot)
+ AppRoot()
.frame(minWidth: 1280, minHeight: 720)
}
}
}
struct AppRoot: View {
- let snapshot: UsageSnapshot
+ @StateObject private var model = ContextPanelAppModel()
@State private var selectedID: UsageLimit.ID?
+ private var snapshot: UsageSnapshot {
+ model.currentSnapshot
+ }
+
private var selectedLimit: UsageLimit {
if let selectedID, let match = snapshot.limits.first(where: { $0.id == selectedID }) {
return match
}
- return snapshot.mostConstrainedLimits[0]
+ return snapshot.mostConstrainedLimits.first ?? SampleUsageData.snapshot.mostConstrainedLimits[0]
}
var body: some View {
HStack(spacing: 0) {
- AccountsSidebar(snapshot: snapshot, selectedID: $selectedID)
+ AccountsSidebar(model: model, snapshot: snapshot, selectedID: $selectedID)
.frame(width: 210)
Divider()
- InstrumentDashboard(snapshot: snapshot)
+ InstrumentDashboard(model: model, snapshot: snapshot)
.frame(minWidth: 740)
Divider()
- AccountDetail(limit: selectedLimit, generatedAt: snapshot.generatedAt)
+ AccountDetail(model: model, limit: selectedLimit, generatedAt: snapshot.generatedAt)
.frame(width: 320)
}
.tint(CPTheme.accent)
.onAppear {
+ model.loadSnapshot()
selectedID = selectedID ?? snapshot.mostConstrainedLimits.first?.id
}
+ .sheet(isPresented: $model.isClaudeWebCapturePresented) {
+ ClaudeWebCaptureSheet(model: model)
+ .frame(minWidth: 980, minHeight: 680)
+ }
}
}
struct AccountsSidebar: View {
+ @ObservedObject var model: ContextPanelAppModel
let snapshot: UsageSnapshot
@Binding var selectedID: UsageLimit.ID?
@@ -61,12 +72,23 @@ struct AccountsSidebar: View {
}
.navigationTitle("Context Panel")
.safeAreaInset(edge: .bottom) {
- Button {
- } label: {
- Label("Add Account", systemImage: "plus")
- .frame(maxWidth: .infinity)
+ VStack(spacing: 8) {
+ Button {
+ Task { await model.refreshLocalConnectors() }
+ } label: {
+ Label(model.isRefreshing ? "Refreshing" : "Refresh", systemImage: "arrow.clockwise")
+ .frame(maxWidth: .infinity)
+ }
+ .disabled(model.isRefreshing)
+
+ Button {
+ model.openClaudeWebCapture()
+ } label: {
+ Label("Claude Web", systemImage: "gauge.with.dots.needle.67percent")
+ .frame(maxWidth: .infinity)
+ }
}
- .buttonStyle(.borderedProminent)
+ .buttonStyle(.bordered)
.controlSize(.large)
.padding(12)
}
@@ -79,15 +101,15 @@ struct ProviderSidebarRow: View {
var body: some View {
HStack(spacing: 8) {
- ProviderGlyph(provider: provider, size: 12)
+ ProviderBadge(provider: provider)
Text(provider.displayName)
.font(.system(size: 12, weight: .semibold))
.textCase(.uppercase)
- .foregroundStyle(CPTheme.secondaryText)
+ .foregroundStyle(.secondary)
Spacer()
Text("\(limits.count)")
.font(.system(.caption, design: .monospaced, weight: .medium))
- .foregroundStyle(CPTheme.tertiaryText)
+ .foregroundStyle(.tertiary)
}
}
}
@@ -101,20 +123,21 @@ struct SidebarLimitRow: View {
VStack(alignment: .leading, spacing: 2) {
Text(limit.accountName)
.font(.system(size: 13, weight: .medium))
- Text(limit.label)
+ Text(limit.displayLabel)
.font(.system(size: 11))
- .foregroundStyle(CPTheme.tertiaryText)
+ .foregroundStyle(.secondary)
}
Spacer()
Text(limit.compactUsageText)
.font(.system(.caption2, design: .monospaced, weight: .medium))
- .foregroundStyle(CPTheme.secondaryText)
+ .foregroundStyle(.secondary)
}
.padding(.vertical, 3)
}
}
struct InstrumentDashboard: View {
+ @ObservedObject var model: ContextPanelAppModel
let snapshot: UsageSnapshot
private var constrained: [UsageLimit] {
@@ -124,7 +147,8 @@ struct InstrumentDashboard: View {
var body: some View {
ScrollView([.vertical, .horizontal]) {
VStack(alignment: .leading, spacing: 18) {
- HeaderCard(snapshot: snapshot)
+ HeaderCard(model: model, snapshot: snapshot)
+ SetupStatusStrip(model: model)
WidgetPreviewGrid(snapshot: snapshot)
SectionHeader(title: "Most Constrained", trailing: "\(snapshot.limits.count) accounts")
VStack(spacing: 10) {
@@ -144,6 +168,7 @@ struct InstrumentDashboard: View {
}
struct HeaderCard: View {
+ @ObservedObject var model: ContextPanelAppModel
let snapshot: UsageSnapshot
var body: some View {
@@ -157,21 +182,21 @@ struct HeaderCard: View {
Text(snapshot.subheadline)
.font(.system(size: 13))
.foregroundStyle(CPTheme.secondaryText)
- Text(SampleUsageData.fastModeForecast.copy)
+ Text(model.fastModeForecast.copy)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(CPTheme.accent)
HStack(spacing: 8) {
TagLabel("SwiftUI")
TagLabel("WidgetKit")
- TagLabel("Keychain-local")
+ TagLabel(model.storeStatus.rawValue)
}
}
Spacer(minLength: 16)
CapacityDial(
- value: snapshot.aggregateCapacityRatio,
+ value: snapshot.tightestCapacityRatio,
status: snapshot.aggregateStatus,
- label: "\(Int(snapshot.aggregateCapacityRatio * 100))",
- sublabel: "capacity",
+ label: "\(Int(snapshot.tightestCapacityRatio * 100))",
+ sublabel: "tightest",
size: 116
)
}
@@ -183,6 +208,63 @@ struct HeaderCard: View {
}
}
+struct SetupStatusStrip: View {
+ @ObservedObject var model: ContextPanelAppModel
+
+ var body: some View {
+ HStack(spacing: 12) {
+ SetupStatusItem(
+ title: "Snapshot cache",
+ value: model.storeStatus == .healthy ? "Ready" : model.storeStatus.rawValue,
+ status: model.storeStatus
+ )
+ SetupStatusItem(
+ title: "History",
+ value: "\(model.historyCount) entries",
+ status: model.historyCount > 0 ? .healthy : .unknown
+ )
+ SetupStatusItem(
+ title: "Last refresh",
+ value: model.lastRefreshText,
+ status: model.isRefreshing ? .loading : .healthy
+ )
+ Spacer(minLength: 12)
+ if let errorMessage = model.errorMessage {
+ Text(errorMessage)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(CPTheme.statusColor(.failure))
+ .lineLimit(1)
+ }
+ }
+ .padding(14)
+ .background(CPTheme.surface)
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .overlay(CPTheme.stroke(cornerRadius: 10))
+ }
+}
+
+struct SetupStatusItem: View {
+ let title: String
+ let value: String
+ let status: UsageStatus
+
+ var body: some View {
+ HStack(spacing: 8) {
+ StatusMark(status: status, size: 8)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(CPTheme.tertiaryText)
+ .textCase(.uppercase)
+ Text(value)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(CPTheme.secondaryText)
+ .lineLimit(1)
+ }
+ }
+ }
+}
+
struct WidgetPreviewGrid: View {
let snapshot: UsageSnapshot
@@ -206,7 +288,7 @@ struct SmallWidgetPreview: View {
VStack(alignment: .leading, spacing: 10) {
WidgetHeader(status: snapshot.aggregateStatus)
Spacer()
- Text(snapshot.tightestUsageText)
+ Text(snapshot.fastModeForecast.copy)
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(CPTheme.primaryText)
.lineLimit(2)
@@ -232,21 +314,21 @@ struct MediumWidgetPreview: View {
WidgetHeader(status: snapshot.aggregateStatus)
Spacer()
CapacityDial(
- value: snapshot.aggregateCapacityRatio,
+ value: snapshot.tightestCapacityRatio,
status: snapshot.aggregateStatus,
- label: "\(Int(snapshot.aggregateCapacityRatio * 100))",
- sublabel: "capacity",
+ label: "\(Int(snapshot.tightestCapacityRatio * 100))",
+ sublabel: "tightest",
size: 94
)
VStack(alignment: .leading, spacing: 2) {
- Text("Working room")
+ Text(snapshot.fastModeForecast.copy)
.font(.system(size: 18, weight: .semibold))
- Text("1 limited ยท 2 close")
+ Text(snapshot.providerPressureText)
.font(.system(size: 11))
.foregroundStyle(CPTheme.tertiaryText)
}
Spacer()
- Text("nearest reset ยท 42m")
+ Text(snapshot.nearestResetText)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(CPTheme.tertiaryText)
}
@@ -274,19 +356,19 @@ struct LargeWidgetPreview: View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
CPLabel("Context Panel")
- Text("You're good for the afternoon.")
+ Text(snapshot.fastModeForecast.copy)
.font(.system(size: 25, weight: .semibold))
.foregroundStyle(CPTheme.primaryText)
- Text("Image gen on Team is the only blocker.")
+ Text(snapshot.tightestSupportText)
.font(.system(size: 12))
.foregroundStyle(CPTheme.secondaryText)
}
Spacer()
CapacityDial(
- value: snapshot.aggregateCapacityRatio,
+ value: snapshot.tightestCapacityRatio,
status: snapshot.aggregateStatus,
- label: "\(Int(snapshot.aggregateCapacityRatio * 100))",
- sublabel: "cap",
+ label: "\(Int(snapshot.tightestCapacityRatio * 100))",
+ sublabel: "tightest",
size: 84
)
}
@@ -298,11 +380,11 @@ struct LargeWidgetPreview: View {
HStack {
Sparkline(values: [0.72, 0.68, 0.7, 0.64, 0.62, 0.58, 0.64])
.frame(width: 120, height: 20)
- Text("24h capacity")
+ Text("pressure trend")
.font(.system(size: 10))
.foregroundStyle(CPTheme.tertiaryText)
Spacer()
- Text("next reset in 42m ยท upd 2m ago")
+ Text(snapshot.nearestResetText)
.font(.system(size: 10))
.foregroundStyle(CPTheme.tertiaryText)
}
@@ -322,7 +404,7 @@ struct ProviderGroupGrid: View {
if !limits.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
- ProviderGlyph(provider: provider, size: 11)
+ ProviderBadge(provider: provider, compact: true)
Text(provider.displayName)
.font(.system(size: 11, weight: .semibold))
.textCase(.uppercase)
@@ -342,7 +424,7 @@ struct ProviderGroupGrid: View {
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundStyle(CPTheme.secondaryText)
}
- Text(limit.label)
+ Text(limit.displayLabel)
.font(.system(size: 10))
.foregroundStyle(CPTheme.tertiaryText)
.lineLimit(1)
@@ -358,6 +440,7 @@ struct ProviderGroupGrid: View {
}
struct AccountDetail: View {
+ @ObservedObject var model: ContextPanelAppModel
let limit: UsageLimit
let generatedAt: Date
@@ -365,11 +448,11 @@ struct AccountDetail: View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 10) {
- ProviderGlyph(provider: limit.provider, size: 16)
+ ProviderBadge(provider: limit.provider)
VStack(alignment: .leading, spacing: 2) {
Text(limit.accountName)
.font(.system(size: 22, weight: .semibold))
- Text("\(limit.provider.displayName) ยท \(limit.label)")
+ Text("\(limit.provider.displayName) ยท \(limit.displayLabel) ยท \(limit.contextLabel)")
.font(.system(size: 13))
.foregroundStyle(CPTheme.secondaryText)
}
@@ -399,16 +482,27 @@ struct AccountDetail: View {
DetailRow(label: "Limit", value: limit.limit.map(String.init) ?? "unknown")
DetailRow(label: "Remaining", value: limit.remaining.map(String.init) ?? "unknown")
DetailRow(label: "Status", value: limit.status.rawValue)
- DetailRow(label: "Updated", value: "2m ago")
+ DetailRow(label: "Updated", value: limit.lastUpdatedAt.map(model.relativeTime) ?? "unknown")
}
DetailCard(title: "Refresh history") {
Sparkline(values: [0.72, 0.68, 0.70, 0.64, 0.62, 0.58, 0.64])
.frame(height: 42)
- Text("Last good snapshot preserved for stale and failure states.")
+ Text("\(model.historyCount) cached snapshots. Last good snapshot is preserved for stale and failure states.")
.font(.system(size: 12))
.foregroundStyle(CPTheme.secondaryText)
}
+
+ DetailCard(title: "Setup") {
+ DetailRow(label: "Store", value: ConnectorRedactor.redactedPath(model.store.rootDirectory.path))
+ DetailRow(label: "Accounts", value: "\(model.configuredAccounts.filter(\.isEnabled).count) enabled")
+ ForEach(model.configuredAccounts) { account in
+ DetailRow(
+ label: account.displayName,
+ value: account.isEnabled ? account.connectorKind.rawValue : "disabled"
+ )
+ }
+ }
}
.padding(22)
}
@@ -417,11 +511,11 @@ struct AccountDetail: View {
}
private var forecastCopy: String {
- if limit.provider == .openAI, limit.label.contains("GPT-5") {
+ if limit.provider == .openAI, limit.unit == .percent {
return FastModeForecast(
input: FastModeForecastInput(
- limit: limit,
- now: SampleUsageData.referenceNow,
+ limit: limit,
+ now: model.now,
standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
reserveUnits: 6,
@@ -502,10 +596,7 @@ struct ProviderMiniStatus: View {
ForEach(Provider.allCases) { provider in
let limits = snapshot.limits.filter { $0.provider == provider }
HStack(spacing: 5) {
- ProviderGlyph(provider: provider, size: 10)
- Text(provider.shortName)
- .font(.system(size: 11, weight: .medium, design: .monospaced))
- .foregroundStyle(CPTheme.secondaryText)
+ ProviderBadge(provider: provider, compact: true)
}
.opacity(limits.isEmpty ? 0.35 : 1)
}
@@ -519,14 +610,14 @@ struct AccountRow: View {
var body: some View {
HStack(spacing: 10) {
- ProviderGlyph(provider: limit.provider, size: compact ? 11 : 13)
+ ProviderBadge(provider: limit.provider, compact: true)
.frame(width: 16)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
- Text(limit.label)
+ Text(limit.displayLabel)
.font(.system(size: compact ? 12 : 13, weight: .medium))
.lineLimit(1)
- Text("ยท \(limit.accountName)")
+ Text("ยท \(limit.contextLabel)")
.font(.system(size: compact ? 12 : 13))
.foregroundStyle(CPTheme.tertiaryText)
.lineLimit(1)
@@ -555,6 +646,126 @@ struct AccountRow: View {
}
}
+@MainActor
+final class ContextPanelAppModel: ObservableObject {
+ @Published private(set) var storedSnapshot: StoredUsageSnapshot?
+ @Published private(set) var storeStatus: UsageStatus = .unknown
+ @Published private(set) var historyCount: Int = 0
+ @Published private(set) var configuredAccounts: [LocalProviderAccountConfiguration] = []
+ @Published private(set) var isRefreshing = false
+ @Published var isClaudeWebCapturePresented = false
+ @Published private(set) var errorMessage: String?
+ @Published private(set) var lastRefreshAt: Date?
+
+ let now = Date()
+ let store: JSONSnapshotStore
+ let accountStore: AccountConfigurationStore
+
+ var currentSnapshot: UsageSnapshot {
+ storedSnapshot?.snapshot ?? SampleUsageData.snapshot
+ }
+
+ var fastModeForecast: FastModePortfolioForecast {
+ let forecasts = currentSnapshot.limits
+ .filter { $0.provider == .openAI && $0.unit == .percent }
+ .map { limit in
+ FastModeForecast(input: FastModeForecastInput(
+ limit: limit,
+ now: Date(),
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
+ reserveUnits: 6,
+ minimumSafeHours: 1
+ ))
+ }
+ return FastModePortfolioForecast(forecasts: forecasts)
+ }
+
+ var lastRefreshText: String {
+ lastRefreshAt.map(relativeTime) ?? "not yet"
+ }
+
+ init() {
+ store = JSONSnapshotStore(
+ rootDirectory: ContextPanelLocations.snapshotDirectory(appGroupID: ContextPanelLocations.appGroupID)
+ )
+ accountStore = AccountConfigurationStore(configurationURL: ContextPanelLocations.accountConfigurationURL())
+ }
+
+ func loadSnapshot() {
+ let accounts = accountStore.load().document.accounts
+ configuredAccounts = accounts
+ let result = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 15 * 60), now: Date())
+ storedSnapshot = result.snapshot
+ storeStatus = result.status
+ errorMessage = result.errorMessage
+ historyCount = store.loadHistory().count
+ }
+
+ func refreshLocalConnectors() async {
+ isRefreshing = true
+ defer { isRefreshing = false }
+
+ let accountDocument = accountStore.load().document
+ configuredAccounts = accountDocument.accounts
+ let connectors = AccountConnectorFactory.connectors(from: accountDocument)
+ let refreshResult = await ProviderConnectorRuntime(connectors: connectors).refreshAll()
+ let savedAt = Date()
+
+ do {
+ try store.saveMerged(refreshResult: refreshResult, savedAt: savedAt)
+ lastRefreshAt = savedAt
+ loadSnapshot()
+ } catch {
+ storeStatus = .failure
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func openClaudeWebCapture() {
+ isClaudeWebCapturePresented = true
+ }
+
+ func closeClaudeWebCapture() {
+ isClaudeWebCapturePresented = false
+ }
+
+ func saveClaudeWebLimits(_ limits: [UsageLimit]) {
+ guard !limits.isEmpty else { return }
+ let savedAt = Date()
+ let report = ProviderConnectorReport(
+ provider: .anthropic,
+ accountID: "claude-web",
+ accountName: "Claude Web",
+ generatedAt: savedAt,
+ limits: limits,
+ status: .healthy
+ )
+ do {
+ try store.saveMerged(
+ refreshResult: ConnectorRefreshResult(generatedAt: savedAt, reports: [report]),
+ savedAt: savedAt
+ )
+ lastRefreshAt = savedAt
+ loadSnapshot()
+ } catch {
+ storeStatus = .failure
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func relativeTime(_ date: Date) -> String {
+ let seconds = max(Int(Date().timeIntervalSince(date)), 0)
+ if seconds < 60 { return "just now" }
+ let minutes = seconds / 60
+ if minutes < 60 { return "\(minutes)m ago" }
+ let hours = minutes / 60
+ if hours < 24 { return "\(hours)h ago" }
+ return "\(hours / 24)d ago"
+ }
+
+}
+
struct CapacityDial: View {
let value: Double
let status: UsageStatus
@@ -607,26 +818,15 @@ struct CapacityBar: View {
}
}
-struct ProviderGlyph: View {
+struct ProviderBadge: View {
let provider: Provider
- var size: CGFloat = 12
+ var compact = false
var body: some View {
- Group {
- switch provider {
- case .openAI:
- RoundedRectangle(cornerRadius: 2, style: .continuous)
- .stroke(CPTheme.accent, lineWidth: 1.4)
- case .anthropic:
- Triangle()
- .stroke(CPTheme.accent, lineWidth: 1.4)
- case .google:
- RoundedRectangle(cornerRadius: 1, style: .continuous)
- .rotation(.degrees(45))
- .stroke(CPTheme.accent, lineWidth: 1.4)
- }
- }
- .frame(width: size, height: size)
+ Text(provider.shortName)
+ .font(.system(size: compact ? 10 : 11, weight: .semibold, design: .monospaced))
+ .foregroundStyle(CPTheme.providerColor(provider))
+ .lineLimit(1)
}
}
@@ -657,6 +857,289 @@ struct StatusMark: View {
}
}
+struct ClaudeWebCaptureSheet: View {
+ @ObservedObject var model: ContextPanelAppModel
+ @StateObject private var captureModel = ClaudeWebCaptureModel()
+
+ var body: some View {
+ HStack(spacing: 0) {
+ VStack(alignment: .leading, spacing: 14) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Claude Web")
+ .font(.system(size: 22, weight: .semibold))
+ Text("Complete Claude verification here. The app captures only official usage windows from the Usage page.")
+ .font(.system(size: 13))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+
+ HStack {
+ Button("Open Usage") { captureModel.openUsagePage() }
+ Button("Reload") { captureModel.reload() }
+ Spacer()
+ Button("Done") { model.closeClaudeWebCapture() }
+ }
+
+ Label(captureModel.statusText, systemImage: captureModel.statusIcon)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(captureModel.limits.isEmpty ? CPTheme.secondaryText : CPTheme.primaryText)
+
+ Divider()
+
+ Text("Captured windows")
+ .font(.system(size: 11, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(CPTheme.secondaryText)
+
+ if captureModel.limits.isEmpty {
+ ContentUnavailableView(
+ "Waiting for Claude usage",
+ systemImage: "network",
+ description: Text("The sheet auto-saves when Claude's usage endpoint returns percent windows.")
+ )
+ .frame(maxHeight: 220)
+ } else {
+ ScrollView {
+ VStack(spacing: 8) {
+ ForEach(captureModel.limits) { limit in
+ ClaudeWebCaptureLimitRow(limit: limit)
+ }
+ }
+ }
+ }
+
+ Spacer()
+
+ VStack(alignment: .leading, spacing: 6) {
+ Label("No cookies, tokens, headers, IDs, emails, local storage, or raw bodies are stored.", systemImage: "lock.shield")
+ Label("Saved rows are merged with OpenAI and Gemini instead of replacing them.", systemImage: "square.stack.3d.up")
+ }
+ .font(.system(size: 11))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+ .frame(width: 330)
+ .padding(18)
+ .background(CPTheme.surface)
+
+ Divider()
+
+ ClaudeWebCaptureWebView(model: captureModel)
+ }
+ .onReceive(captureModel.$limits) { limits in
+ guard !limits.isEmpty else { return }
+ model.saveClaudeWebLimits(limits)
+ }
+ }
+}
+
+struct ClaudeWebCaptureLimitRow: View {
+ let limit: UsageLimit
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(limit.displayLabel)
+ .font(.system(size: 13, weight: .semibold))
+ Text(limit.contextLabel)
+ .font(.system(size: 11))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+ Spacer()
+ Text(limit.compactUsageText)
+ .font(.system(size: 18, weight: .semibold, design: .rounded))
+ }
+ CapacityBar(value: limit.usageRatio ?? 0, status: limit.status)
+ Text(limit.resetText)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(CPTheme.tertiaryText)
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(CPTheme.background)
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .overlay(CPTheme.stroke(cornerRadius: 8))
+ }
+}
+
+@MainActor
+final class ClaudeWebCaptureModel: ObservableObject {
+ @Published var limits: [UsageLimit] = []
+ @Published var statusText = "Opening Claude usage page"
+ @Published var statusIcon = "safari"
+
+ private lazy var navigationDelegate = ClaudeWebCaptureNavigationDelegate(owner: self)
+
+ lazy var webView: WKWebView = {
+ let configuration = WKWebViewConfiguration()
+ configuration.websiteDataStore = .default()
+ configuration.userContentController.add(ClaudeWebCaptureScriptHandler(owner: self), name: "claudeUsageCapture")
+ configuration.userContentController.addUserScript(
+ WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false)
+ )
+ let view = WKWebView(frame: .zero, configuration: configuration)
+ view.navigationDelegate = navigationDelegate
+ return view
+ }()
+
+ init() {
+ openUsagePage()
+ }
+
+ func openUsagePage() {
+ statusText = "Opening Claude usage page"
+ statusIcon = "safari"
+ webView.load(URLRequest(url: URL(string: "https://claude.ai/settings/usage")!))
+ }
+
+ func reload() {
+ statusText = "Reloading Claude usage page"
+ statusIcon = "arrow.clockwise"
+ webView.reload()
+ }
+
+ fileprivate func record(payload: [String: Any]) {
+ let windows = payload["windows"] as? [String: Any] ?? [:]
+ let wrapped = ["rate_limits": windows]
+ do {
+ let data = try JSONSerialization.data(withJSONObject: wrapped)
+ let parsed = try ClaudeWebUsageParser.usageLimits(
+ from: data,
+ accountID: "claude-web",
+ accountName: "Claude Web",
+ observedAt: Date()
+ )
+ guard !parsed.isEmpty else { return }
+ limits = parsed
+ statusText = "Captured and saved Claude web usage"
+ statusIcon = "checkmark.circle.fill"
+ } catch {
+ statusText = "Capture failed: \(error.localizedDescription)"
+ statusIcon = "exclamationmark.triangle"
+ }
+ }
+
+ fileprivate func didFinishNavigation(url: URL?) {
+ if let host = url?.host, host.contains("claude.ai"), limits.isEmpty {
+ statusText = "Claude page loaded; waiting for usage API"
+ statusIcon = "network"
+ }
+ }
+
+ private static let networkProbeScript = #"""
+ (() => {
+ if (window.__contextPanelClaudeUsageCaptureInstalled) return;
+ window.__contextPanelClaudeUsageCaptureInstalled = true;
+
+ const windowKeys = new Set(['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet', 'seven_day_oauth_apps']);
+ const fieldKeys = new Set(['used_percentage', 'remaining_percentage', 'utilization', 'resets_at', 'reset_at']);
+
+ function isUsageURL(rawUrl) {
+ try { return /^\/api\/organizations\/[^/]+\/usage$/.test(new URL(rawUrl, window.location.href).pathname); }
+ catch (_) { return false; }
+ }
+
+ function sanitizeWindow(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const sanitized = {};
+ for (const key of fieldKeys) {
+ const raw = value[key];
+ if (typeof raw === 'number' || typeof raw === 'string') sanitized[key] = raw;
+ }
+ return Object.keys(sanitized).length ? sanitized : null;
+ }
+
+ function collectWindows(value, out = {}) {
+ if (!value || typeof value !== 'object') return out;
+ if (Array.isArray(value)) {
+ value.slice(0, 3).forEach(item => collectWindows(item, out));
+ return out;
+ }
+ for (const [key, child] of Object.entries(value)) {
+ if (windowKeys.has(key)) {
+ const sanitized = sanitizeWindow(child);
+ if (sanitized) out[key] = sanitized;
+ }
+ collectWindows(child, out);
+ }
+ return out;
+ }
+
+ function post(payload) {
+ try { window.webkit.messageHandlers.claudeUsageCapture.postMessage(payload); }
+ catch (_) {}
+ }
+
+ function inspect(url, contentType, text) {
+ if (!isUsageURL(url) || !/json/i.test(contentType || '')) return;
+ try {
+ const windows = collectWindows(JSON.parse(String(text || '')));
+ if (Object.keys(windows).length) post({ windows });
+ } catch (_) {}
+ }
+
+ const originalFetch = window.fetch;
+ if (originalFetch) {
+ window.fetch = async function(input, init) {
+ const response = await originalFetch.apply(this, arguments);
+ try {
+ const clone = response.clone();
+ const url = typeof input === 'string' ? input : (input && input.url) || '';
+ clone.text().then(text => inspect(url, clone.headers.get('content-type') || '', text)).catch(() => {});
+ } catch (_) {}
+ return response;
+ };
+ }
+
+ const originalOpen = XMLHttpRequest.prototype.open;
+ const originalSend = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function(method, url) {
+ this.__cpClaudeUsageUrl = url;
+ return originalOpen.apply(this, arguments);
+ };
+ XMLHttpRequest.prototype.send = function() {
+ this.addEventListener('load', function() {
+ try { inspect(this.__cpClaudeUsageUrl || '', this.getResponseHeader('content-type') || '', this.responseText || ''); }
+ catch (_) {}
+ });
+ return originalSend.apply(this, arguments);
+ };
+ })();
+ """#
+}
+
+final class ClaudeWebCaptureScriptHandler: NSObject, WKScriptMessageHandler {
+ weak var owner: ClaudeWebCaptureModel?
+
+ init(owner: ClaudeWebCaptureModel) {
+ self.owner = owner
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let payload = message.body as? [String: Any] else { return }
+ Task { @MainActor [weak owner] in owner?.record(payload: payload) }
+ }
+}
+
+final class ClaudeWebCaptureNavigationDelegate: NSObject, WKNavigationDelegate {
+ weak var owner: ClaudeWebCaptureModel?
+
+ init(owner: ClaudeWebCaptureModel) {
+ self.owner = owner
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ Task { @MainActor [weak webView, weak owner] in owner?.didFinishNavigation(url: webView?.url) }
+ }
+}
+
+struct ClaudeWebCaptureWebView: NSViewRepresentable {
+ @ObservedObject var model: ClaudeWebCaptureModel
+
+ func makeNSView(context: Context) -> WKWebView { model.webView }
+
+ func updateNSView(_ nsView: WKWebView, context: Context) {}
+}
+
struct Sparkline: View {
let values: [Double]
@@ -739,17 +1222,6 @@ struct TagLabel: View {
}
}
-struct Triangle: Shape {
- func path(in rect: CGRect) -> Path {
- var path = Path()
- path.move(to: CGPoint(x: rect.midX, y: rect.minY))
- path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
- path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
- path.closeSubpath()
- return path
- }
-}
-
enum CPTheme {
static let background = Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255)
static let surface = Color.white
@@ -760,6 +1232,17 @@ enum CPTheme {
static let tertiaryText = primaryText.opacity(0.46)
static let accent = Color(red: 74 / 255, green: 91 / 255, blue: 122 / 255)
+ static func providerColor(_ provider: Provider) -> Color {
+ switch provider {
+ case .openAI:
+ Color(red: 56 / 255, green: 92 / 255, blue: 126 / 255)
+ case .anthropic:
+ Color(red: 139 / 255, green: 102 / 255, blue: 51 / 255)
+ case .google:
+ Color(red: 35 / 255, green: 116 / 255, blue: 106 / 255)
+ }
+ }
+
static func statusColor(_ status: UsageStatus) -> Color {
switch status {
case .healthy:
@@ -799,51 +1282,93 @@ extension UsageSnapshot {
var tightestSupportText: String {
guard let tightestLimit else { return "Add OpenAI, Anthropic, or Google." }
- return "\(tightestLimit.label) ยท \(tightestLimit.accountName) โ your tightest account"
+ return "\(tightestLimit.provider.shortName) ยท \(tightestLimit.displayLabel) ยท \(tightestLimit.contextLabel)"
+ }
+
+ var tightestCapacityRatio: Double {
+ guard let ratio = tightestLimit?.usageRatio else { return 0 }
+ return max(1 - ratio, 0)
+ }
+
+ var fastModeForecast: FastModePortfolioForecast {
+ let forecasts = limits
+ .filter { $0.provider == .openAI && $0.unit == .percent }
+ .map { limit in
+ FastModeForecast(input: FastModeForecastInput(
+ limit: limit,
+ now: Date(),
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
+ reserveUnits: 6,
+ minimumSafeHours: 1
+ ))
+ }
+ return FastModePortfolioForecast(forecasts: forecasts)
+ }
+
+ var providerPressureText: String {
+ let limited = limits.filter { $0.status == .limited }.count
+ let close = limits.filter { $0.status == .close }.count
+ if limited > 0 || close > 0 {
+ return "\(limited) limited ยท \(close) close"
+ }
+ return "all tracked windows healthy"
+ }
+
+ var nearestResetText: String {
+ let futureResets = limits.compactMap(\.resetsAt).filter { $0 > Date() }.sorted()
+ guard let reset = futureResets.first else { return "reset unknown" }
+ return "nearest reset \(reset.widgetRelativeText)"
}
}
extension UsageLimit {
var compactUsageText: String {
+ if provider == .anthropic, unit == .unknown, status == .unknown {
+ return "unknown"
+ }
guard let used, let limit else { return status == .failure ? "โ" : "?" }
return "\(used)/\(limit)"
}
var percentText: String {
+ if provider == .anthropic, unit == .unknown, status == .unknown {
+ return "unknown"
+ }
guard let usageRatio else { return status == .failure ? "โ" : "?" }
return "\(Int(usageRatio * 100))%"
}
var resetText: String {
- switch status {
- case .failure:
- "refresh failed"
- case .unknown:
- "unknown"
- default:
- switch label {
- case "Image generation":
- "42m"
- case "Claude Opus":
- "1h 15m"
- case "GPT-5":
- "3h 20m"
- case "GPT-5 Thinking":
- "tomorrow 9:00"
- default:
- "tonight"
- }
+ if status == .failure { return "refresh failed" }
+ guard let resetsAt else { return "unknown reset" }
+ if resetsAt < Date().addingTimeInterval(-60) { return "reset passed" }
+ return "resets \(resetsAt.widgetRelativeText)"
+ }
+}
+
+extension Date {
+ var widgetRelativeText: String {
+ let seconds = Int(timeIntervalSince(Date()))
+ if abs(seconds) < 60 { return "now" }
+ if seconds >= 0 {
+ let minutes = seconds / 60
+ if minutes < 60 { return "in \(minutes)m" }
+ let hours = minutes / 60
+ if hours < 24 { return "in \(hours)h" }
+ return "in \(hours / 24)d"
}
+ let elapsed = abs(seconds)
+ let minutes = elapsed / 60
+ if minutes < 60 { return "\(minutes)m ago" }
+ let hours = minutes / 60
+ if hours < 24 { return "\(hours)h ago" }
+ return "\(hours / 24)d ago"
}
}
extension [UsageStatus] {
var worstStatus: UsageStatus {
- if contains(.limited) { return .limited }
- if contains(.failure) { return .failure }
- if contains(.close) { return .close }
- if contains(.stale) { return .stale }
- if contains(.unknown) { return .unknown }
- return .healthy
+ contextPanelWorstStatus
}
}
diff --git a/Sources/ContextPanelPreview/SampleUsageData.swift b/Sources/ContextPanelPreview/SampleUsageData.swift
index c4ae20a..42fd6ac 100644
--- a/Sources/ContextPanelPreview/SampleUsageData.swift
+++ b/Sources/ContextPanelPreview/SampleUsageData.swift
@@ -12,7 +12,10 @@ enum SampleUsageData {
provider: .openAI,
accountID: "openai-personal",
accountName: "Personal",
- label: "GPT-5",
+ label: "GPT-5 5-hour",
+ windowLabel: "5-hour",
+ modelLabel: "GPT-5",
+ unit: .percent,
used: 72,
limit: 100,
resetsAt: referenceNow.addingTimeInterval(12_000),
@@ -23,9 +26,12 @@ enum SampleUsageData {
provider: .openAI,
accountID: "openai-work",
accountName: "Work",
- label: "GPT-5 Thinking",
+ label: "GPT-5 Thinking Weekly",
+ windowLabel: "Weekly",
+ modelLabel: "GPT-5 Thinking",
+ unit: .percent,
used: 18,
- limit: 40,
+ limit: 100,
resetsAt: referenceNow.addingTimeInterval(86_400),
lastUpdatedAt: referenceNow.addingTimeInterval(-120),
confidence: .estimated,
@@ -35,9 +41,12 @@ enum SampleUsageData {
provider: .openAI,
accountID: "openai-team",
accountName: "Team",
- label: "Image generation",
+ label: "Image generation Hourly",
+ windowLabel: "Hourly",
+ modelLabel: "Image generation",
+ unit: .percent,
used: 49,
- limit: 50,
+ limit: 100,
resetsAt: referenceNow.addingTimeInterval(2_520),
lastUpdatedAt: referenceNow.addingTimeInterval(-120),
confidence: .observed
@@ -46,7 +55,9 @@ enum SampleUsageData {
provider: .anthropic,
accountID: "anthropic-personal",
accountName: "Personal",
- label: "Claude Opus",
+ label: "Claude Opus 5-hour",
+ windowLabel: "5-hour",
+ modelLabel: "Claude Opus",
used: 38,
limit: 45,
resetsAt: referenceNow.addingTimeInterval(4_500),
@@ -57,7 +68,9 @@ enum SampleUsageData {
provider: .anthropic,
accountID: "anthropic-work",
accountName: "Work",
- label: "Claude Sonnet",
+ label: "Claude Sonnet Daily",
+ windowLabel: "Daily",
+ modelLabel: "Claude Sonnet",
used: 12,
limit: 100,
resetsAt: referenceNow.addingTimeInterval(21_600),
@@ -69,6 +82,7 @@ enum SampleUsageData {
accountID: "google-personal",
accountName: "Personal",
label: "Gemini Pro",
+ modelLabel: "Gemini Pro",
used: nil,
limit: nil,
lastUpdatedAt: referenceNow.addingTimeInterval(-120),
@@ -81,6 +95,7 @@ enum SampleUsageData {
accountID: "google-work",
accountName: "Work",
label: "Gemini Deep Research",
+ modelLabel: "Gemini Deep Research",
used: nil,
limit: nil,
lastUpdatedAt: referenceNow.addingTimeInterval(-21_600),
diff --git a/Sources/ContextPanelWidget/ContextPanelWidget.swift b/Sources/ContextPanelWidget/ContextPanelWidget.swift
new file mode 100644
index 0000000..101a103
--- /dev/null
+++ b/Sources/ContextPanelWidget/ContextPanelWidget.swift
@@ -0,0 +1,112 @@
+import ContextPanelCore
+import SwiftUI
+import WidgetKit
+
+struct ContextPanelWidgetEntry: TimelineEntry {
+ let date: Date
+ let snapshot: WidgetSnapshot
+}
+
+struct ContextPanelTimelineProvider: TimelineProvider {
+ let store: JSONSnapshotStore
+
+ init(
+ store: JSONSnapshotStore = JSONSnapshotStore(
+ rootDirectory: ContextPanelLocations.snapshotDirectory(appGroupID: ContextPanelLocations.appGroupID)
+ )
+ ) {
+ self.store = store
+ }
+
+ func placeholder(in context: Context) -> ContextPanelWidgetEntry {
+ ContextPanelWidgetEntry(date: Date(), snapshot: .placeholder)
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (ContextPanelWidgetEntry) -> Void) {
+ completion(entry(date: Date()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let now = Date()
+ let nextRefresh = Calendar.current.date(byAdding: .minute, value: 10, to: now) ?? now.addingTimeInterval(600)
+ completion(Timeline(entries: [entry(date: now)], policy: .after(nextRefresh)))
+ }
+
+ private func entry(date: Date) -> ContextPanelWidgetEntry {
+ let result = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 20 * 60), now: date)
+ return ContextPanelWidgetEntry(date: date, snapshot: WidgetSnapshot.fromStore(result, now: date))
+ }
+}
+
+struct ContextPanelWidgetView: View {
+ @Environment(\.widgetFamily) private var family
+ let entry: ContextPanelWidgetEntry
+
+ var body: some View {
+ switch family {
+ case .systemSmall:
+ ContextPanelSmallWidget(snapshot: entry.snapshot)
+ case .systemLarge, .systemExtraLarge:
+ ContextPanelLargeWidget(snapshot: entry.snapshot)
+ default:
+ ContextPanelMediumWidget(snapshot: entry.snapshot)
+ }
+ }
+}
+
+@main
+struct ContextPanelWidget: Widget {
+ let kind = "ContextPanelWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: ContextPanelTimelineProvider()) { entry in
+ ContextPanelWidgetView(entry: entry)
+ .containerBackground(CPWTheme.surface, for: .widget)
+ }
+ .configurationDisplayName("Context Panel")
+ .description("AI account usage limits, reset timing, and fast-mode safety from local snapshots.")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
+
+extension WidgetSnapshot {
+ static var placeholder: WidgetSnapshot {
+ let now = Date()
+ return WidgetSnapshot(
+ state: .ready,
+ generatedAt: now,
+ limits: [
+ UsageLimit(
+ provider: .openAI,
+ accountID: "placeholder-openai",
+ accountName: "OpenAI",
+ label: "Codex Weekly",
+ windowLabel: "Weekly",
+ modelLabel: "Codex",
+ unit: .percent,
+ used: 52,
+ limit: 100,
+ resetsAt: now.addingTimeInterval(18_000),
+ lastUpdatedAt: now.addingTimeInterval(-90),
+ confidence: .observed
+ ),
+ UsageLimit(
+ provider: .google,
+ accountID: "placeholder-google",
+ accountName: "Gemini",
+ label: "gemini-3-pro-preview",
+ windowLabel: "Daily",
+ modelLabel: "gemini-3-pro-preview",
+ unit: .percent,
+ used: 12,
+ limit: 100,
+ resetsAt: now.addingTimeInterval(86_400),
+ lastUpdatedAt: now.addingTimeInterval(-90),
+ confidence: .observed
+ ),
+ ],
+ status: .healthy,
+ message: "Fast mode looks safe."
+ )
+ }
+}
diff --git a/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift
new file mode 100644
index 0000000..01a24f8
--- /dev/null
+++ b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift
@@ -0,0 +1,516 @@
+import ContextPanelCore
+import SwiftUI
+
+struct ContextPanelSmallWidget: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ CPWHeader(status: snapshot.status)
+ Spacer(minLength: 4)
+ if let tightest = snapshot.mostConstrainedLimits.first {
+ Text(snapshot.fastModeVerdict)
+ .font(.system(size: 26, weight: .semibold))
+ .foregroundStyle(CPWTheme.primaryText)
+ .minimumScaleFactor(0.75)
+ .lineLimit(2)
+ Text("\(tightest.provider.shortName) ยท \(tightest.displayLabel) ยท \(tightest.widgetUsageText)")
+ .font(.system(size: 11))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .lineLimit(2)
+ Text(tightest.widgetResetText)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(CPWTheme.tertiaryText)
+ .lineLimit(1)
+ } else {
+ Text("Set up accounts")
+ .font(.system(size: 22, weight: .semibold))
+ .foregroundStyle(CPWTheme.primaryText)
+ .lineLimit(2)
+ Text(snapshot.message)
+ .font(.system(size: 11))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .lineLimit(2)
+ }
+ Spacer(minLength: 4)
+ CPWProviderMiniStatus(snapshot: snapshot)
+ }
+ .padding(16)
+ }
+}
+
+struct ContextPanelMediumWidget: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ HStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 10) {
+ CPWHeader(status: snapshot.status)
+ Spacer(minLength: 0)
+ CPWCapacityDial(
+ value: snapshot.tightestCapacityRatio,
+ status: snapshot.status,
+ label: "\(Int(snapshot.tightestCapacityRatio * 100))",
+ sublabel: "tightest",
+ size: 86
+ )
+ Text(snapshot.fastModeVerdict)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .lineLimit(2)
+ Spacer(minLength: 0)
+ }
+ .frame(width: 142, alignment: .leading)
+
+ Divider()
+
+ VStack(alignment: .leading, spacing: 8) {
+ CPWSectionHeader(title: "Most Constrained", trailing: "\(snapshot.limits.count) limits")
+ ForEach(snapshot.mostConstrainedLimits.prefix(4)) { limit in
+ CPWLimitRow(limit: limit)
+ }
+ if snapshot.limits.isEmpty {
+ CPWEmptyRow(message: snapshot.message)
+ }
+ }
+ }
+ .padding(16)
+ }
+}
+
+struct ContextPanelLargeWidget: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(alignment: .top, spacing: 12) {
+ VStack(alignment: .leading, spacing: 6) {
+ CPWLabel("Context Panel")
+ Text(snapshot.fastModeVerdict)
+ .font(.system(size: 24, weight: .semibold))
+ .foregroundStyle(CPWTheme.primaryText)
+ .lineLimit(2)
+ Text(snapshot.state.rawValue)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .textCase(.uppercase)
+ }
+ Spacer()
+ CPWCapacityDial(
+ value: snapshot.tightestCapacityRatio,
+ status: snapshot.status,
+ label: "\(Int(snapshot.tightestCapacityRatio * 100))",
+ sublabel: "tightest",
+ size: 82
+ )
+ }
+
+ CPWProviderSummaryGrid(snapshot: snapshot)
+
+ Divider()
+
+ VStack(alignment: .leading, spacing: 8) {
+ CPWFastModeCard(snapshot: snapshot)
+
+ CPWSectionHeader(title: "Account Limits", trailing: snapshot.generatedAt.widgetRelativeText)
+ ForEach(snapshot.mostConstrainedLimits.prefix(6)) { limit in
+ CPWLimitRow(limit: limit)
+ }
+ if snapshot.limits.isEmpty {
+ CPWEmptyRow(message: snapshot.message)
+ }
+ }
+ }
+ .padding(16)
+ }
+}
+
+struct CPWHeader: View {
+ let status: UsageStatus
+
+ var body: some View {
+ HStack {
+ CPWLabel("Context Panel")
+ Spacer()
+ CPWStatusMark(status: status, size: 9)
+ }
+ }
+}
+
+struct CPWLimitRow: View {
+ let limit: UsageLimit
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ CPWProviderBadge(provider: limit.provider, compact: true)
+ Text(limit.displayLabel)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(CPWTheme.primaryText)
+ .lineLimit(1)
+ Text("ยท \(limit.contextLabel)")
+ .font(.system(size: 11))
+ .foregroundStyle(CPWTheme.tertiaryText)
+ .lineLimit(1)
+ Spacer(minLength: 6)
+ Text(limit.widgetUsageText)
+ .font(.system(size: 10, weight: .medium, design: .monospaced))
+ .foregroundStyle(CPWTheme.secondaryText)
+ }
+ HStack(spacing: 8) {
+ CPWCapacityBar(value: limit.usageRatio ?? 0, status: limit.status)
+ Text(limit.widgetResetText)
+ .font(.system(size: 10))
+ .foregroundStyle(CPWTheme.tertiaryText)
+ .lineLimit(1)
+ }
+ }
+ }
+}
+
+struct CPWProviderSummaryGrid: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ForEach(snapshot.providerSummaries, id: \.provider) { summary in
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 6) {
+ CPWProviderBadge(provider: summary.provider, compact: true)
+ Spacer()
+ CPWStatusMark(status: summary.status, size: 7)
+ }
+ Text(summary.limitCount == 0 ? "setup" : "\(Int(summary.capacityRatio * 100))% tightest room")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(CPWTheme.secondaryText)
+ CPWCapacityBar(value: 1 - summary.capacityRatio, status: summary.status)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+}
+
+struct CPWProviderMiniStatus: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ForEach(snapshot.providerSummaries, id: \.provider) { summary in
+ HStack(spacing: 5) {
+ CPWProviderBadge(provider: summary.provider, compact: true)
+ }
+ .foregroundStyle(CPWTheme.secondaryText)
+ .opacity(summary.limitCount == 0 ? 0.35 : 1)
+ }
+ }
+ }
+}
+
+struct CPWEmptyRow: View {
+ let message: String
+
+ var body: some View {
+ HStack(spacing: 8) {
+ CPWStatusMark(status: .unknown, size: 9)
+ Text(message)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .lineLimit(2)
+ }
+ }
+}
+
+struct CPWFastModeCard: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "bolt.fill")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(CPWTheme.fastModeColor(snapshot.fastModeStatus))
+ .frame(width: 18)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(snapshot.fastModeVerdict)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(CPWTheme.primaryText)
+ .lineLimit(1)
+ Text(snapshot.fastModeDetail)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 4)
+ }
+ .padding(.vertical, 8)
+ .padding(.horizontal, 10)
+ .background(CPWTheme.line.opacity(0.65))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+}
+
+struct CPWCapacityDial: View {
+ let value: Double
+ let status: UsageStatus
+ let label: String
+ let sublabel: String
+ var size: CGFloat = 86
+
+ var body: some View {
+ ZStack {
+ Circle().stroke(CPWTheme.line, lineWidth: 6)
+ Circle()
+ .trim(from: 0, to: min(max(value, 0), 1))
+ .stroke(CPWTheme.statusColor(status), style: StrokeStyle(lineWidth: 6, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ VStack(spacing: 0) {
+ Text(label)
+ .font(.system(size: 22, weight: .semibold, design: .monospaced))
+ .foregroundStyle(CPWTheme.primaryText)
+ Text(sublabel)
+ .font(.system(size: 9, weight: .medium))
+ .foregroundStyle(CPWTheme.tertiaryText)
+ .textCase(.uppercase)
+ }
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct CPWCapacityBar: View {
+ let value: Double
+ let status: UsageStatus
+
+ var body: some View {
+ GeometryReader { proxy in
+ ZStack(alignment: .leading) {
+ Capsule().fill(CPWTheme.line)
+ Capsule()
+ .fill(CPWTheme.statusColor(status))
+ .frame(width: proxy.size.width * min(max(value, 0), 1))
+ }
+ }
+ .frame(height: 4)
+ }
+}
+
+struct CPWProviderBadge: View {
+ let provider: Provider
+ var compact = false
+
+ var body: some View {
+ Text(provider.shortName)
+ .font(.system(size: compact ? 9 : 10, weight: .semibold, design: .monospaced))
+ .foregroundStyle(CPWTheme.providerColor(provider))
+ .lineLimit(1)
+ }
+}
+
+struct CPWStatusMark: View {
+ let status: UsageStatus
+ var size: CGFloat = 8
+
+ var body: some View {
+ Group {
+ switch status {
+ case .healthy:
+ Circle().fill(CPWTheme.statusColor(status))
+ case .close:
+ Circle().trim(from: 0, to: 0.75).stroke(CPWTheme.statusColor(status), lineWidth: 2)
+ case .limited:
+ RoundedRectangle(cornerRadius: 1).fill(CPWTheme.statusColor(status))
+ case .stale:
+ Circle().stroke(CPWTheme.statusColor(status), style: StrokeStyle(lineWidth: 1.4, dash: [2, 2]))
+ case .unknown:
+ Text("?").font(.system(size: size + 3, weight: .semibold)).foregroundStyle(CPWTheme.statusColor(status))
+ case .failure:
+ Image(systemName: "xmark").font(.system(size: size, weight: .bold)).foregroundStyle(CPWTheme.statusColor(status))
+ case .loading:
+ Circle().stroke(CPWTheme.statusColor(status), lineWidth: 1.4)
+ }
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct CPWSectionHeader: View {
+ let title: String
+ var trailing: String? = nil
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline) {
+ Text(title)
+ .font(.system(size: 10, weight: .semibold))
+ .tracking(0.8)
+ .textCase(.uppercase)
+ .foregroundStyle(CPWTheme.tertiaryText)
+ Spacer()
+ if let trailing {
+ Text(trailing)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(CPWTheme.tertiaryText)
+ }
+ }
+ }
+}
+
+struct CPWLabel: View {
+ let text: String
+
+ init(_ text: String) {
+ self.text = text
+ }
+
+ var body: some View {
+ HStack(spacing: 6) {
+ RoundedRectangle(cornerRadius: 1)
+ .fill(CPWTheme.accent)
+ .rotationEffect(.degrees(45))
+ .frame(width: 6, height: 6)
+ Text(text)
+ .font(.system(size: 10, weight: .semibold))
+ .tracking(0.8)
+ .textCase(.uppercase)
+ .foregroundStyle(CPWTheme.tertiaryText)
+ }
+ }
+}
+
+enum CPWTheme {
+ static let surface = Color(red: 250 / 255, green: 250 / 255, blue: 250 / 255)
+ static let line = Color.black.opacity(0.08)
+ static let primaryText = Color(red: 10 / 255, green: 10 / 255, blue: 11 / 255)
+ static let secondaryText = primaryText.opacity(0.66)
+ static let tertiaryText = primaryText.opacity(0.46)
+ static let accent = Color(red: 74 / 255, green: 91 / 255, blue: 122 / 255)
+
+ static func providerColor(_ provider: Provider) -> Color {
+ switch provider {
+ case .openAI:
+ Color(red: 56 / 255, green: 92 / 255, blue: 126 / 255)
+ case .anthropic:
+ Color(red: 139 / 255, green: 102 / 255, blue: 51 / 255)
+ case .google:
+ Color(red: 35 / 255, green: 116 / 255, blue: 106 / 255)
+ }
+ }
+
+ static func fastModeColor(_ recommendation: FastModeRecommendation?) -> Color {
+ switch recommendation {
+ case .safeThroughReset:
+ statusColor(.healthy)
+ case .safeForLimitedTime:
+ statusColor(.close)
+ case .saveFastMode, .limited:
+ statusColor(.limited)
+ case .needsCalibration, nil:
+ statusColor(.unknown)
+ }
+ }
+
+ static func statusColor(_ status: UsageStatus) -> Color {
+ switch status {
+ case .healthy:
+ Color(red: 74 / 255, green: 122 / 255, blue: 91 / 255)
+ case .close:
+ Color(red: 138 / 255, green: 106 / 255, blue: 42 / 255)
+ case .limited, .failure:
+ Color(red: 138 / 255, green: 74 / 255, blue: 74 / 255)
+ case .stale, .unknown, .loading:
+ Color(red: 106 / 255, green: 106 / 255, blue: 114 / 255)
+ }
+ }
+}
+
+extension UsageLimit {
+ var widgetUsageText: String {
+ if provider == .anthropic, unit == .unknown, status == .unknown {
+ return "allowance unknown"
+ }
+ if let remaining, let limit {
+ return "\(remaining)/\(limit) left"
+ }
+ if status == .failure { return "refresh failed" }
+ return "unknown"
+ }
+
+ var widgetResetText: String {
+ guard let resetsAt else {
+ return status == .failure ? "refresh failed" : "unknown reset"
+ }
+ if resetsAt < Date().addingTimeInterval(-60) {
+ return "reset passed"
+ }
+ return "resets \(resetsAt.widgetRelativeText)"
+ }
+}
+
+extension WidgetSnapshot {
+ var tightestCapacityRatio: Double {
+ guard let ratio = mostConstrainedLimits.first?.usageRatio else { return 0 }
+ return max(1 - ratio, 0)
+ }
+
+ var fastModeForecast: FastModeForecast? {
+ let forecasts = limits
+ .filter { $0.provider == .openAI && $0.unit == .percent }
+ .map { limit in
+ FastModeForecast(input: FastModeForecastInput(
+ limit: limit,
+ now: Date(),
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
+ reserveUnits: 6,
+ minimumSafeHours: 1
+ ))
+ }
+ return FastModePortfolioForecast(forecasts: forecasts).bestForecast
+ }
+
+ var fastModeStatus: FastModeRecommendation? {
+ fastModeForecast?.recommendation
+ }
+
+ var fastModeVerdict: String {
+ fastModeForecast?.copy ?? message
+ }
+
+ var fastModeDetail: String {
+ guard let forecast = fastModeForecast else { return "OpenAI account needed for fast-mode forecast" }
+ let runway = forecast.fastModeRunwayHours.map { "runway \(Self.format(hours: $0))" } ?? "runway unknown"
+ let reset = forecast.hoursUntilReset.map { "reset \(Self.format(hours: $0))" } ?? "reset unknown"
+ return "\(forecast.accountName) ยท \(runway) ยท \(reset)"
+ }
+
+ private static func format(hours: Double) -> String {
+ if hours < 1 {
+ return "\(max(Int((hours * 60).rounded()), 1))m"
+ }
+ if hours < 10 {
+ let rounded = (hours * 2).rounded() / 2
+ if rounded == rounded.rounded() { return "\(Int(rounded))h" }
+ return "\(rounded)h"
+ }
+ return "\(Int(hours.rounded()))h"
+ }
+}
+
+extension Date {
+ var widgetRelativeText: String {
+ let seconds = Int(timeIntervalSince(Date()))
+ if abs(seconds) < 60 { return "now" }
+ if seconds >= 0 {
+ let minutes = seconds / 60
+ if minutes < 60 { return "in \(minutes)m" }
+ let hours = minutes / 60
+ if hours < 24 { return "in \(hours)h" }
+ return "in \(hours / 24)d"
+ }
+ let elapsed = abs(seconds)
+ let minutes = elapsed / 60
+ if minutes < 60 { return "\(minutes)m ago" }
+ let hours = minutes / 60
+ if hours < 24 { return "\(hours)h ago" }
+ return "\(hours / 24)d ago"
+ }
+}
diff --git a/Sources/GeminiQuotaProbe/main.swift b/Sources/GeminiQuotaProbe/main.swift
new file mode 100644
index 0000000..b3d6e6d
--- /dev/null
+++ b/Sources/GeminiQuotaProbe/main.swift
@@ -0,0 +1,132 @@
+import ContextPanelCore
+import Foundation
+
+struct GeminiProbeError: LocalizedError {
+ let message: String
+
+ var errorDescription: String? { message }
+}
+struct ProbeConfiguration {
+ let account: GeminiAccountConfiguration
+
+ static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration {
+ var authPath: String?
+ var tokenEndpoint = URL(string: "https://oauth2.googleapis.com/token")!
+ var codeAssistEndpoint = URL(string: "https://cloudcode-pa.googleapis.com/v1internal")!
+ var metadata = GeminiOAuthClientMetadataDiscovery.discover()
+ var iterator = arguments.dropFirst().makeIterator()
+
+ while let argument = iterator.next() {
+ switch argument {
+ case "--auth":
+ guard let value = iterator.next() else {
+ throw GeminiProbeError(message: "--auth requires a path")
+ }
+ authPath = value
+ case "--token-endpoint":
+ guard let value = iterator.next(), let url = URL(string: value) else {
+ throw GeminiProbeError(message: "--token-endpoint requires an absolute URL")
+ }
+ tokenEndpoint = url
+ case "--code-assist-endpoint":
+ guard let value = iterator.next(), let url = URL(string: value) else {
+ throw GeminiProbeError(message: "--code-assist-endpoint requires an absolute URL")
+ }
+ codeAssistEndpoint = url
+ case "--client-id":
+ guard let value = iterator.next(), !value.isEmpty else {
+ throw GeminiProbeError(message: "--client-id requires a value")
+ }
+ metadata = GeminiOAuthClientMetadata(
+ clientID: value,
+ clientSecret: metadata?.clientSecret ?? ""
+ )
+ case "--client-secret":
+ guard let value = iterator.next(), !value.isEmpty else {
+ throw GeminiProbeError(message: "--client-secret requires a value")
+ }
+ metadata = GeminiOAuthClientMetadata(
+ clientID: metadata?.clientID ?? "",
+ clientSecret: value
+ )
+ case "--help", "-h":
+ printHelp()
+ Foundation.exit(0)
+ default:
+ throw GeminiProbeError(message: "unknown argument: \(argument)")
+ }
+ }
+
+ guard let metadata, !metadata.clientID.isEmpty, !metadata.clientSecret.isEmpty else {
+ throw GeminiProbeError(message: "install Gemini CLI, set GEMINI_OAUTH_CLIENT_ID/SECRET, or pass --client-id/--client-secret")
+ }
+
+ return ProbeConfiguration(account: GeminiAccountConfiguration(
+ authPath: authPath ?? defaultAuthPath(),
+ tokenEndpoint: tokenEndpoint,
+ codeAssistEndpoint: codeAssistEndpoint,
+ clientID: metadata.clientID,
+ clientSecret: metadata.clientSecret
+ ))
+ }
+
+ private static func defaultAuthPath() -> String {
+ let environment = ProcessInfo.processInfo.environment
+ let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path
+ let geminiHome = environment["GEMINI_CLI_HOME"] ?? "\(home)/.gemini"
+ return "\(geminiHome)/oauth_creds.json"
+ }
+
+ private static func printHelp() {
+ print("""
+ Usage: swift run GeminiQuotaProbe [--auth /path/to/oauth_creds.json]
+
+ Uses the locally installed Gemini CLI OAuth client metadata when
+ available. You can also set GEMINI_OAUTH_CLIENT_ID and
+ GEMINI_OAUTH_CLIENT_SECRET, or pass --client-id and --client-secret.
+ Do not commit OAuth client values to this repository.
+
+ Uses the production Gemini Code Assist connector and prints only a
+ redacted quota summary. Tokens, account identifiers, project IDs,
+ emails, headers, and raw response bodies are never printed.
+ """)
+ }
+}
+
+@main
+struct GeminiQuotaProbe {
+ static func main() async {
+ do {
+ let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments)
+ let connector = GeminiCodeAssistConnector(accounts: [configuration.account])
+ let result = await connector.refresh(now: Date())
+ printSummary(result: result, account: configuration.account)
+ } catch {
+ fputs("GeminiQuotaProbe failed: \(error.localizedDescription)\n", stderr)
+ Foundation.exit(1)
+ }
+ }
+
+ private static func printSummary(result: ConnectorRefreshResult, account: GeminiAccountConfiguration) {
+ print("Gemini Code Assist quota probe")
+ print("endpoint: \(account.codeAssistEndpoint.absoluteString)")
+ print("auth: \(ConnectorRedactor.redactedPath(account.authPath))")
+ print("accounts: \(result.reports.count)")
+ print("limits: \(result.snapshot.limits.count)")
+ print("redacted: tokens, account identifiers, project IDs, emails, headers, raw response bodies")
+ print("")
+
+ for report in result.reports {
+ print("- \(report.accountName): \(report.status.rawValue)")
+ if let errorMessage = report.errorMessage {
+ print(" error: \(errorMessage)")
+ }
+ for limit in report.limits.sorted(by: { $0.label < $1.label }) {
+ let used = limit.used.map { "\($0)% used" } ?? "unknown used"
+ let remaining = limit.remaining.map { "\($0)% remaining" } ?? "unknown remaining"
+ let reset = limit.resetsAt.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown reset"
+ print(" - \(limit.label): \(used), \(remaining), resets \(reset)")
+ }
+ }
+ }
+}
diff --git a/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift
new file mode 100644
index 0000000..3a0c712
--- /dev/null
+++ b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift
@@ -0,0 +1,426 @@
+import ContextPanelCore
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+
+@main
+struct OpenAILimitProbeApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ProbeRootView()
+ .frame(minWidth: 1180, minHeight: 760)
+ }
+ }
+}
+
+struct ProbeRootView: View {
+ @StateObject private var model = ProbeModel()
+
+ var body: some View {
+ HStack(spacing: 0) {
+ VStack(alignment: .leading, spacing: 14) {
+ header
+ controls
+ Divider()
+ ObservationList(observations: model.observations)
+ NetworkEventList(events: model.networkEvents)
+ Spacer()
+ safetyFooter
+ }
+ .frame(width: 360)
+ .padding(18)
+ .background(Color(nsColor: .controlBackgroundColor))
+
+ Divider()
+
+ ProbeWebView(model: model)
+ }
+ .fileExporter(
+ isPresented: $model.isExportingReport,
+ document: ProbeReportDocument(report: model.reportMarkdown),
+ contentType: .plainText,
+ defaultFilename: "openai-limit-probe-report.md"
+ ) { _ in }
+ }
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("OpenAI Limit Probe")
+ .font(.system(size: 24, weight: .semibold))
+ Text("Log in directly with OpenAI, navigate to ChatGPT/model picker, then scan visible text for subscription limit signals.")
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var controls: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Button("Open ChatGPT") {
+ model.loadChatGPT()
+ }
+ Button("Scan Visible Text") {
+ model.scanVisibleText()
+ }
+ .keyboardShortcut("s", modifiers: [.command])
+ }
+
+ HStack {
+ Button("Record Manual Observation") {
+ model.recordManualObservation()
+ }
+ Button("Export Redacted Report") {
+ model.exportReport()
+ }
+ .keyboardShortcut("e", modifiers: [.command])
+ }
+
+ TextField("Manual note, e.g. resets tomorrow 9:00 AM", text: $model.manualObservation)
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+
+ private var safetyFooter: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Label("No passwords, cookies, auth headers, or raw response bodies are exported.", systemImage: "lock.shield")
+ Label("Network capture stores only method/path/status/body size and matching field names.", systemImage: "point.3.connected.trianglepath.dotted")
+ }
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+}
+
+struct NetworkEventList: View {
+ let events: [NetworkProbeEvent]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Network candidates")
+ .font(.system(size: 12, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(events.count)")
+ .font(.system(.caption, design: .monospaced, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ if events.isEmpty {
+ Text("No candidate response fields yet. Open model menus or settings, then scan/navigate.")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(events.prefix(12)) { event in
+ VStack(alignment: .leading, spacing: 4) {
+ Text("\(event.method) \(event.pathHint)")
+ .font(.system(size: 11, design: .monospaced))
+ .lineLimit(2)
+ Text(event.matchedFields.joined(separator: ", "))
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ Text("status \(event.status.map(String.init) ?? "?") ยท \(event.bodySize ?? 0)b")
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(nsColor: .textBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+ }
+ }
+ .frame(maxHeight: 220)
+ }
+ }
+ }
+}
+
+struct ObservationList: View {
+ let observations: [LimitProbeObservation]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Observations")
+ .font(.system(size: 12, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(observations.count)")
+ .font(.system(.caption, design: .monospaced, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ if observations.isEmpty {
+ ContentUnavailableView(
+ "No signals yet",
+ systemImage: "waveform.path.ecg.rectangle",
+ description: Text("Open ChatGPT, log in, navigate to the model picker, and scan visible text.")
+ )
+ .frame(maxHeight: 220)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(observations) { observation in
+ VStack(alignment: .leading, spacing: 4) {
+ Text(observation.signalKind.rawValue)
+ .font(.system(size: 12, weight: .semibold))
+ Text(observation.sanitizedEvidence)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ Text("\(observation.source.rawValue) ยท \(observation.confidence.rawValue)")
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(nsColor: .textBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@MainActor
+final class ProbeModel: ObservableObject {
+ @Published var observations: [LimitProbeObservation] = []
+ @Published var networkEvents: [NetworkProbeEvent] = []
+ @Published var manualObservation = ""
+ @Published var isExportingReport = false
+
+ lazy var webView: WKWebView = {
+ let configuration = WKWebViewConfiguration()
+ configuration.websiteDataStore = .nonPersistent()
+ configuration.userContentController.add(ProbeScriptHandler(owner: self), name: "limitProbe")
+ configuration.userContentController.addUserScript(
+ WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false)
+ )
+ return WKWebView(frame: .zero, configuration: configuration)
+ }()
+
+ init() {
+ loadChatGPT()
+ }
+
+ var reportMarkdown: String {
+ LimitProbeReport(
+ provider: .openAI,
+ capturedAt: Date(),
+ observations: observations,
+ networkEvents: networkEvents
+ ).markdownSummary
+ }
+
+ func loadChatGPT() {
+ webView.load(URLRequest(url: URL(string: "https://chatgpt.com/")!))
+ }
+
+ func scanVisibleText() {
+ let script = "document.body ? document.body.innerText : ''"
+ webView.evaluateJavaScript(script) { [weak self] result, error in
+ Task { @MainActor in
+ guard let self else { return }
+ if let text = result as? String {
+ let newObservations = LimitProbeScanner.scanVisibleText(text, provider: .openAI)
+ self.merge(newObservations)
+ } else if let error {
+ self.merge([
+ LimitProbeObservation(
+ provider: .openAI,
+ observedAt: Date(),
+ source: .visibleText,
+ signalKind: .limitReached,
+ confidence: .unknown,
+ sanitizedEvidence: "Scan failed: \(error.localizedDescription)"
+ )
+ ])
+ }
+ }
+ }
+ }
+
+ func recordManualObservation() {
+ let trimmed = manualObservation.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ merge([
+ LimitProbeObservation(
+ provider: .openAI,
+ observedAt: Date(),
+ source: .manualUserEntry,
+ signalKind: .resetLanguage,
+ confidence: .manual,
+ sanitizedEvidence: trimmed
+ )
+ ])
+ manualObservation = ""
+ }
+
+ func exportReport() {
+ isExportingReport = true
+ }
+
+ private func merge(_ newObservations: [LimitProbeObservation]) {
+ var keys = Set(observations.map { "\($0.signalKind.rawValue):\($0.sanitizedEvidence.lowercased())" })
+ for observation in newObservations {
+ let key = "\(observation.signalKind.rawValue):\(observation.sanitizedEvidence.lowercased())"
+ guard !keys.contains(key) else { continue }
+ keys.insert(key)
+ observations.append(observation)
+ }
+ }
+
+ fileprivate func record(networkEvent event: NetworkProbeEvent) {
+ let key = "\(event.method):\(event.pathHint):\(event.matchedFields.joined(separator: ","))"
+ let existing = Set(networkEvents.map { "\($0.method):\($0.pathHint):\($0.matchedFields.joined(separator: ","))" })
+ guard !existing.contains(key), !event.matchedFields.isEmpty else { return }
+ networkEvents.insert(event, at: 0)
+ }
+
+ private static let networkProbeScript = #"""
+ (() => {
+ if (window.__contextPanelLimitProbeInstalled) return;
+ window.__contextPanelLimitProbeInstalled = true;
+ const candidate = /limit|usage|remaining|reset|quota|message|model|plan|cap/i;
+
+ function pathHint(rawUrl) {
+ try { return new URL(rawUrl, window.location.href).pathname; }
+ catch (_) { return String(rawUrl || '').split('?')[0].slice(0, 180); }
+ }
+
+ function collectFields(value, prefix = '', out = new Set(), depth = 0) {
+ if (!value || depth > 3 || out.size > 80) return out;
+ if (Array.isArray(value)) {
+ value.slice(0, 3).forEach(item => collectFields(item, prefix, out, depth + 1));
+ return out;
+ }
+ if (typeof value !== 'object') return out;
+ Object.keys(value).forEach(key => {
+ const full = prefix ? `${prefix}.${key}` : key;
+ if (candidate.test(full)) out.add(full);
+ collectFields(value[key], full, out, depth + 1);
+ });
+ return out;
+ }
+
+ function post(event) {
+ try { window.webkit.messageHandlers.limitProbe.postMessage(event); }
+ catch (_) {}
+ }
+
+ function inspect(method, url, status, contentType, text) {
+ const body = String(text || '');
+ let fields = [];
+ if (/json/i.test(contentType || '') && body.length < 2_000_000) {
+ try { fields = Array.from(collectFields(JSON.parse(body))).slice(0, 40); }
+ catch (_) { fields = []; }
+ }
+ if (fields.length === 0) return;
+ post({
+ method: method || 'GET',
+ pathHint: pathHint(url),
+ status: status || null,
+ contentType: contentType || null,
+ bodySize: body.length,
+ matchedFields: fields
+ });
+ }
+
+ const originalFetch = window.fetch;
+ if (originalFetch) {
+ window.fetch = async function(input, init) {
+ const response = await originalFetch.apply(this, arguments);
+ try {
+ const clone = response.clone();
+ const url = typeof input === 'string' ? input : (input && input.url) || '';
+ const method = (init && init.method) || (input && input.method) || 'GET';
+ const contentType = clone.headers.get('content-type') || '';
+ clone.text().then(text => inspect(method, url, clone.status, contentType, text)).catch(() => {});
+ } catch (_) {}
+ return response;
+ };
+ }
+
+ const originalOpen = XMLHttpRequest.prototype.open;
+ const originalSend = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function(method, url) {
+ this.__cpMethod = method;
+ this.__cpUrl = url;
+ return originalOpen.apply(this, arguments);
+ };
+ XMLHttpRequest.prototype.send = function() {
+ this.addEventListener('load', function() {
+ try {
+ const contentType = this.getResponseHeader('content-type') || '';
+ inspect(this.__cpMethod || 'GET', this.__cpUrl || '', this.status, contentType, this.responseText || '');
+ } catch (_) {}
+ });
+ return originalSend.apply(this, arguments);
+ };
+ })();
+ """#
+}
+
+final class ProbeScriptHandler: NSObject, WKScriptMessageHandler {
+ weak var owner: ProbeModel?
+
+ init(owner: ProbeModel) {
+ self.owner = owner
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let payload = message.body as? [String: Any] else { return }
+ let method = payload["method"] as? String ?? "GET"
+ let pathHint = payload["pathHint"] as? String ?? ""
+ let status = payload["status"] as? Int
+ let contentType = payload["contentType"] as? String
+ let bodySize = payload["bodySize"] as? Int
+ let matchedFields = payload["matchedFields"] as? [String] ?? []
+ let event = NetworkProbeEvent(
+ observedAt: Date(),
+ method: method,
+ pathHint: pathHint,
+ status: status,
+ contentType: contentType,
+ bodySize: bodySize,
+ matchedFields: matchedFields
+ )
+ Task { @MainActor [weak owner = self.owner] in
+ owner?.record(networkEvent: event)
+ }
+ }
+}
+
+struct ProbeWebView: NSViewRepresentable {
+ @ObservedObject var model: ProbeModel
+
+ func makeNSView(context: Context) -> WKWebView {
+ model.webView
+ }
+
+ func updateNSView(_ nsView: WKWebView, context: Context) {}
+}
+
+struct ProbeReportDocument: FileDocument {
+ static var readableContentTypes: [UTType] { [.plainText] }
+
+ var report: String
+
+ init(report: String) {
+ self.report = report
+ }
+
+ init(configuration: ReadConfiguration) throws {
+ report = ""
+ }
+
+ func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
+ FileWrapper(regularFileWithContents: Data(report.utf8))
+ }
+}
diff --git a/Sources/SnapshotStoreProbe/main.swift b/Sources/SnapshotStoreProbe/main.swift
new file mode 100644
index 0000000..1bfd4c4
--- /dev/null
+++ b/Sources/SnapshotStoreProbe/main.swift
@@ -0,0 +1,128 @@
+import ContextPanelCore
+import Foundation
+
+struct SnapshotStoreProbeError: LocalizedError {
+ let message: String
+
+ var errorDescription: String? { message }
+}
+
+struct ProbeConfiguration {
+ let outputDirectory: URL
+ let codexAccounts: [CodexAccountConfiguration]
+ let includeConfiguredAccounts: Bool
+ let includeClaude: Bool
+
+ static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration {
+ var outputDirectory: URL?
+ var codexAccounts: [CodexAccountConfiguration] = []
+ var includeConfiguredAccounts = false
+ var includeClaude = false
+ var iterator = arguments.dropFirst().makeIterator()
+
+ while let argument = iterator.next() {
+ switch argument {
+ case "--output":
+ guard let value = iterator.next() else {
+ throw SnapshotStoreProbeError(message: "--output requires a directory path")
+ }
+ outputDirectory = URL(fileURLWithPath: NSString(string: value).expandingTildeInPath)
+ case "--codex-auth":
+ guard let value = iterator.next() else {
+ throw SnapshotStoreProbeError(message: "--codex-auth requires a path")
+ }
+ codexAccounts.append(CodexAccountConfiguration(authPath: value))
+ case "--codex-account":
+ guard let value = iterator.next() else {
+ throw SnapshotStoreProbeError(message: "--codex-account requires label=path")
+ }
+ codexAccounts.append(try parseCodexAccount(value))
+ case "--configured-accounts":
+ includeConfiguredAccounts = true
+ case "--include-claude":
+ includeClaude = true
+ case "--help", "-h":
+ printHelp()
+ Foundation.exit(0)
+ default:
+ throw SnapshotStoreProbeError(message: "unknown argument: \(argument)")
+ }
+ }
+
+ return ProbeConfiguration(
+ outputDirectory: outputDirectory ?? defaultOutputDirectory(),
+ codexAccounts: codexAccounts,
+ includeConfiguredAccounts: includeConfiguredAccounts,
+ includeClaude: includeClaude
+ )
+ }
+
+ private static func parseCodexAccount(_ value: String) throws -> CodexAccountConfiguration {
+ let parts = value.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
+ guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else {
+ throw SnapshotStoreProbeError(message: "--codex-account requires label=path")
+ }
+ return CodexAccountConfiguration(authPath: String(parts[1]), accountName: String(parts[0]))
+ }
+
+ private static func defaultOutputDirectory() -> URL {
+ FileManager.default.temporaryDirectory
+ .appending(path: "context-panel-snapshot-store", directoryHint: .isDirectory)
+ }
+
+ private static func printHelp() {
+ print("""
+ Usage: swift run SnapshotStoreProbe [--output /tmp/context-panel-store] [--configured-accounts] [--codex-account Label=~/.codex/auth.json] [--include-claude]
+
+ Refreshes selected local connectors, writes the normalized snapshot to
+ the JSON snapshot store, then reloads it. The store contains normalized
+ usage state only; tokens, account identifiers, project IDs, emails,
+ headers, and raw provider responses are not persisted.
+ """)
+ }
+}
+
+@main
+struct SnapshotStoreProbe {
+ static func main() async {
+ do {
+ let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments)
+ let connectors = makeConnectors(configuration: configuration)
+ guard !connectors.isEmpty else {
+ throw SnapshotStoreProbeError(message: "no connectors selected")
+ }
+
+ let result = await ProviderConnectorRuntime(connectors: connectors).refreshAll()
+ let store = JSONSnapshotStore(rootDirectory: configuration.outputDirectory)
+ try store.save(StoredUsageSnapshot(savedAt: Date(), refreshResult: result))
+ let loaded = store.loadCurrent(policy: SnapshotStoreStalenessPolicy())
+
+ print("Context Panel snapshot store probe")
+ print("store: \(ConnectorRedactor.redactedPath(configuration.outputDirectory.path))")
+ print("reports: \(result.reports.count)")
+ print("limits: \(result.snapshot.limits.count)")
+ print("load status: \(loaded.status.rawValue)")
+ print("history entries: \(store.loadHistory().count)")
+ print("redacted: tokens, account identifiers, project IDs, emails, headers, raw response bodies")
+ } catch {
+ fputs("SnapshotStoreProbe failed: \(error.localizedDescription)\n", stderr)
+ Foundation.exit(1)
+ }
+ }
+
+ private static func makeConnectors(configuration: ProbeConfiguration) -> [any ProviderConnector] {
+ if configuration.includeConfiguredAccounts {
+ let store = AccountConfigurationStore(configurationURL: ContextPanelLocations.accountConfigurationURL())
+ return AccountConnectorFactory.connectors(from: store.load().document)
+ }
+
+ var connectors: [any ProviderConnector] = []
+ if !configuration.codexAccounts.isEmpty {
+ connectors.append(CodexRateLimitConnector(accounts: configuration.codexAccounts))
+ }
+ if configuration.includeClaude {
+ connectors.append(ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration()]))
+ }
+ return connectors
+ }
+}
diff --git a/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift
new file mode 100644
index 0000000..54844bb
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift
@@ -0,0 +1,124 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func accountConfigurationStoreReturnsDefaultsWhenMissing() throws {
+ let store = AccountConfigurationStore(configurationURL: try temporaryDirectory().appending(path: "accounts.json"))
+
+ let result = store.load(now: Date(timeIntervalSince1970: 0))
+
+ #expect(result.status == .unknown)
+ #expect(result.document.accounts.count == 3)
+ #expect(result.document.accounts.contains { $0.connectorKind == .codexRateLimits && $0.isEnabled })
+ #expect(result.document.accounts.contains { $0.connectorKind == .geminiCodeAssist && !$0.isEnabled })
+}
+
+@Test func accountConfigurationStoreRoundTripsAccounts() throws {
+ let url = try temporaryDirectory().appending(path: "accounts.json")
+ let store = AccountConfigurationStore(configurationURL: url)
+ let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 10), accounts: [
+ LocalProviderAccountConfiguration(
+ id: "codex-a",
+ provider: .openAI,
+ connectorKind: .codexRateLimits,
+ displayName: "OpenAI A",
+ authPath: "/tmp/codex-a.json"
+ )
+ ])
+
+ try store.save(document)
+ let result = store.load()
+
+ #expect(result.status == .healthy)
+ #expect(result.document == document)
+}
+
+@Test func accountConnectorFactorySkipsDisabledAndMissingSecretEnvironment() {
+ let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [
+ LocalProviderAccountConfiguration(
+ id: "codex",
+ provider: .openAI,
+ connectorKind: .codexRateLimits,
+ displayName: "OpenAI",
+ authPath: "/tmp/codex.json"
+ ),
+ LocalProviderAccountConfiguration(
+ id: "gemini",
+ provider: .google,
+ connectorKind: .geminiCodeAssist,
+ displayName: "Gemini",
+ authPath: "/tmp/gemini.json",
+ oauthClientIDEnvironmentName: "GEMINI_ID",
+ oauthClientSecretEnvironmentName: "GEMINI_SECRET"
+ ),
+ LocalProviderAccountConfiguration(
+ id: "claude-disabled",
+ provider: .anthropic,
+ connectorKind: .claudeLocalStatus,
+ displayName: "Claude",
+ isEnabled: false
+ ),
+ ])
+
+ let withoutGeminiEnvironment = AccountConnectorFactory.connectors(
+ from: document,
+ environment: [:],
+ geminiMetadataFileLoader: { _ in "" },
+ geminiMetadataFileExists: { _ in false }
+ )
+ let withGeminiEnvironment = AccountConnectorFactory.connectors(from: document, environment: [
+ "GEMINI_ID": "client",
+ "GEMINI_SECRET": "secret",
+ ])
+
+ #expect(withoutGeminiEnvironment.count == 1)
+ #expect(withoutGeminiEnvironment[0].provider == .openAI)
+ #expect(withGeminiEnvironment.count == 2)
+ #expect(Set(withGeminiEnvironment.map(\.provider)) == [.openAI, .google])
+}
+
+@Test func accountConnectorFactoryCanDiscoverGeminiMetadataFromInstalledCLI() {
+ let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [
+ LocalProviderAccountConfiguration(
+ id: "gemini",
+ provider: .google,
+ connectorKind: .geminiCodeAssist,
+ displayName: "Gemini",
+ authPath: "/tmp/gemini.json"
+ )
+ ])
+
+ let source = #"""
+ var OAUTH_CLIENT_ID = "client-id.apps.googleusercontent.com";
+ var OAUTH_CLIENT_SECRET = "client-secret";
+ """#
+ let connectors = AccountConnectorFactory.connectors(
+ from: document,
+ environment: ["GEMINI_CLI_BUNDLE_PATH": "/tmp/gemini-bundle.js"],
+ geminiMetadataFileLoader: { _ in source },
+ geminiMetadataFileExists: { _ in true }
+ )
+
+ #expect(connectors.count == 1)
+ #expect(connectors[0].provider == .google)
+}
+
+@Test func accountConfigurationStoreReportsCorruptFilesAsFailure() throws {
+ let url = try temporaryDirectory().appending(path: "accounts.json")
+ try Data("nope".utf8).write(to: url)
+
+ let result = AccountConfigurationStore(configurationURL: url).load(now: Date(timeIntervalSince1970: 0))
+
+ #expect(result.status == .failure)
+ #expect(result.document.accounts.count == 3)
+ #expect(result.errorMessage?.isEmpty == false)
+}
+
+private func temporaryDirectory() throws -> URL {
+ let url = FileManager.default.temporaryDirectory
+ .appending(path: "context-panel-account-tests")
+ .appending(path: UUID().uuidString, directoryHint: .isDirectory)
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ return url
+}
diff --git a/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift
new file mode 100644
index 0000000..9869c89
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift
@@ -0,0 +1,244 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func claudeAuthStatusParserReducesStatusToNonSecretFields() throws {
+ let json = #"""
+ {
+ "loggedIn": true,
+ "authMethod": "claude.ai",
+ "apiProvider": "firstParty",
+ "email": "friend@example.com",
+ "orgId": "org_secret",
+ "subscriptionType": "pro"
+ }
+ """#
+
+ let status = try ClaudeAuthStatusParser.status(from: Data(json.utf8))
+
+ #expect(status.loggedIn)
+ #expect(status.authMethod == "claude.ai")
+ #expect(status.apiProvider == "firstParty")
+ #expect(status.subscriptionType == "pro")
+ #expect(status.subscriptionDisplayName == "Claude Pro")
+}
+
+@Test func claudeStatsCacheParserSummarizesLocalActivityOnly() throws {
+ let json = #"""
+ {
+ "version": 2,
+ "lastComputedDate": "2026-05-06T14:00:00Z",
+ "dailyActivity": {
+ "2026-05-06": { "messageCount": 5 }
+ },
+ "modelUsage": {
+ "claude-sonnet-4-6": { "inputTokens": 12 },
+ "claude-opus-4-6": { "inputTokens": 3 }
+ },
+ "totalSessions": 7,
+ "totalMessages": 42,
+ "firstSessionDate": "2026-05-01"
+ }
+ """#
+
+ let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8))
+
+ #expect(summary.version == 2)
+ #expect(ContextPanelDateFormatting.string(from: summary.lastComputedDate!) == "2026-05-06T14:00:00Z")
+ #expect(summary.totalSessions == 7)
+ #expect(summary.totalMessages == 42)
+ #expect(summary.modelUsageCount == 2)
+ #expect(summary.dailyActivityCount == 1)
+}
+
+@Test func claudeStatsCacheParserAcceptsArrayShapedDailyActivity() throws {
+ let json = #"""
+ {
+ "lastComputedDate": "2026-04-26",
+ "dailyActivity": [
+ { "date": "2026-04-25" },
+ { "date": "2026-04-26" }
+ ],
+ "modelUsage": {},
+ "totalSessions": 1,
+ "totalMessages": 2
+ }
+ """#
+
+ let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8))
+
+ #expect(ContextPanelDateFormatting.string(from: summary.lastComputedDate!) == "2026-04-26T00:00:00Z")
+ #expect(summary.dailyActivityCount == 2)
+ #expect(summary.modelUsageCount == 0)
+}
+
+@Test func claudeStatsCacheParserAcceptsRateLimitSnapshotWhenPresent() throws {
+ let json = #"""
+ {
+ "lastComputedDate": "2026-05-06T14:00:00Z",
+ "rate_limits": {
+ "five_hour": {
+ "used_percentage": 42.4,
+ "resets_at": 1788397200
+ },
+ "seven_day": {
+ "used_percentage": 51.6,
+ "resets_at": 1788984000
+ }
+ }
+ }
+ """#
+
+ let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8))
+
+ #expect(summary.rateLimitSnapshot?.windows.map(\.label) == ["5-hour", "Weekly"])
+ #expect(summary.rateLimitSnapshot?.windows.map { Int($0.usedPercent.rounded()) } == [42, 52])
+}
+
+@Test func claudeUsageBlocksParserSummarizesActiveBlockAndLimitEstimate() throws {
+ let json = #"""
+ {
+ "blocks": [
+ { "isActive": false, "totalTokens": 1000 },
+ { "isActive": false, "totalTokens": 2000 },
+ {
+ "isActive": true,
+ "totalTokens": 500,
+ "endTime": "2026-05-06T23:00:00Z",
+ "projection": { "totalTokens": 1200, "remainingMinutes": 45 },
+ "models": ["claude-sonnet-4-6", ""]
+ }
+ ]
+ }
+ """#
+
+ let summary = try ClaudeUsageBlocksParser.summary(from: Data(json.utf8))
+
+ #expect(summary.activeBlock?.totalTokens == 500)
+ #expect(summary.activeBlock?.projectedTotalTokens == 1200)
+ #expect(summary.activeBlock?.remainingMinutes == 45)
+ #expect(summary.activeBlock?.modelCount == 1)
+ #expect(summary.completedBlockTokenLimitEstimate == 1000)
+}
+
+@Test func claudeLocalStatusLimitMakesUnknownAllowanceExplicit() {
+ let limit = claudeLocalStatusLimits(
+ authStatus: ClaudeAuthStatus(
+ loggedIn: true,
+ authMethod: "claude.ai",
+ apiProvider: "firstParty",
+ subscriptionType: "pro"
+ ),
+ statsSummary: nil,
+ accountID: "local",
+ accountName: "Claude",
+ observedAt: Date(timeIntervalSince1970: 0)
+ ).first!
+
+ #expect(limit.label == "Claude Pro status")
+ #expect(limit.modelLabel == "Claude Code")
+ #expect(limit.confidence == .observed)
+ #expect(limit.status == .unknown)
+ #expect(limit.note?.contains("allowance: not exposed by Claude Code") == true)
+}
+
+@Test func claudeStatuslineRateLimitCacheParsesSubscriptionWindows() throws {
+ let json = #"""
+ {
+ "observed_at": 1788379200,
+ "rate_limits": {
+ "five_hour": {
+ "used_percentage": 42.4,
+ "resets_at": 1788397200
+ },
+ "seven_day": {
+ "used_percentage": 51.6,
+ "resets_at": 1788984000
+ }
+ }
+ }
+ """#
+
+ let snapshot = try ClaudeSubscriptionRateLimitCacheParser.snapshot(from: Data(json.utf8))
+
+ #expect(ContextPanelDateFormatting.string(from: snapshot.observedAt) == "2026-09-02T20:00:00Z")
+ #expect(snapshot.windows.map(\.label) == ["5-hour", "Weekly"])
+ #expect(snapshot.windows.map { Int($0.usedPercent.rounded()) } == [42, 52])
+}
+
+@Test func claudeLocalStatusLimitsPrefersStatuslineSubscriptionWindows() {
+ let limits = claudeLocalStatusLimits(
+ authStatus: ClaudeAuthStatus(
+ loggedIn: true,
+ authMethod: "claude.ai",
+ apiProvider: "firstParty",
+ subscriptionType: "pro"
+ ),
+ statsSummary: nil,
+ rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot(
+ observedAt: Date(timeIntervalSince1970: 10),
+ windows: [
+ ClaudeSubscriptionRateLimitWindow(
+ label: "5-hour",
+ usedPercent: 42.4,
+ resetsAt: Date(timeIntervalSince1970: 100)
+ ),
+ ClaudeSubscriptionRateLimitWindow(
+ label: "Weekly",
+ usedPercent: 51.6,
+ resetsAt: Date(timeIntervalSince1970: 200)
+ ),
+ ]
+ ),
+ accountID: "local",
+ accountName: "Claude",
+ observedAt: Date(timeIntervalSince1970: 0)
+ )
+
+ #expect(limits.count == 2)
+ #expect(limits.map(\.provider) == [.anthropic, .anthropic])
+ #expect(limits.map(\.windowLabel) == ["5-hour", "Weekly"])
+ #expect(limits.map(\.modelLabel) == ["Claude Pro", "Claude Pro"])
+ #expect(limits.map(\.used) == [42, 52])
+ #expect(limits.allSatisfy { $0.unit == .percent && $0.limit == 100 && $0.confidence == .observed })
+}
+
+@Test func claudeLocalStatusLimitsUsesEveryCodeEstimateWhenAvailable() {
+ let limits = claudeLocalStatusLimits(
+ authStatus: ClaudeAuthStatus(
+ loggedIn: true,
+ authMethod: "claude.ai",
+ apiProvider: "firstParty",
+ subscriptionType: "pro"
+ ),
+ statsSummary: nil,
+ rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot(
+ observedAt: Date(timeIntervalSince1970: 10),
+ windows: [ClaudeSubscriptionRateLimitWindow(label: "5-hour", usedPercent: 4, resetsAt: nil)]
+ ),
+ usageBlocksSummary: ClaudeUsageBlocksSummary(
+ activeBlock: ClaudeUsageBlock(
+ isActive: true,
+ totalTokens: 500,
+ endTime: Date(timeIntervalSince1970: 9_000),
+ projectedTotalTokens: 1_200,
+ remainingMinutes: 45,
+ modelCount: 2
+ ),
+ completedBlockTokenLimitEstimate: 1_000
+ ),
+ accountID: "local",
+ accountName: "Claude",
+ observedAt: Date(timeIntervalSince1970: 1_000)
+ )
+
+ #expect(limits.count == 1)
+ #expect(limits[0].label == "Claude 5-hour estimate")
+ #expect(limits[0].unit == .tokens)
+ #expect(limits[0].used == 500)
+ #expect(limits[0].limit == 1_000)
+ #expect(limits[0].confidence == .estimated)
+ #expect(limits[0].resetsAt == Date(timeIntervalSince1970: 3_700))
+ #expect(limits[0].note?.contains("official subscription percentage unavailable") == true)
+}
diff --git a/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift b/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift
new file mode 100644
index 0000000..3e90790
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift
@@ -0,0 +1,62 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func claudeWebUsageParserBuildsSubscriptionPercentWindows() throws {
+ let payload = #"""
+ {
+ "rate_limits": {
+ "five_hour": {
+ "used_percentage": 42.4,
+ "resets_at": 1778109600
+ },
+ "seven_day": {
+ "remaining_percentage": 30,
+ "resets_at": "2026-05-08T01:00:00Z"
+ },
+ "seven_day_opus": {
+ "utilization": 0.91,
+ "resets_at": 1778192400000
+ }
+ },
+ "account_uuid": "1e13c5e0-a592-428d-a051-9fe5d6260e38"
+ }
+ """#.data(using: .utf8)!
+
+ let limits = try ClaudeWebUsageParser.usageLimits(
+ from: payload,
+ accountID: "claude-local",
+ accountName: "Claude Max",
+ observedAt: Date(timeIntervalSince1970: 1)
+ )
+
+ #expect(limits.count == 3)
+ #expect(limits[0].label == "Claude 5-hour")
+ #expect(limits[0].windowLabel == "5-hour")
+ #expect(limits[0].used == 42)
+ #expect(limits[0].confidence == .observed)
+ #expect(limits[1].used == 70)
+ #expect(limits[2].modelLabel == "Opus")
+ #expect(limits[2].used == 91)
+}
+
+@Test func claudeWebUsageSanitizerReturnsOnlyAllowedUsageFields() throws {
+ let payload = #"""
+ {
+ "rate_limits": {
+ "five_hour": { "used_percentage": 12, "resets_at": 1778109600 }
+ },
+ "email": "chris@example.com",
+ "organization_uuid": "1e13c5e0-a592-428d-a051-9fe5d6260e38"
+ }
+ """#.data(using: .utf8)!
+
+ let fields = try ClaudeWebUsageParser.sanitizedUsageFields(from: payload)
+
+ #expect(fields.contains("rate_limits"))
+ #expect(fields.contains("rate_limits.five_hour"))
+ #expect(fields.contains("rate_limits.five_hour.used_percentage"))
+ #expect(!fields.contains { $0.localizedCaseInsensitiveContains("email") })
+ #expect(!fields.contains { $0.localizedCaseInsensitiveContains("uuid") })
+}
diff --git a/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift b/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift
new file mode 100644
index 0000000..42aee55
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift
@@ -0,0 +1,96 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func codexUsagePayloadParserNormalizesPrimarySecondaryAndAdditionalWindows() throws {
+ let json = #"""
+ {
+ "plan_type": "pro",
+ "rate_limit": {
+ "allowed": true,
+ "limit_reached": false,
+ "primary_window": {
+ "used_percent": 45,
+ "limit_window_seconds": 18000,
+ "reset_after_seconds": 14000,
+ "reset_at": 1788393600
+ },
+ "secondary_window": {
+ "used_percent": 36,
+ "limit_window_seconds": 604800,
+ "reset_after_seconds": 500000,
+ "reset_at": 1788998400
+ }
+ },
+ "credits": {
+ "has_credits": true,
+ "unlimited": false,
+ "balance": "0"
+ },
+ "additional_rate_limits": [
+ {
+ "limit_name": "GPT-5.3-Codex-Spark",
+ "metered_feature": "codex_bengalfox",
+ "rate_limit": {
+ "allowed": true,
+ "limit_reached": false,
+ "primary_window": {
+ "used_percent": 1,
+ "limit_window_seconds": 18000,
+ "reset_after_seconds": 1000,
+ "reset_at": 1788400000
+ },
+ "secondary_window": null
+ }
+ }
+ ],
+ "rate_limit_reached_type": {
+ "type": "workspace_member_usage_limit_reached"
+ }
+ }
+ """#
+
+ let snapshots = try CodexUsagePayloadParser.snapshots(from: Data(json.utf8))
+
+ #expect(snapshots.count == 2)
+ #expect(snapshots[0].id == "codex")
+ #expect(snapshots[0].planType == "pro")
+ #expect(snapshots[0].primary?.usedPercent == 45)
+ #expect(snapshots[0].primary?.windowMinutes == 300)
+ #expect(snapshots[0].secondary?.windowMinutes == 10080)
+ #expect(snapshots[0].credits == CodexCreditsSnapshot(hasCredits: true, unlimited: false, balance: "0"))
+ #expect(snapshots[0].rateLimitReachedType == .workspaceMemberUsageLimitReached)
+ #expect(snapshots[1].id == "codex_bengalfox")
+ #expect(snapshots[1].limitName == "GPT-5.3-Codex-Spark")
+ #expect(snapshots[1].primary?.usedPercent == 1)
+ #expect(snapshots[1].credits == nil)
+
+ let limits = codexUsageLimits(
+ from: snapshots[0],
+ accountID: "acct",
+ accountName: "Personal",
+ observedAt: Date(timeIntervalSince1970: 0)
+ )
+ #expect(limits.map(\.windowLabel) == ["5-hour", "Weekly"])
+ #expect(limits.first?.modelLabel == "Codex")
+}
+
+@Test func codexUsagePayloadParserHandlesMissingLimitDetails() throws {
+ let json = #"""
+ {
+ "plan_type": "plus",
+ "rate_limit": null,
+ "additional_rate_limits": null,
+ "credits": null
+ }
+ """#
+
+ let snapshots = try CodexUsagePayloadParser.snapshots(from: Data(json.utf8))
+
+ #expect(snapshots.count == 1)
+ #expect(snapshots[0].id == "codex")
+ #expect(snapshots[0].planType == "plus")
+ #expect(snapshots[0].primary == nil)
+ #expect(snapshots[0].secondary == nil)
+}
diff --git a/Tests/ContextPanelCoreTests/FastModeForecastTests.swift b/Tests/ContextPanelCoreTests/FastModeForecastTests.swift
index 303e97f..808d0dc 100644
--- a/Tests/ContextPanelCoreTests/FastModeForecastTests.swift
+++ b/Tests/ContextPanelCoreTests/FastModeForecastTests.swift
@@ -103,6 +103,7 @@ private func openAILimit(
accountID: "openai-\(accountName.lowercased())",
accountName: accountName,
label: "GPT-5 Thinking",
+ unit: .percent,
used: used,
limit: limit,
resetsAt: resetsInHours.map { now.addingTimeInterval($0 * 3_600) },
diff --git a/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift
new file mode 100644
index 0000000..6c93b80
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift
@@ -0,0 +1,96 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func geminiQuotaPayloadParserNormalizesBucketsAsPercentPressure() throws {
+ let json = #"""
+ {
+ "buckets": [
+ {
+ "modelId": "gemini-3-flash-preview",
+ "remainingFraction": 0.965,
+ "resetTime": "2026-05-06T16:04:50Z"
+ },
+ {
+ "modelId": "gemini-3.1-pro-preview",
+ "remainingFraction": 1,
+ "remainingAmount": 12,
+ "resetTime": "2026-05-07T14:19:35Z"
+ }
+ ]
+ }
+ """#
+
+ let buckets = try GeminiQuotaPayloadParser.buckets(from: Data(json.utf8))
+
+ #expect(buckets.count == 2)
+ #expect(buckets[0].modelID == "gemini-3-flash-preview")
+ #expect(buckets[0].remainingFraction == 0.965)
+ #expect(abs((buckets[0].usedPercent ?? 0) - 3.5) < 0.0001)
+ #expect(ContextPanelDateFormatting.string(from: buckets[0].resetsAt!) == "2026-05-06T16:04:50Z")
+ #expect(buckets[1].remainingAmount == 12)
+
+ let limit = buckets[0].usageLimit(accountID: "local", accountName: "Gemini CLI", observedAt: Date(timeIntervalSince1970: 0))
+ #expect(limit.provider == .google)
+ #expect(limit.unit == .percent)
+ #expect(limit.used == 4)
+ #expect(limit.limit == 100)
+ #expect(limit.confidence == .observed)
+}
+
+@Test func geminiQuotaPayloadParserHandlesMissingBuckets() throws {
+ let json = #"{"notBuckets": true}"#
+
+ let buckets = try GeminiQuotaPayloadParser.buckets(from: Data(json.utf8))
+
+ #expect(buckets.isEmpty)
+}
+
+@Test func geminiOAuthClientMetadataDiscoveryParsesInstalledCLIBundleShape() {
+ let source = #"""
+ var OAUTH_CLIENT_ID = "client-id.apps.googleusercontent.com";
+ var OAUTH_CLIENT_SECRET = "client-secret";
+ """#
+
+ let metadata = GeminiOAuthClientMetadataDiscovery.parseClientMetadata(from: source)
+
+ #expect(metadata?.clientID == "client-id.apps.googleusercontent.com")
+ #expect(metadata?.clientSecret == "client-secret")
+}
+
+@Test func geminiOAuthClientMetadataDiscoveryPrefersEnvironmentValues() {
+ let metadata = GeminiOAuthClientMetadataDiscovery.discover(
+ environment: [
+ "GEMINI_OAUTH_CLIENT_ID": "env-client",
+ "GEMINI_OAUTH_CLIENT_SECRET": "env-secret",
+ ],
+ fileLoader: { _ in "" },
+ fileExists: { _ in false },
+ directoryLister: { _ in [] }
+ )
+
+ #expect(metadata == GeminiOAuthClientMetadata(clientID: "env-client", clientSecret: "env-secret"))
+}
+
+@Test func geminiOAuthClientMetadataDiscoveryScansInstalledBundleDirectory() {
+ let source = #"""
+ var OAUTH_CLIENT_ID = "bundle-client";
+ var OAUTH_CLIENT_SECRET = "bundle-secret";
+ """#
+
+ let metadata = GeminiOAuthClientMetadataDiscovery.discover(
+ environment: [:],
+ fileLoader: { path in
+ path.hasSuffix("chunk-with-oauth.js") ? source : ""
+ },
+ fileExists: { _ in true },
+ directoryLister: { root in
+ root == "/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle"
+ ? ["\(root)/chunk-with-oauth.js"]
+ : []
+ }
+ )
+
+ #expect(metadata == GeminiOAuthClientMetadata(clientID: "bundle-client", clientSecret: "bundle-secret"))
+}
diff --git a/Tests/ContextPanelCoreTests/LimitProbeTests.swift b/Tests/ContextPanelCoreTests/LimitProbeTests.swift
new file mode 100644
index 0000000..dc63c59
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/LimitProbeTests.swift
@@ -0,0 +1,112 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func visibleTextScannerFindsSubscriptionLimitSignals() {
+ let text = """
+ GPT-5 Thinking unavailable. You have reached your weekly limit.
+ Try again in 3h. Pro shows 52 percent used in the 5 hour window.
+ """
+
+ let observations = LimitProbeScanner.scanVisibleText(text, provider: .openAI)
+ let kinds = Set(observations.map(\.signalKind))
+
+ #expect(kinds.contains(.limitReached))
+ #expect(kinds.contains(.relativeDuration))
+ #expect(kinds.contains(.usagePressure))
+ #expect(kinds.contains(.modelAvailability))
+ #expect(kinds.contains(.planLanguage))
+}
+
+@Test func responseShapeScannerOnlyReportsCandidateFieldNames() {
+ let observations = LimitProbeScanner.scanResponseShape(
+ fieldNames: ["id", "email", "used_percent", "reset_at", "avatar", "token_pressure"],
+ provider: .openAI
+ )
+
+ #expect(observations.map(\.sanitizedEvidence).contains("reset_at"))
+ #expect(!observations.map(\.sanitizedEvidence).contains("email"))
+ #expect(observations.map(\.sanitizedEvidence).contains("used_percent"))
+ #expect(observations.map(\.sanitizedEvidence).contains("token_pressure"))
+}
+
+@Test func probeEvidenceRedactsSecretsBeforeStorage() {
+ let observation = LimitProbeObservation(
+ provider: .openAI,
+ observedAt: Date(),
+ source: .visibleText,
+ signalKind: .resetLanguage,
+ confidence: .observed,
+ sanitizedEvidence: "Authorization: bearer abc.def.ghi user chris@example.com token=secret"
+ )
+
+ #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("abc.def.ghi"))
+ #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("chris@example.com"))
+ #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("secret"))
+ #expect(observation.sanitizedEvidence.localizedCaseInsensitiveContains("[email redacted]"))
+}
+
+@Test func markdownReportContainsRedactionStatement() {
+ let report = LimitProbeReport(
+ provider: .openAI,
+ capturedAt: Date(timeIntervalSinceReferenceDate: 1),
+ observations: [
+ LimitProbeObservation(
+ provider: .openAI,
+ observedAt: Date(timeIntervalSinceReferenceDate: 1),
+ source: .visibleText,
+ signalKind: .relativeDuration,
+ confidence: .observed,
+ sanitizedEvidence: "resets in 3h"
+ )
+ ],
+ networkEvents: [
+ NetworkProbeEvent(
+ observedAt: Date(timeIntervalSinceReferenceDate: 1),
+ method: "GET",
+ pathHint: "/backend-api/accounts/check",
+ status: 200,
+ contentType: "application/json",
+ bodySize: 256,
+ matchedFields: ["default_account_plan_type", "accounts.[id].entitlement.subscription_plan"]
+ )
+ ]
+ )
+
+ #expect(report.markdownSummary.contains("Limit Probe Report"))
+ #expect(report.markdownSummary.contains("resets in 3h"))
+ #expect(report.markdownSummary.contains("Network Candidates"))
+ #expect(report.markdownSummary.contains("/backend-api/accounts/check"))
+ #expect(report.markdownSummary.contains("subscription_plan"))
+ #expect(report.markdownSummary.contains("authorization headers"))
+}
+
+@Test func networkProbeEventRedactsPathIdentifiers() {
+ let event = NetworkProbeEvent(
+ observedAt: Date(),
+ method: "GET",
+ pathHint: "https://chatgpt.com/backend-api/conversation/abc123def456abc123def456?token=secret",
+ status: 200,
+ contentType: "application/json",
+ bodySize: 120,
+ matchedFields: ["reset_at", "used_percent"]
+ )
+
+ #expect(event.pathHint == "/backend-api/conversation/[id]")
+ #expect(event.matchedFields == ["reset_at", "used_percent"])
+}
+
+@Test func networkProbeEventRedactsIdentifiersInsideFieldNames() {
+ let event = NetworkProbeEvent(
+ observedAt: Date(),
+ method: "GET",
+ pathHint: "/api/accounts",
+ status: 200,
+ contentType: "application/json",
+ bodySize: 120,
+ matchedFields: ["accounts.1e13c5e0-a592-428d-a051-9fe5d6260e38.entitlement.subscription_plan"]
+ )
+
+ #expect(event.matchedFields == ["accounts.[id].entitlement.subscription_plan"])
+}
diff --git a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift
new file mode 100644
index 0000000..566fc71
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift
@@ -0,0 +1,240 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func codexConnectorRefreshesMultipleAccountsIntoNormalizedLimits() async throws {
+ let auth = #"{"tokens":{"access_token":"token-secret"}}"#.data(using: .utf8)!
+ let usage = #"""
+ {
+ "plan_type": "pro",
+ "rate_limit": {
+ "primary_window": { "used_percent": 50, "limit_window_seconds": 18000, "reset_at": 1788393600 },
+ "secondary_window": { "used_percent": 25, "limit_window_seconds": 604800, "reset_at": 1788998400 }
+ }
+ }
+ """#.data(using: .utf8)!
+ let http = StubHTTPClient(responses: [ConnectorHTTPResponse(statusCode: 200, data: usage)])
+ let connector = CodexRateLimitConnector(
+ accounts: [
+ CodexAccountConfiguration(authPath: "/tmp/openai-a.json", accountName: "OpenAI A"),
+ CodexAccountConfiguration(authPath: "/tmp/openai-b.json", accountName: "OpenAI B"),
+ ],
+ httpClient: http,
+ fileLoader: { _ in auth }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 0))
+
+ #expect(result.reports.count == 2)
+ #expect(result.snapshot.limits.count == 4)
+ #expect(Set(result.snapshot.limits.map(\.accountName)) == ["OpenAI A", "OpenAI B"])
+ #expect(result.snapshot.limits.allSatisfy { $0.provider == .openAI && $0.unit == .percent })
+ #expect(result.snapshot.limits.contains { $0.used == 50 && $0.windowLabel == "5-hour" })
+ #expect(http.requests.count == 2)
+}
+
+@Test func codexConnectorRedactsHTTPFailures() async {
+ let auth = #"{"tokens":{"access_token":"token-secret"}}"#.data(using: .utf8)!
+ let http = StubHTTPClient(responses: [ConnectorHTTPResponse(statusCode: 401, data: Data("secret body".utf8))])
+ let connector = CodexRateLimitConnector(
+ accounts: [CodexAccountConfiguration(authPath: "/tmp/openai.json", accountName: "OpenAI")],
+ httpClient: http,
+ fileLoader: { _ in auth }
+ )
+
+ let result = await connector.refresh(now: Date())
+
+ #expect(result.reports.count == 1)
+ #expect(result.reports[0].status == .failure)
+ #expect(result.reports[0].errorMessage?.contains("HTTP 401") == true)
+ #expect(result.reports[0].errorMessage?.contains("secret body") == false)
+ #expect(result.snapshot.limits.isEmpty)
+}
+
+@Test func geminiConnectorRefreshesQuotaBuckets() async throws {
+ let credentials = #"{"refresh_token":"refresh-secret"}"#.data(using: .utf8)!
+ let refresh = #"{"access_token":"access-secret"}"#.data(using: .utf8)!
+ let load = #"{"cloudaicompanionProject":"project-secret","currentTier":{"name":"Gemini Code Assist"}}"#.data(using: .utf8)!
+ let quota = #"""
+ {
+ "buckets": [
+ { "modelId": "gemini-3-flash-preview", "remainingFraction": 0.75, "resetTime": "2026-05-06T16:04:50Z" }
+ ]
+ }
+ """#.data(using: .utf8)!
+ let http = StubHTTPClient(responses: [
+ ConnectorHTTPResponse(statusCode: 200, data: refresh),
+ ConnectorHTTPResponse(statusCode: 200, data: load),
+ ConnectorHTTPResponse(statusCode: 200, data: quota),
+ ])
+ let connector = GeminiCodeAssistConnector(
+ accounts: [GeminiAccountConfiguration(authPath: "/tmp/gemini.json", accountName: "Gemini", clientID: "client", clientSecret: "secret")],
+ httpClient: http,
+ fileLoader: { _ in credentials }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 0))
+
+ #expect(result.reports.count == 1)
+ #expect(result.snapshot.limits.count == 1)
+ #expect(result.snapshot.limits[0].provider == .google)
+ #expect(result.snapshot.limits[0].label == "gemini-3-flash-preview")
+ #expect(result.snapshot.limits[0].used == 25)
+ #expect(http.requests.map(\.method) == ["POST", "POST", "POST"])
+ #expect(http.requests[2].body.flatMap { String(data: $0, encoding: .utf8) }?.contains("project-secret") == true)
+}
+
+@Test func claudeConnectorReportsUnknownLiveAllowanceFromLocalStatus() async throws {
+ let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)!
+ let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)!
+ let connector = ClaudeLocalStatusConnector(
+ accounts: [ClaudeAccountConfiguration(accountName: "Claude", claudeBinary: "claude", statsPath: "/tmp/stats.json")],
+ processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)),
+ fileLoader: { _ in stats },
+ fileExists: { path in path == "/tmp/stats.json" }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 0))
+
+ #expect(result.reports.count == 1)
+ #expect(result.reports[0].status == .unknown)
+ #expect(result.snapshot.limits.count == 1)
+ #expect(result.snapshot.limits[0].provider == .anthropic)
+ #expect(result.snapshot.limits[0].status == .unknown)
+ #expect(result.snapshot.limits[0].note?.contains("subscription: pro") == true)
+}
+
+@Test func claudeConnectorReportsHealthyWhenStatuslineLimitsExist() async throws {
+ let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)!
+ let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)!
+ let cache = #"{"observed_at":1788379200,"rate_limits":{"five_hour":{"used_percentage":4,"resets_at":1788397200},"seven_day":{"used_percentage":0,"resets_at":1788984000}}}"#.data(using: .utf8)!
+ let connector = ClaudeLocalStatusConnector(
+ accounts: [ClaudeAccountConfiguration(
+ accountName: "Claude",
+ claudeBinary: "claude",
+ statsPath: "/tmp/stats.json",
+ rateLimitSnapshotPath: "/tmp/claude-statusline.json",
+ rateLimitSnapshotMaximumAge: 60
+ )],
+ processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)),
+ fileLoader: { path in path == "/tmp/claude-statusline.json" ? cache : stats },
+ fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/claude-statusline.json" }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_788_379_230))
+
+ #expect(result.reports[0].status == .healthy)
+ #expect(result.snapshot.limits.count == 2)
+ #expect(result.snapshot.limits.map(\.windowLabel) == ["5-hour", "Weekly"])
+}
+
+@Test func claudeConnectorMarksOldStatuslineLimitsStale() async throws {
+ let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)!
+ let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)!
+ let cache = #"{"observed_at":1788379200,"rate_limits":{"five_hour":{"used_percentage":4,"resets_at":1788397200},"seven_day":{"used_percentage":0,"resets_at":1788984000}}}"#.data(using: .utf8)!
+ let connector = ClaudeLocalStatusConnector(
+ accounts: [ClaudeAccountConfiguration(
+ accountName: "Claude",
+ claudeBinary: "claude",
+ statsPath: "/tmp/stats.json",
+ rateLimitSnapshotPath: "/tmp/claude-statusline.json",
+ rateLimitSnapshotMaximumAge: 60
+ )],
+ processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)),
+ fileLoader: { path in path == "/tmp/claude-statusline.json" ? cache : stats },
+ fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/claude-statusline.json" }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_788_379_500))
+
+ #expect(result.reports[0].status == .stale)
+ #expect(result.snapshot.limits.map(\.status) == [.stale, .stale])
+ #expect(result.snapshot.limits[0].note?.contains("stale Claude Code statusline") == true)
+}
+
+@Test func claudeConnectorReportsEveryCodeUsageEstimate() async throws {
+ let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)!
+ let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)!
+ let blocks = #"{"blocks":[{"isActive":false,"totalTokens":1000},{"isActive":true,"totalTokens":500,"projection":{"totalTokens":1200,"remainingMinutes":30},"models":["claude-sonnet-4-6"]}]}"#.data(using: .utf8)!
+ let connector = ClaudeLocalStatusConnector(
+ accounts: [ClaudeAccountConfiguration(
+ accountName: "Claude",
+ claudeBinary: "claude",
+ statsPath: "/tmp/stats.json",
+ usageBlocksPath: "/tmp/ccusage-blocks.json"
+ )],
+ processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)),
+ fileLoader: { path in path == "/tmp/ccusage-blocks.json" ? blocks : stats },
+ fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/ccusage-blocks.json" }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_000))
+
+ #expect(result.reports[0].status == .healthy)
+ #expect(result.snapshot.limits.count == 1)
+ #expect(result.snapshot.limits[0].confidence == .estimated)
+ #expect(result.snapshot.limits[0].used == 500)
+ #expect(result.snapshot.limits[0].limit == 1000)
+ #expect(result.snapshot.limits[0].note?.contains("Every Code/Claude sessions") == true)
+}
+
+@Test func providerConnectorRuntimeAggregatesConnectorSnapshots() async {
+ let connectorA = StubConnector(provider: .openAI, report: ProviderConnectorReport(
+ provider: .openAI,
+ accountID: "a",
+ accountName: "A",
+ generatedAt: Date(timeIntervalSince1970: 0),
+ limits: [UsageLimit(provider: .openAI, label: "A", used: 1, limit: 100)]
+ ))
+ let connectorB = StubConnector(provider: .google, report: ProviderConnectorReport(
+ provider: .google,
+ accountID: "b",
+ accountName: "B",
+ generatedAt: Date(timeIntervalSince1970: 0),
+ limits: [UsageLimit(provider: .google, label: "B", used: 2, limit: 100)]
+ ))
+
+ let result = await ProviderConnectorRuntime(connectors: [connectorA, connectorB]).refreshAll(now: Date(timeIntervalSince1970: 10))
+
+ #expect(result.reports.count == 2)
+ #expect(result.snapshot.generatedAt == Date(timeIntervalSince1970: 10))
+ #expect(Set(result.snapshot.limits.map(\.provider)) == [.openAI, .google])
+}
+
+private final class StubHTTPClient: ConnectorHTTPClient, @unchecked Sendable {
+ private var responses: [ConnectorHTTPResponse]
+ private(set) var requests: [ConnectorHTTPRequest] = []
+
+ init(responses: [ConnectorHTTPResponse]) {
+ self.responses = responses
+ }
+
+ func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse {
+ requests.append(request)
+ guard !responses.isEmpty else {
+ return ConnectorHTTPResponse(statusCode: 500, data: Data())
+ }
+ if responses.count == 1 {
+ return responses[0]
+ }
+ return responses.removeFirst()
+ }
+}
+
+private struct StubProcessClient: ConnectorProcessClient {
+ let result: ConnectorProcessResult
+
+ func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult {
+ result
+ }
+}
+
+private struct StubConnector: ProviderConnector {
+ let provider: Provider
+ let report: ProviderConnectorReport
+
+ func refresh(now: Date) async -> ConnectorRefreshResult {
+ ConnectorRefreshResult(generatedAt: now, reports: [report])
+ }
+}
diff --git a/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift
new file mode 100644
index 0000000..3099d0a
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift
@@ -0,0 +1,210 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func jsonSnapshotStoreRoundTripsCurrentSnapshotAndReports() throws {
+ let root = try temporaryDirectory()
+ let store = JSONSnapshotStore(rootDirectory: root)
+ let savedAt = Date(timeIntervalSince1970: 100)
+ let refresh = ConnectorRefreshResult(generatedAt: savedAt, reports: [ProviderConnectorReport(
+ provider: .openAI,
+ accountID: "local-openai",
+ accountName: "OpenAI",
+ generatedAt: savedAt,
+ limits: [usageLimit(provider: .openAI, accountID: "local-openai", used: 30, savedAt: savedAt)]
+ )])
+
+ try store.save(StoredUsageSnapshot(savedAt: savedAt, refreshResult: refresh))
+
+ let result = store.loadCurrent()
+
+ #expect(result.status == .healthy)
+ #expect(result.snapshot?.schemaVersion == 1)
+ #expect(result.snapshot?.savedAt == savedAt)
+ #expect(result.snapshot?.snapshot.limits.count == 1)
+ #expect(result.snapshot?.reports.count == 1)
+ #expect(result.snapshot?.reports[0].provider == .openAI)
+ #expect(FileManager.default.fileExists(atPath: store.currentSnapshotURL.path))
+ #expect(store.loadHistory().count == 1)
+}
+
+@Test func jsonSnapshotStoreFiltersHistoryByProviderAccountAndLimit() throws {
+ let root = try temporaryDirectory()
+ let store = JSONSnapshotStore(rootDirectory: root)
+ let first = Date(timeIntervalSince1970: 100)
+ let second = Date(timeIntervalSince1970: 200)
+
+ try store.save(StoredUsageSnapshot(savedAt: first, snapshot: UsageSnapshot(
+ generatedAt: first,
+ limits: [usageLimit(provider: .openAI, accountID: "a", used: 10, savedAt: first)]
+ )))
+ try store.save(StoredUsageSnapshot(savedAt: second, snapshot: UsageSnapshot(
+ generatedAt: second,
+ limits: [usageLimit(provider: .google, accountID: "b", used: 20, savedAt: second)]
+ )))
+
+ #expect(store.loadHistory().map(\.savedAt) == [second, first])
+ #expect(store.loadHistory(query: SnapshotStoreQuery(provider: .openAI)).map(\.savedAt) == [first])
+ #expect(store.loadHistory(query: SnapshotStoreQuery(accountID: "b")).map(\.savedAt) == [second])
+ #expect(store.loadHistory(query: SnapshotStoreQuery(limit: 1)).count == 1)
+}
+
+@Test func jsonSnapshotStoreMergesRefreshResultByProviderAccount() throws {
+ let root = try temporaryDirectory()
+ let store = JSONSnapshotStore(rootDirectory: root)
+ let first = Date(timeIntervalSince1970: 100)
+ let second = Date(timeIntervalSince1970: 200)
+
+ let initial = ConnectorRefreshResult(generatedAt: first, reports: [
+ ProviderConnectorReport(
+ provider: .openAI,
+ accountID: "openai-a",
+ accountName: "OpenAI A",
+ generatedAt: first,
+ limits: [usageLimit(provider: .openAI, accountID: "openai-a", used: 40, savedAt: first)]
+ ),
+ ProviderConnectorReport(
+ provider: .google,
+ accountID: "gemini-a",
+ accountName: "Gemini A",
+ generatedAt: first,
+ limits: [usageLimit(provider: .google, accountID: "gemini-a", used: 10, savedAt: first)]
+ ),
+ ])
+ try store.save(StoredUsageSnapshot(savedAt: first, refreshResult: initial))
+
+ let claudeRefresh = ConnectorRefreshResult(generatedAt: second, reports: [
+ ProviderConnectorReport(
+ provider: .anthropic,
+ accountID: "claude-web",
+ accountName: "Claude Web",
+ generatedAt: second,
+ limits: [usageLimit(provider: .anthropic, accountID: "claude-web", used: 3, savedAt: second)]
+ )
+ ])
+
+ try store.saveMerged(refreshResult: claudeRefresh, savedAt: second)
+
+ let limits = try #require(store.loadCurrent().snapshot?.snapshot.limits)
+ #expect(limits.map(\.provider).contains(.openAI))
+ #expect(limits.map(\.provider).contains(.google))
+ #expect(limits.map(\.provider).contains(.anthropic))
+ #expect(store.loadCurrent().snapshot?.reports.count == 3)
+ #expect(store.loadHistory().count == 2)
+
+ let replacement = ConnectorRefreshResult(generatedAt: second, reports: [
+ ProviderConnectorReport(
+ provider: .openAI,
+ accountID: "openai-a",
+ accountName: "OpenAI A",
+ generatedAt: second,
+ limits: [usageLimit(provider: .openAI, accountID: "openai-a", used: 80, savedAt: second)]
+ )
+ ])
+ try store.saveMerged(refreshResult: replacement, savedAt: second.addingTimeInterval(1))
+
+ let replaced = try #require(store.loadCurrent().snapshot?.snapshot.limits)
+ let openAILimit = try #require(replaced.first { $0.provider == .openAI && $0.accountID == "openai-a" })
+ #expect(openAILimit.used == 80)
+ #expect(replaced.filter { $0.provider == .openAI && $0.accountID == "openai-a" }.count == 1)
+ #expect(replaced.map(\.provider).contains(.google))
+ #expect(replaced.map(\.provider).contains(.anthropic))
+}
+
+@Test func jsonSnapshotStoreReportsMissingCurrentAsUnknown() throws {
+ let store = JSONSnapshotStore(rootDirectory: try temporaryDirectory())
+
+ let result = store.loadCurrent()
+
+ #expect(result.snapshot == nil)
+ #expect(result.status == .unknown)
+}
+
+@Test func jsonSnapshotStoreReportsCorruptCurrentAsFailureWithoutThrowing() throws {
+ let root = try temporaryDirectory()
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ try Data("not json".utf8).write(to: root.appending(path: "current-snapshot.json"))
+
+ let result = JSONSnapshotStore(rootDirectory: root).loadCurrent()
+
+ #expect(result.snapshot == nil)
+ #expect(result.status == .failure)
+ #expect(result.errorMessage?.isEmpty == false)
+}
+
+@Test func jsonSnapshotStoreAppliesStalenessPolicy() throws {
+ let root = try temporaryDirectory()
+ let store = JSONSnapshotStore(rootDirectory: root)
+ let savedAt = Date(timeIntervalSince1970: 100)
+ try store.save(StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot(
+ generatedAt: savedAt,
+ limits: [usageLimit(provider: .google, accountID: "g", used: 20, savedAt: savedAt)]
+ )))
+
+ let fresh = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 60), now: Date(timeIntervalSince1970: 120))
+ let stale = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 60), now: Date(timeIntervalSince1970: 200))
+
+ #expect(fresh.status == .healthy)
+ #expect(stale.status == .stale)
+}
+
+@Test func jsonSnapshotStoreRejectsUnsupportedSchema() throws {
+ let root = try temporaryDirectory()
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let current = root.appending(path: "current-snapshot.json")
+ let json = #"""
+ {
+ "schemaVersion": 99,
+ "savedAt": "1970-01-01T00:00:00Z",
+ "snapshot": { "generatedAt": "1970-01-01T00:00:00Z", "limits": [] },
+ "reports": []
+ }
+ """#
+ try Data(json.utf8).write(to: current)
+
+ let result = JSONSnapshotStore(rootDirectory: root).loadCurrent()
+
+ #expect(result.status == .failure)
+ #expect(result.errorMessage?.contains("Unsupported snapshot schema") == true)
+}
+
+@Test func storedProviderReportRedactsErrorMessages() {
+ let report = ProviderConnectorReport(
+ provider: .openAI,
+ accountID: "local",
+ accountName: "OpenAI",
+ generatedAt: Date(timeIntervalSince1970: 0),
+ limits: [],
+ status: .failure,
+ errorMessage: "failed for user@example.com with bearer sk-secret"
+ )
+
+ let stored = StoredProviderReport(report: report)
+
+ #expect(stored.errorMessage?.contains("user@example.com") == false)
+ #expect(stored.errorMessage?.contains("sk-secret") == false)
+}
+
+private func usageLimit(provider: Provider, accountID: String, used: Int, savedAt: Date) -> UsageLimit {
+ UsageLimit(
+ provider: provider,
+ accountID: accountID,
+ accountName: accountID,
+ label: "usage",
+ unit: .percent,
+ used: used,
+ limit: 100,
+ resetsAt: savedAt.addingTimeInterval(3_600),
+ lastUpdatedAt: savedAt,
+ confidence: .observed
+ )
+}
+
+private func temporaryDirectory() throws -> URL {
+ let url = FileManager.default.temporaryDirectory
+ .appending(path: "context-panel-tests")
+ .appending(path: UUID().uuidString, directoryHint: .isDirectory)
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ return url
+}
diff --git a/Tests/ContextPanelCoreTests/UsageLimitTests.swift b/Tests/ContextPanelCoreTests/UsageLimitTests.swift
index 838f260..fa3f369 100644
--- a/Tests/ContextPanelCoreTests/UsageLimitTests.swift
+++ b/Tests/ContextPanelCoreTests/UsageLimitTests.swift
@@ -59,9 +59,54 @@ import Testing
let first = snapshot.mostConstrainedLimits.first
#expect(first?.label == "Image generation")
- #expect(snapshot.aggregateCapacityRatio > 0)
+ #expect(abs(snapshot.aggregateCapacityRatio - 0.02) < 0.0001)
+}
+
+@Test func aggregateCapacityUsesTightestTrackedWindow() {
+ let snapshot = UsageSnapshot(
+ generatedAt: Date(),
+ limits: [
+ UsageLimit(provider: .openAI, label: "Weekly", used: 95, limit: 100),
+ UsageLimit(provider: .openAI, label: "5-hour", used: 5, limit: 100),
+ ]
+ )
+
+ #expect(abs(snapshot.aggregateCapacityRatio - 0.05) < 0.0001)
+}
+
+@Test func usageLimitSeparatesWindowAndModelLabels() {
+ let limit = UsageLimit(
+ provider: .openAI,
+ accountID: "openai-personal",
+ accountName: "Personal",
+ label: "Codex 5-hour",
+ windowLabel: "5-hour",
+ modelLabel: "Codex",
+ used: 42,
+ limit: 100
+ )
+
+ #expect(limit.displayLabel == "5-hour")
+ #expect(limit.contextLabel == "Codex ยท Personal")
}
@Test func providersCoverInitialScope() {
#expect(Provider.allCases == [.openAI, .anthropic, .google])
}
+
+@Test func usageLimitCanRepresentPercentPressure() {
+ let limit = UsageLimit(
+ provider: .openAI,
+ accountID: "openai-personal",
+ accountName: "Personal",
+ label: "Codex weekly",
+ unit: .percent,
+ used: 38,
+ limit: 100,
+ confidence: .official
+ )
+
+ #expect(limit.unit == .percent)
+ #expect(limit.remaining == 62)
+ #expect(limit.usageRatio == 0.38)
+}
diff --git a/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift b/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift
new file mode 100644
index 0000000..53be3a9
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift
@@ -0,0 +1,69 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func widgetSnapshotUsesSetupNeededForMissingStore() {
+ let widget = WidgetSnapshot.fromStore(
+ SnapshotStoreLoadResult(snapshot: nil, status: .unknown),
+ now: Date(timeIntervalSince1970: 0)
+ )
+
+ #expect(widget.state == .setupNeeded)
+ #expect(widget.status == .unknown)
+ #expect(widget.limits.isEmpty)
+ #expect(widget.message.contains("Set up"))
+}
+
+@Test func widgetSnapshotPreservesStaleCachedLimits() {
+ let savedAt = Date(timeIntervalSince1970: 100)
+ let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot(
+ generatedAt: savedAt,
+ limits: [UsageLimit(provider: .openAI, label: "Codex", used: 20, limit: 100)]
+ ))
+
+ let widget = WidgetSnapshot.fromStore(
+ SnapshotStoreLoadResult(snapshot: stored, status: .stale),
+ now: Date(timeIntervalSince1970: 1_000)
+ )
+
+ #expect(widget.state == .stale)
+ #expect(widget.limits.count == 1)
+ #expect(widget.message == "Last snapshot is stale.")
+}
+
+@Test func widgetSnapshotBuildsProviderSummaries() {
+ let savedAt = Date(timeIntervalSince1970: 100)
+ let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot(
+ generatedAt: savedAt,
+ limits: [
+ UsageLimit(provider: .openAI, label: "Codex", used: 85, limit: 100),
+ UsageLimit(provider: .google, label: "Gemini", used: 10, limit: 100),
+ ]
+ ))
+
+ let widget = WidgetSnapshot.fromStore(SnapshotStoreLoadResult(snapshot: stored, status: .healthy))
+ let summaries = Dictionary(uniqueKeysWithValues: widget.providerSummaries.map { ($0.provider, $0) })
+
+ #expect(widget.state == .ready)
+ #expect(summaries[.openAI]?.status == .close)
+ #expect(summaries[.openAI]?.limitCount == 1)
+ #expect(summaries[.google]?.status == .healthy)
+ #expect(summaries[.anthropic]?.limitCount == 0)
+}
+
+@Test func providerSummariesUseTheTightestWindowInsteadOfAverageCapacity() {
+ let savedAt = Date(timeIntervalSince1970: 100)
+ let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot(
+ generatedAt: savedAt,
+ limits: [
+ UsageLimit(provider: .openAI, label: "Weekly", used: 95, limit: 100),
+ UsageLimit(provider: .openAI, label: "5-hour", used: 5, limit: 100),
+ ]
+ ))
+
+ let widget = WidgetSnapshot.fromStore(SnapshotStoreLoadResult(snapshot: stored, status: .healthy))
+ let openAI = widget.providerSummaries.first { $0.provider == .openAI }
+
+ #expect(abs((openAI?.capacityRatio ?? 0) - 0.05) < 0.0001)
+}
diff --git a/docs/README.md b/docs/README.md
index 956a1b8..3fb5baf 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -4,5 +4,7 @@
- [Product Goals](product-goals.md)
- [Architecture](architecture.md)
- [Design Direction](design-direction.md)
+- [Local Limit Probe Design](local-limit-probe.md)
- [Provider Usage Access Research](provider-usage-access.md)
+- [macOS Release Path](release.md)
- [Repository Settings](repo-settings.md)
diff --git a/docs/architecture.md b/docs/architecture.md
index c262388..4da0aa9 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -18,6 +18,54 @@ Context Panel is expected to split into a few native boundaries:
The first committed code lives in `ContextPanelCore` so provider, account, and
UI work can share the same vocabulary from the start.
+## Connector Runtime
+
+`ContextPanelCore` owns the provider connector contract. A connector refreshes
+one or more configured local accounts and returns a `ConnectorRefreshResult`,
+which carries provider/account reports plus a normalized `UsageSnapshot` for UI
+and storage code.
+
+MVP connectors:
+
+- `CodexRateLimitConnector`: reads Codex-style auth roots such as `~/.code` or
+ `~/.codex`, calls the live Codex usage endpoint, and normalizes primary,
+ secondary, and additional percent-window buckets.
+- `GeminiCodeAssistConnector`: reads Gemini CLI OAuth credentials, uses
+ explicitly supplied OAuth client inputs, resolves the active Code Assist
+ project internally, and normalizes model quota buckets as percent pressure.
+- `ClaudeLocalStatusConnector`: runs `claude auth status --json` and summarizes
+ `~/.claude/stats-cache.json`; live personal subscription allowance remains
+ unknown unless a clean provider signal appears.
+
+Connector implementations must keep secrets out of normalized state. Do not
+persist or print tokens, account IDs, project IDs, organization IDs, emails,
+headers, or raw response bodies. Errors should mention status and operation but
+not provider response content.
+
+## Snapshot Store
+
+The MVP cache is a local JSON store. It writes one current snapshot file plus a
+history directory of timestamped snapshots. The schema is intentionally simple:
+`StoredUsageSnapshot` includes a schema version, save time, normalized
+`UsageSnapshot`, and redacted provider refresh reports.
+
+The widget should read `current-snapshot.json` and apply a staleness policy. It
+must not read provider credential files or make provider network calls. The app
+owns connector refreshes, account setup, diagnostics, and future migration from
+JSON to a richer store if history queries become more complex.
+
+The WidgetKit implementation uses a `WidgetSnapshot` projection from the stored
+snapshot. That projection owns setup-needed, stale, failure, provider-summary,
+and most-constrained row selection so the widget view stays read-only and small.
+
+## Account Configuration
+
+The MVP account configuration is also local JSON. It stores account labels,
+enabled/disabled state, connector kind, and local paths or command names needed
+to locate provider CLI auth. It does not store provider secrets. Gemini OAuth
+client inputs are referenced by environment variable names so the values can
+remain outside the repository and outside the account config file.
+
Widget interactions should keep the widget simple. Tapping the widget should
open the app to the relevant provider or account detail; mutation and setup stay
inside the app.
diff --git a/docs/design-direction.md b/docs/design-direction.md
index a018884..b3cec85 100644
--- a/docs/design-direction.md
+++ b/docs/design-direction.md
@@ -27,10 +27,9 @@ multi-account data.
- True neutral gray surfaces, tuned separately for light and dark appearances.
- One swappable accent color; default accent is a restrained slate blue.
- Subtle status tints. Avoid alarm-heavy red as the dominant state language.
-- Provider identity should use abstract glyphs plus labels, not provider logos as
- the only hierarchy.
-- Status must be communicated by color plus shape or text for color-vision
- safety.
+- Provider identity should use short text badges plus labels, not abstract
+ shapes or provider logos as the only hierarchy.
+- Status must be communicated by color plus nearby text for color-vision safety.
- Widgets are read-only and deep-link into the app for setup and detail.
- Failure and stale states isolate to the affected account or provider; never
blank the whole widget when neighboring data is still valid.
@@ -55,8 +54,8 @@ of copying the React implementation:
- `CapacityDial`: ring/dial for overall or account capacity.
- `CapacityBar`: compact account/model capacity bar.
-- `ProviderGlyph`: abstract provider symbol.
-- `StatusMark`: shape-coded status marker.
+- `ProviderBadge`: provider short-name text badge.
+- `StatusMark`: compact status marker paired with text.
- `AccountRow`: reusable account/model row for widgets and app detail.
- `ContextWidget`: WidgetKit configuration for small, medium, and large layouts.
- `WidgetTimelineProvider`: timeline backed by cached local snapshots and
diff --git a/docs/local-limit-probe.md b/docs/local-limit-probe.md
new file mode 100644
index 0000000..2a3753e
--- /dev/null
+++ b/docs/local-limit-probe.md
@@ -0,0 +1,197 @@
+# Local Limit Probe Design
+
+Last updated: 2026-05-05.
+
+## Goal
+
+Context Panel needs to know whether subscription limits are exposed anywhere a
+logged-in user can legitimately see them. The first target is OpenAI ChatGPT
+subscription usage: weekly limits, short rolling windows, reset times, model
+availability, and any percent or token pressure signals. If the approach works
+for OpenAI, the same diagnostic shape can be reused for Claude and Gemini.
+
+The probe is a local diagnostic tool, not a production data integration. It
+should help answer:
+
+- Does the provider expose subscription limits in visible UI text?
+- Does the provider expose subscription limits in browser-accessible structured
+ responses after a normal login?
+- Which observations are safe and reliable enough to become Context Panel
+ signals?
+- Which observations should stay manual/calibrated because the provider hides or
+ changes them?
+
+## Safety Rules
+
+- The user logs in directly with the provider. Context Panel never asks for or
+ stores the provider password.
+- The probe never prints, commits, uploads, or logs cookies, bearer tokens,
+ session IDs, full response bodies, or account identifiers.
+- Captured artifacts are local and gitignored by default.
+- Raw network/body capture is opt-in and redacted before display.
+- The default probe reports only route/method/status/content-type/body-size plus
+ detected field names or text snippets that match usage-limit patterns.
+- Automated message sending is out of scope. The probe observes login/account
+ pages and model picker state; it does not burn subscription allowance.
+
+## Recommended Implementation
+
+Build a local macOS diagnostic surface called **Limit Probe** inside the
+companion app or as a development-only target.
+
+For the first implementation, prefer a native `WKWebView`-based probe because it
+lets the user log in normally while keeping the session isolated from Safari or
+Chrome. Later, a browser-extension or browser-control probe can be considered if
+WKWebView cannot observe enough.
+
+### Flow
+
+1. User opens `Limit Probe`.
+2. User selects provider: OpenAI first, then Anthropic, then Google.
+3. App opens an isolated `WKWebView` at the provider's normal product URL.
+4. User logs in normally.
+5. Probe shows a checklist:
+ - Login detected.
+ - Model picker/account UI reachable.
+ - Limit/reset text detected.
+ - Candidate structured responses detected.
+ - Manual observation needed.
+6. User navigates to the relevant UI, such as ChatGPT model picker.
+7. Probe scans visible text and sanitized network metadata for usage/reset
+ signals.
+8. User can press `Record Observation` to save a sanitized event.
+9. User can press `Export Redacted Report` for a local Markdown/JSON report.
+
+### OpenAI Targets
+
+Initial OpenAI pages and states to inspect:
+
+- ChatGPT app after login.
+- Model picker when GPT-5/GPT-5.5/Thinking modes are available.
+- Provider UI when a model is close to its limit.
+- Provider UI when a model is unavailable or limit-reached.
+- Account or plan surfaces that mention current plan and reset.
+
+Detection patterns:
+
+- `reset`, `resets`, `refresh`, `available`, `limit`, `usage`, `percent`,
+ `tokens`, `weekly`, `every 3 hours`, `every 5 hours`, `Thinking`, `fast`,
+ `temporary`.
+- Dates and relative durations such as `tomorrow`, `in 42m`, `3h`, `5 hours`,
+ `7 days`, `weekly`.
+- JSON field names containing `limit`, `usage`, `used_percent`, `token`,
+ `remaining`, `reset`, `cap`, `quota`, `model`, or `plan`.
+
+## Capture Model
+
+```swift
+struct LimitProbeObservation: Codable, Sendable {
+ var provider: Provider
+ var accountLabel: String?
+ var surface: String
+ var observedAt: Date
+ var source: ProbeSource
+ var signal: ProbeSignal
+ var confidence: UsageConfidence
+ var sanitizedEvidence: String
+}
+
+enum ProbeSource: String, Codable, Sendable {
+ case visibleText
+ case networkMetadata
+ case redactedResponseShape
+ case manualUserEntry
+}
+
+enum ProbeSignal: Codable, Sendable {
+ case resetTime(Date)
+ case relativeReset(seconds: TimeInterval)
+ case knownLimit(used: Int?, limit: Int, unit: String)
+ case modelAvailable(model: String)
+ case modelUnavailable(model: String, reason: String?)
+ case plan(name: String)
+ case unknownLimit
+}
+```
+
+## Sanitized Network Probe
+
+`WKWebView` does not expose every network body through public APIs. There are
+three possible tiers:
+
+1. **Visible text only**: safest and easiest. JavaScript reads `document.body`
+ text after login and extracts matching snippets.
+2. **JavaScript fetch instrumentation**: inject a user script that wraps
+ `window.fetch` and `XMLHttpRequest` to record sanitized URL path, method,
+ status, content type, body size, and matching field names from JSON responses.
+ Do not store headers or raw bodies.
+3. **External browser-control/devtools probe**: use a separate development-only
+ probe with browser automation/DevTools if WKWebView cannot see enough. This
+ remains local and diagnostic-only.
+
+Start with tier 1 and tier 2. Escalate only if OpenAI hides the useful signal
+from visible text and simple response-shape inspection.
+
+## Report Shape
+
+The exported report should be local and safe to share in a PR or issue after
+review:
+
+```json
+{
+ "schema_version": 1,
+ "provider": "openai",
+ "captured_at": "2026-05-05T00:00:00Z",
+ "surfaces": [
+ {
+ "surface": "chatgpt-model-picker",
+ "signals": [
+ {
+ "source": "visibleText",
+ "signal": "relativeReset",
+ "evidence": "resets in 3h",
+ "confidence": "observed"
+ }
+ ]
+ }
+ ],
+ "candidate_network_shapes": [
+ {
+ "method": "GET",
+ "path_hint": "/.../models/...",
+ "status": 200,
+ "content_type": "application/json",
+ "matched_fields": ["model", "limit", "reset"]
+ }
+ ],
+ "redactions": [
+ "cookies",
+ "authorization headers",
+ "account ids",
+ "emails",
+ "raw response bodies"
+ ]
+}
+```
+
+## Acceptance Criteria For A Prototype
+
+- User can log in to OpenAI in an isolated local web view.
+- Probe can scan visible UI text for limit/reset signals without storing
+ secrets.
+- Probe can record a manual observation when the UI exposes reset or limit state.
+- Probe can show whether any structured candidate responses appear, without
+ revealing raw response bodies or tokens.
+- Probe writes only redacted local artifacts under a gitignored directory.
+- Findings can update `docs/provider-usage-access.md` with evidence and
+ confidence.
+
+## Open Questions
+
+- Does ChatGPT's login flow work reliably in `WKWebView`, or does it require the
+ user's default browser?
+- Does the model picker expose useful reset text before a limit is reached?
+- Can visible text reveal enough to calibrate weekly Thinking limits without
+ network inspection?
+- Are subscription limit signals account-specific enough to distinguish multiple
+ OpenAI accounts cleanly?
diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md
index f5f3f97..3ebb7aa 100644
--- a/docs/provider-usage-access.md
+++ b/docs/provider-usage-access.md
@@ -1,6 +1,6 @@
# Provider Usage Access Research
-Last verified: 2026-05-05.
+Last verified: 2026-05-06.
## Summary
@@ -9,15 +9,15 @@ Context Panel should model two different worlds:
- API/provider-console usage, where official usage APIs, cost APIs, quota APIs,
rate-limit headers, or Cloud Monitoring data can provide real measurements.
- Consumer chat subscription usage, where providers often expose limits and reset
- timing in product UI but do not expose a stable public API for remaining
- message allowance.
+ timing in product UI but do not expose a stable public API for the underlying
+ percent or token pressure.
The OpenAI account use case needs special treatment. For ChatGPT-style weekly
-message budgets, official OpenAI help currently documents weekly Thinking limits
-and reset behavior, but not a reliable API for personal message usage counts.
-Context Panel should therefore support local forecasting: multiple OpenAI
-accounts, reset windows, manually or locally observed usage, burn-rate history,
-and a clear answer to "am I safe to turn on fast mode?"
+subscription limits, current product surfaces have moved away from a visible
+message counter. Context Panel should model OpenAI usage as percent or token
+pressure over reset windows: multiple OpenAI accounts, reset windows, observed
+usage pressure, burn-rate history, and a clear answer to "am I safe to turn on
+fast mode?"
## Forecast Requirement
@@ -51,26 +51,31 @@ instead of pretending an estimate is exact.
## Provider Matrix
-| Provider surface | Official data available | Reset/limit signal | Multi-login shape | V1 recommendation | Confidence |
-| --- | --- | --- | --- | --- | --- |
-| OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High |
-| OpenAI ChatGPT accounts | No stable public API found for personal ChatGPT message allowance. Help docs say some model budgets expose reset date in the model picker and, for that documented budget, there is no way to check messages used. Current GPT-5.5 Thinking docs document weekly limits and pop-up behavior at exhaustion. | Weekly Thinking limits exist for Plus/Business. Older OpenAI help explicitly says weekly limits reset seven days after first use and the reset date is visible by hovering the model name. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, and local observation history. | Start with manual/assisted local tracking: account profile, plan/bucket defaults, user-entered or UI-observed reset time, local message counter, and forecast confidence. Avoid credential sharing and avoid automated extraction that could violate terms. | Medium for reset; low for used count without local tracking |
-| Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High |
-| Claude subscriptions and Claude Code seats | Public docs describe usage limits across Claude.ai, Claude Code, and Claude Desktop, but no stable public API for personal subscription allowance was found. Claude Code can show session cost for API-key usage. | Pro/Max/Team usage has session-based reset behavior; Claude Code Enterprise seats show reset time when a limit is reached. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Defer automated subscription tracking unless a supported local/official signal is found. Support manual observation later; prioritize Anthropic API first. | Medium for displayed limits; low for automation |
-| Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier |
-| Google consumer Gemini app subscriptions | No stable public API for personal Gemini app subscription allowance was found in this pass. | Provider UI likely remains source of truth. | Multiple Google accounts may matter, but automation risk is high. | Defer for v1 unless a supported API emerges. | Low |
+| Provider surface | Official data available | Reset/limit signal | Multi-login shape | V1 recommendation | Confidence |
+| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High |
+| OpenAI ChatGPT accounts | No stable public API found for general personal ChatGPT subscription pressure outside Codex. Current product surfaces no longer present a simple message counter; the useful automated signal found so far is percent-used pressure for Codex/Fast Mode. | Weekly and short rolling reset windows matter. Codex/Fast Mode exposes live percent-used windows through the Codex backend usage endpoint. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, percent/token pressure, and local observation history. | For Codex/Fast Mode, use the live Codex usage endpoint. For non-Codex ChatGPT surfaces, keep manual/assisted observations and forecast confidence until a clean provider signal exists. | High for Codex percent windows; medium for visible reset clues; low for non-Codex automation |
+| Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High |
+| Claude subscriptions and Claude Code seats | Claude Code status-line JSON can include `rate_limits.five_hour` and `rate_limits.seven_day` for Claude.ai Pro and Max subscribers after a session receives an API response. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. Non-interactive `claude -p --output-format stream-json --verbose` can emit `rate_limit_info` with status, active window type, and reset time, but no used percentage was observed. `ccusage blocks --json --offline` can derive active 5-hour block token use and runway from local aggregate session data used by Every Code. | Status-line rate-limit windows include used percentage and reset epochs. Pro/Max/Team usage has session-based reset behavior. `ccusage` estimates token pressure and reset/runway rather than official server percent. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata, local activity freshness, optional status-line rate-limit cache, and an explicit Every Code-compatible `ccusage` estimate. Mark old status-line readings stale, and label `ccusage` rows as estimated. Do not read auth, Keychain, raw transcripts, prompts, or conversation JSONL directly. | High for fresh status-line subscription windows when configured; medium for non-interactive reset/status metadata; medium for `ccusage` estimated runway; medium for auth/subscription metadata and local history |
+| Google Gemini CLI / Code Assist | Gemini CLI OAuth credentials can be refreshed locally, then the Code Assist backend returns live quota buckets with model IDs, remaining fractions, optional remaining amounts, and reset times. | Quota buckets are percent-style remaining fractions per model with provider reset timestamps. | Google account plus Code Assist project is the natural boundary; multiple `GEMINI_CLI_HOME` roots can represent multiple logins. | Support a Gemini Code Assist live quota connector using Gemini CLI auth. Store only normalized percent pressure and reset times. | High for Gemini CLI/Code Assist buckets observed locally |
+| Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier |
+| Google consumer Gemini app subscriptions | No stable public API for personal Gemini app subscription allowance was found in this pass. | Provider UI likely remains source of truth. | Multiple Google accounts may matter, but automation risk is high. | Defer for v1 unless a supported API emerges. | Low |
## OpenAI Fast-Mode Forecasting
-For the user's immediate OpenAI need, the best product path is not a hidden
-provider API. It is an honest local predictor:
+For the user's immediate OpenAI need, the best product path is a live Codex
+percent-window connector backed by an honest local predictor:
1. Add each OpenAI account separately.
-2. Record plan and relevant buckets, such as GPT-5.5 Thinking weekly allowance.
-3. Capture reset time from the UI when available, or let the user enter it.
-4. Start a local usage ledger from the moment Context Panel is installed.
-5. Allow manual correction when the provider UI reveals a reset or limit state.
-6. Estimate standard and fast-mode burn rates from local usage history.
+2. Record plan and relevant buckets, such as Codex/Fast Mode weekly and
+ five-hour windows.
+3. Fetch current percent-used pressure and reset times when the Codex endpoint
+ is available.
+4. Capture reset time from the UI when no endpoint signal exists, or let the
+ user enter it.
+5. Start a local usage ledger from the moment Context Panel is installed.
+6. Estimate standard and fast-mode burn rates in percent or tokens per hour
+ from local usage history.
7. Recommend when to enable fast mode only when the forecast has enough margin.
The widget should make confidence visible. Good copy examples:
@@ -80,6 +85,199 @@ The widget should make confidence visible. Good copy examples:
- `Save fast mode: projected to run out 18h before reset.`
- `Needs calibration: open ChatGPT and set reset time.`
+## Local Probe And Every Code Evidence
+
+The first OpenAI Limit Probe run confirmed the uncomfortable but useful shape of
+the problem:
+
+- ChatGPT visible text exposed plan/model language such as model names, `Pro`,
+ `Instant`, and `Thinking`, but did not expose a percent/token counter or a
+ reset time before exhaustion.
+- Sanitized network response-shape scanning found account entitlement and plan
+ fields, including subscription-plan style field names, but no obvious
+ `used_percent`, token pressure, `reset_at`, weekly allowance, or five-hour
+ allowance fields outside the Codex usage surface.
+- The probe should remain useful as a diagnostic harness because it can detect
+ if OpenAI later starts exposing cleaner fields, and it can produce redacted
+ evidence across multiple accounts.
+
+Codex-family tooling exposes a stronger path for Codex/Fast Mode. Upstream
+Codex CLI has an app-server method, `account/rateLimits/read`, backed by a
+backend client call that fetches live snapshots from the ChatGPT Codex backend:
+`GET /backend-api/wham/usage` for ChatGPT-backed auth, or `/api/codex/usage` for
+Codex API-style deployments. The payload maps into rate-limit snapshots with
+provider window buckets, reset times, plan type, credits, reached-limit
+classification, and additional buckets keyed by `limit_id`.
+
+Every Code is useful as a fallback and validation source. It does not derive
+Codex rate-limit snapshots from local token counts. It sends authenticated
+requests to the ChatGPT Codex backend, parses server-reported `x-codex-*`
+response headers into percentage and reset-window snapshots, and persists the
+latest server snapshot under local usage files. The local files are a cache of
+server state plus local token history, which explains why displayed limit
+pressure reflects cloud and other-machine usage for the same account.
+
+Every Code also has a deliberate refresh path: it sends a tiny `"ok"` prompt via
+the selected account, waits for a `RateLimits` event from response headers, then
+persists the snapshot and updates the `/limits` UI. Separately, when the backend
+returns `usage_limit_reached`, it records `plan_type`, `resets_in_seconds`, and
+the reached-limit type as a hint.
+
+That is stronger evidence than visible ChatGPT UI scraping for Codex-style
+limits, but it is still product-surface-specific. Context Panel should separate
+`OpenAI ChatGPT product UI hints` from `OpenAI Codex backend percent windows`.
+The latter looks viable as an automated adapter if Context Panel can reuse the
+same authenticated account flow safely.
+
+Implication: v1 should not promise exact general ChatGPT subscription counters.
+For Codex/Fast Mode, though, the preferred path is a live OpenAI Codex limits
+connector using the same shape as Codex CLI's
+`account/rateLimits/read`/`get_rate_limits_many()` flow. If that cannot be made
+stable or safely testable, fall back to Every Code's local `usage/*.json` cache
+or Codex CLI's app-server request.
+
+### Codex Limits Connector
+
+Preferred v1 connector scope:
+
+- Fetch live Codex limits directly from the Codex backend usage endpoint shape:
+ `GET https://chatgpt.com/backend-api/wham/usage` for ChatGPT-backed auth.
+- Support provider window buckets, reset times, plan type, credits,
+ reached-limit classification, and additional `limit_id` buckets.
+- Keep auth handling isolated and redacted; never log tokens, cookies,
+ authorization headers, account IDs, emails, or raw response bodies.
+- Expose a diagnostic probe that reports only sanitized structure, percentages,
+ reset timing, bucket labels, and staleness.
+- Mark this as an OpenAI Codex/Fast Mode percent-window source, not a general
+ ChatGPT subscription counter.
+- Do not require the Codex CLI binary or app server at runtime. The local
+ `CodexRateLimitProbe` executable exists to prove the direct call path against
+ an existing Codex `auth.json` while printing only redacted summaries.
+
+### Gemini Code Assist Connector
+
+The local Gemini CLI path gives Context Panel a second viable live connector.
+The CLI stores OAuth credentials under `~/.gemini/oauth_creds.json`, while the
+active account metadata lives separately under `~/.gemini/google_accounts.json`.
+The quota values are not persisted as a durable local cache; Gemini CLI keeps
+quota state in memory and refreshes it from the Code Assist backend.
+
+Preferred v1 connector scope:
+
+- Resolve `GEMINI_CLI_HOME`, then default to `~/.gemini`.
+- Read `oauth_creds.json` only to refresh an access token locally; never print,
+ store, or upload token values.
+- Call the Gemini Code Assist load path to resolve the active project internally;
+ never print or persist the raw project identifier.
+- Call the Gemini Code Assist quota path and normalize buckets by model ID,
+ remaining fraction, optional remaining amount, and reset time.
+- Represent each bucket as percent pressure: `used = round((1 - remaining) *
+100)`, `limit = 100`, `unit = percent`.
+- Mark confidence as observed because this is a product backend surface rather
+ than a public quota API contract.
+
+The local `GeminiQuotaProbe` executable proves this path with redacted output.
+On 2026-05-06 it returned seven live model buckets for the local Gemini CLI
+account, including Gemini 2.5 and Gemini 3 preview models, with percent
+remaining and reset timestamps.
+
+### Claude Subscription Connector
+
+Claude subscription pressure should use Claude Code's supported status-line JSON
+surface, not Anthropic API organization usage. Claude Code's status-line input
+can contain `rate_limits.five_hour.used_percentage`,
+`rate_limits.five_hour.resets_at`, `rate_limits.seven_day.used_percentage`, and
+`rate_limits.seven_day.resets_at` for Claude.ai Pro and Max subscribers after a
+Claude Code session receives an API response.
+
+This status-line surface is currently interactive-session scoped. On
+2026-05-06, a local non-interactive probe using `claude -p "Reply with exactly
+OK." --output-format json --verbose` emitted a `rate_limit_event` with
+`rate_limit_info.status`, `rateLimitType`, and `resetsAt`, but did not emit
+`used_percentage` and did not refresh the configured status-line cache. A local
+`claude -p "/usage" --output-format json --verbose` probe returned only that the
+Claude Code subscription was in use, not the five-hour or weekly usage
+percentages. This means Every Code's current external `claude -p` agent path
+does not by itself provide official subscription percent pressure.
+
+Local binary/bundle inspection found runtime strings for
+`anthropic-ratelimit-unified-*`, `utilization`, `five_hour`, and `seven_day` in
+Claude Code/Desktop surfaces. It also found the Claude web/desktop usage hook:
+the installed Claude app calls
+`GET /api/organizations/{active_organization_uuid}/usage` and refreshes it on a
+five-minute interval from the settings usage page. The usage page component is
+what renders "Claude subscription usage", current-session/five-hour usage, and
+weekly limit rows.
+
+That endpoint is the strongest direct subscription API candidate found so far,
+but it is authenticated through the Claude web/app session. A local automated
+browser probe against `https://claude.ai/settings/usage` on 2026-05-06 was
+blocked by Cloudflare before login/session reuse, so Context Panel has not yet
+proven it can call this endpoint without a user-visible web login context. We
+should not extract browser cookies, Keychain credentials, OAuth tokens, local
+storage, raw response bodies, transcripts, account UUIDs, or emails to force the
+call. The next safe implementation path is a Claude web usage probe that runs in
+a user-visible embedded web session and records only sanitized fields such as
+`five_hour`, `seven_day`, `used_percentage`, `remaining_percentage`,
+`utilization`, and `resets_at`.
+
+The local `ClaudeWebUsageProbe` executable implements that path. It opens
+Claude's usage page in a visible WebKit session, lets the user complete login or
+Cloudflare verification normally, observes only `/api/organizations/*/usage`
+responses, and reduces the page response to whitelisted usage windows before
+Swift receives anything. Saving from the probe writes normalized percent/reset
+rows to Context Panel's snapshot store; it does not persist cookies,
+authorization headers, tokens, local storage, account UUIDs, organization UUIDs,
+emails, or raw response bodies.
+
+No safe persisted local Claude Desktop file/cache containing official
+subscription percentages was found. The remaining research target is an
+explicit, privacy-safe metadata capture path for Every Code/non-interactive
+usage, tracked separately in issue #19.
+
+The official Claude Code authentication docs say macOS credentials are stored in
+the encrypted macOS Keychain. Context Panel must not read Keychain secrets or
+try to extract subscription OAuth tokens.
+
+Preferred v1 connector scope:
+
+- Call `claude auth status --json` and keep only non-secret fields such as
+ `loggedIn`, `authMethod`, `apiProvider`, and `subscriptionType`.
+- Offer a tiny status-line helper that receives Claude Code status-line JSON on
+ stdin and writes only observed timestamp, five-hour percentage/reset, and
+ weekly percentage/reset to a Context Panel cache file.
+- Read that sanitized status-line cache and normalize Claude five-hour and
+ weekly windows as percent limits when present.
+- Read `~/.claude/stats-cache.json` only as local historical activity, not live
+ subscription allowance.
+- Summarize local stats by freshness and counts; do not read raw transcript
+ JSONL files, prompts, account UUIDs, emails, organization IDs, or token blobs.
+- Show Claude subscription allowance as unknown until the status-line cache has
+ been populated by a live Claude Code response.
+- Treat `ccusage` and local token aggregates as estimated pressure only; never
+ present them as official Claude subscription percent used.
+
+The local `ClaudeLimitProbe` executable proves the conservative fallback path.
+On 2026-05-06 it confirmed the local Claude CLI is logged in with subscription
+metadata and has a local stats cache. The follow-up subscription path is the
+sanitized status-line cache, not raw Claude auth/session data.
+
+### Every Code Cache Fallback
+
+Fallback connector scope:
+
+- Resolve `CODE_HOME`, then `CODEX_HOME`, then default to `~/.code`.
+- Read only `$CODE_HOME/usage/*.json`.
+- Never read auth files, token files, debug logs, history files, or config files
+ for the connector.
+- Parse `rate_limit.snapshot`, `observed_at`, `primary_next_reset_at`,
+ `secondary_next_reset_at`, `last_usage_limit_hit_at`, and `plan`.
+- Normalize Codex provider window buckets as limits with observed
+ confidence and freshness state.
+- Mark the connector stale when `observed_at` or `last_updated` is older than a
+ conservative threshold; do not trigger refreshes.
+- Show account IDs only as short local labels unless the user assigns names.
+
## Product Decisions
- Treat `unknown`, `manual`, `observed`, and `official` as distinct confidence
@@ -98,8 +296,11 @@ The widget should make confidence visible. Good copy examples:
- [OpenAI o3 and o4-mini usage limits](https://help.openai.com/en/articles/9824962-openai-o1and-o1-mini-usage-limits-on-chatgpt-and-the-api)
- [Anthropic Usage and Cost API](https://platform.claude.com/docs/en/build-with-claude/usage-cost-api)
- [Anthropic API rate limits](https://docs.anthropic.com/en/api/rate-limits)
+- [Claude Code authentication](https://code.claude.com/docs/en/authentication)
- [Claude usage and length limits](https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work)
- [Models, usage, and limits in Claude Code](https://support.claude.com/en/articles/14552983-models-usage-and-limits-in-claude-code)
+- [Gemini CLI authentication](https://google-gemini.github.io/gemini-cli/docs/get-started/authentication.html)
+- [Gemini CLI quotas and pricing](https://google-gemini.github.io/gemini-cli/docs/quota-and-pricing.html)
- [Gemini API billing](https://ai.google.dev/gemini-api/docs/billing/)
- [Gemini API rate limits](https://ai.google.dev/gemini-api/docs/rate-limits)
- [Google Service Usage consumer quota metrics](https://cloud.google.com/service-usage/docs/reference/rest/v1beta1/services.consumerQuotaMetrics/list)
diff --git a/docs/release.md b/docs/release.md
new file mode 100644
index 0000000..bd1e890
--- /dev/null
+++ b/docs/release.md
@@ -0,0 +1,80 @@
+# macOS Release Path
+
+Last verified: 2026-05-06.
+
+Context Panel has two local packaging paths:
+
+- a native Xcode app target with an embedded WidgetKit extension, generated by
+ XcodeGen
+- an interim SwiftPM wrapper script for quickly sharing the preview app without
+ the widget extension
+
+The native Xcode path is the one to use for widget installability.
+
+## Build The Native App And Widget
+
+```sh
+xcodegen generate --spec project.yml
+xcodebuild \
+ -project ContextPanel.xcodeproj \
+ -scheme ContextPanel \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ -allowProvisioningUpdates \
+ build
+```
+
+This builds `Context Panel.app` and embeds
+`ContextPanelWidgetExtension.appex` under `Contents/PlugIns`. The app and widget
+both carry the `group.com.shinycomputers.contextpanel` App Group entitlement and
+share snapshots through the App Group container when it is available.
+
+The Xcode project is generated from `project.yml`; update the spec and
+regenerate rather than editing the project by hand.
+
+On 2026-05-06, the Debug Xcode build succeeded locally with automatic signing,
+embedded the WidgetKit extension, included the WidgetKit extension point in the
+extension Info.plist, and passed `codesign --verify --deep --strict`.
+
+## Build A Signed App
+
+```sh
+scripts/package-macos-app.sh --output dist --identity auto
+```
+
+The script builds `ContextPanelPreview` in release mode, wraps it as
+`dist/Context Panel.app`, writes app metadata, signs the bundle, verifies the
+signature, and runs a local Gatekeeper assessment.
+
+`--identity auto` asks Keychain for available code-signing identities and
+prefers `Developer ID Application`, then `Apple Development`, then ad-hoc
+signing. The script does not read private keys or credentials; signing is
+performed by macOS Keychain through `codesign`.
+
+Useful variants:
+
+```sh
+scripts/package-macos-app.sh --debug
+scripts/package-macos-app.sh --identity -
+scripts/package-macos-app.sh \
+ --product ClaudeWebUsageProbe \
+ --display-name "Claude Usage Probe" \
+ --bundle-id com.shinycomputers.contextpanel.claudeprobe
+```
+
+## Current Constraints
+
+- The package is signed but not notarized by this script.
+- The script's app bundle contains the SwiftPM preview app, not the native Xcode
+ app target.
+- The script does not embed the WidgetKit extension; use the Xcode build for
+ widget testing.
+- Full friend-installable distribution still needs archive/export and
+ notarization polish.
+
+## Validation
+
+On 2026-05-06, `scripts/package-macos-app.sh --output dist --identity auto`
+produced `dist/Context Panel.app`, signed with Developer ID Application:
+Shiny Computers Leasing LLC, and `spctl --assess --type execute` accepted the
+bundle as Developer ID signed.
diff --git a/project.yml b/project.yml
new file mode 100644
index 0000000..f9c3967
--- /dev/null
+++ b/project.yml
@@ -0,0 +1,78 @@
+---
+name: ContextPanel
+options:
+ bundleIdPrefix: com.shinycomputers
+ deploymentTarget:
+ macOS: "14.0"
+settings:
+ base:
+ DEVELOPMENT_TEAM: MM5YXC7T6E
+ CODE_SIGN_STYLE: Automatic
+ SWIFT_VERSION: "6.0"
+ MACOSX_DEPLOYMENT_TARGET: "14.0"
+packages: {}
+targets:
+ ContextPanelCore:
+ type: library.static
+ platform: macOS
+ deploymentTarget: "14.0"
+ sources:
+ - path: Sources/ContextPanelCore
+ settings:
+ base:
+ PRODUCT_NAME: ContextPanelCore
+ SKIP_INSTALL: true
+
+ ContextPanel:
+ type: application
+ platform: macOS
+ deploymentTarget: "14.0"
+ sources:
+ - path: Sources/ContextPanelPreview
+ dependencies:
+ - target: ContextPanelCore
+ - target: ContextPanelWidgetExtension
+ embed: true
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.shinycomputers.contextpanel
+ PRODUCT_NAME: Context Panel
+ GENERATE_INFOPLIST_FILE: true
+ INFOPLIST_KEY_CFBundleDisplayName: Context Panel
+ MARKETING_VERSION: "1.0"
+ CURRENT_PROJECT_VERSION: "1"
+ INFOPLIST_KEY_LSApplicationCategoryType: >-
+ public.app-category.productivity
+ CODE_SIGN_ENTITLEMENTS: Config/ContextPanel.entitlements
+
+ ContextPanelWidgetExtension:
+ type: app-extension
+ platform: macOS
+ deploymentTarget: "14.0"
+ sources:
+ - path: Sources/ContextPanelWidget
+ dependencies:
+ - target: ContextPanelCore
+ info:
+ path: Config/ContextPanelWidget-Info.plist
+ properties:
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.widgetkit-extension
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.shinycomputers.contextpanel.widget
+ PRODUCT_NAME: ContextPanelWidgetExtension
+ GENERATE_INFOPLIST_FILE: false
+ CODE_SIGN_ENTITLEMENTS: Config/ContextPanelWidget.entitlements
+ SKIP_INSTALL: true
+
+schemes:
+ ContextPanel:
+ build:
+ targets:
+ ContextPanel: all
+ ContextPanelWidgetExtension: all
+ run:
+ config: Debug
+ archive:
+ config: Release
diff --git a/scripts/claude-ccusage-cache.sh b/scripts/claude-ccusage-cache.sh
new file mode 100755
index 0000000..1b85566
--- /dev/null
+++ b/scripts/claude-ccusage-cache.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cache_dir="${CONTEXT_PANEL_CLAUDE_RATE_LIMIT_DIR:-$HOME/Library/Application Support/Context Panel/ClaudeRateLimits}"
+cache_file="$cache_dir/ccusage-blocks-cache.json"
+
+mkdir -p "$cache_dir"
+
+if command -v ccusage >/dev/null 2>&1; then
+ ccusage blocks --json --offline >"$cache_file.tmp"
+elif command -v bunx >/dev/null 2>&1; then
+ bunx ccusage@latest blocks --json --offline >"$cache_file.tmp"
+else
+ echo "ccusage or bunx is required" >&2
+ exit 127
+fi
+
+mv "$cache_file.tmp" "$cache_file"
+echo "$cache_file"
diff --git a/scripts/claude-statusline-cache.sh b/scripts/claude-statusline-cache.sh
new file mode 100755
index 0000000..03348be
--- /dev/null
+++ b/scripts/claude-statusline-cache.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+input="$(cat)"
+out_dir="${CONTEXT_PANEL_CLAUDE_RATE_LIMIT_DIR:-$HOME/Library/Application Support/Context Panel/ClaudeRateLimits}"
+out_file="$out_dir/statusline-cache.json"
+tmp_file="${out_file}.tmp"
+
+mkdir -p "$out_dir"
+
+cache_json="$(jq -c --argjson observed_at "$(date +%s)" '
+ def clean_window:
+ select(type == "object")
+ | {
+ used_percentage: (.used_percentage // empty),
+ resets_at: (.resets_at // null)
+ };
+
+ {
+ observed_at: $observed_at,
+ rate_limits: {
+ five_hour: (.rate_limits.five_hour? | clean_window),
+ seven_day: (.rate_limits.seven_day? | clean_window)
+ }
+ }
+ | .rate_limits |= with_entries(select(.value != null))
+ | select((.rate_limits | length) > 0)
+' <<<"$input")"
+
+if [ -n "$cache_json" ]; then
+ printf '%s\n' "$cache_json" >"$tmp_file"
+ mv "$tmp_file" "$out_file"
+fi
+
+model="$(jq -r '.model.display_name // "Claude"' <<<"$input")"
+five_hour="$(jq -r '.rate_limits.five_hour.used_percentage // empty' <<<"$input")"
+weekly="$(jq -r '.rate_limits.seven_day.used_percentage // empty' <<<"$input")"
+
+limits=""
+if [ -n "$five_hour" ]; then
+ limits="5h: $(printf '%.0f' "$five_hour")%"
+fi
+if [ -n "$weekly" ]; then
+ limits="${limits:+$limits }7d: $(printf '%.0f' "$weekly")%"
+fi
+
+if [ -n "$limits" ]; then
+ printf '[%s] | %s\n' "$model" "$limits"
+else
+ printf '[%s]\n' "$model"
+fi
diff --git a/scripts/package-macos-app.sh b/scripts/package-macos-app.sh
new file mode 100755
index 0000000..8e09c15
--- /dev/null
+++ b/scripts/package-macos-app.sh
@@ -0,0 +1,175 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+product="ContextPanelPreview"
+display_name="Context Panel"
+bundle_id="com.shinycomputers.contextpanel"
+configuration="release"
+output_dir="dist"
+signing_identity="auto"
+
+usage() {
+ cat <<'USAGE'
+Usage: scripts/package-macos-app.sh [options]
+
+Builds a SwiftPM executable and wraps it in a launchable macOS .app bundle.
+
+Options:
+ --product NAME SwiftPM executable product. Default: ContextPanelPreview
+ --display-name NAME App display name. Default: Context Panel
+ --bundle-id ID CFBundleIdentifier. Default: com.shinycomputers.contextpanel
+ --debug Build debug instead of release
+ --output DIR Output directory. Default: dist
+ --identity VALUE codesign identity, "auto", or "-" for ad-hoc. Default: auto
+ -h, --help Show this help
+
+The script never reads private keys or credentials. When --identity auto is
+used, it asks Keychain for signing identities and prefers Developer ID
+Application, then Apple Development, then ad-hoc signing.
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --product)
+ product="${2:?--product requires a value}"
+ shift 2
+ ;;
+ --display-name)
+ display_name="${2:?--display-name requires a value}"
+ shift 2
+ ;;
+ --bundle-id)
+ bundle_id="${2:?--bundle-id requires a value}"
+ shift 2
+ ;;
+ --debug)
+ configuration="debug"
+ shift
+ ;;
+ --output)
+ output_dir="${2:?--output requires a value}"
+ shift 2
+ ;;
+ --identity)
+ signing_identity="${2:?--identity requires a value}"
+ shift 2
+ ;;
+ -h | --help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "unknown option: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+build_args=(--product "$product")
+if [[ "$configuration" == "release" ]]; then
+ build_args+=(--configuration release)
+fi
+
+swift build "${build_args[@]}"
+
+bin_path_args=(--show-bin-path)
+if [[ "$configuration" == "release" ]]; then
+ bin_path_args+=(--configuration release)
+fi
+triple=$(swift build "${bin_path_args[@]}" 2>/dev/null || true)
+if [[ -z "$triple" ]]; then
+ if [[ "$configuration" == "release" ]]; then
+ triple=".build/release"
+ else
+ triple=".build/debug"
+ fi
+fi
+
+executable_path="$triple/$product"
+if [[ ! -x "$executable_path" ]]; then
+ echo "built executable not found: $executable_path" >&2
+ exit 1
+fi
+
+app_path="$output_dir/$display_name.app"
+contents="$app_path/Contents"
+macos="$contents/MacOS"
+resources="$contents/Resources"
+
+rm -rf "$app_path"
+mkdir -p "$macos" "$resources"
+cp "$executable_path" "$macos/$product"
+
+cat >"$contents/Info.plist" <
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $product
+ CFBundleIdentifier
+ $bundle_id
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $display_name
+ CFBundleDisplayName
+ $display_name
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 0.1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 14.0
+ NSHighResolutionCapable
+
+ NSPrincipalClass
+ NSApplication
+
+
+PLIST
+
+printf 'APPL????' >"$contents/PkgInfo"
+
+resolve_identity() {
+ if [[ "$signing_identity" != "auto" ]]; then
+ printf '%s\n' "$signing_identity"
+ return
+ fi
+
+ local identity
+ identity=$(security find-identity -v -p codesigning 2>/dev/null |
+ sed -n 's/^.*"\(Developer ID Application:[^"]*\)".*$/\1/p' |
+ head -n 1)
+ if [[ -z "$identity" ]]; then
+ identity=$(security find-identity -v -p codesigning 2>/dev/null |
+ sed -n 's/^.*"\(Apple Development:[^"]*\)".*$/\1/p' |
+ head -n 1)
+ fi
+ printf '%s\n' "${identity:--}"
+}
+
+resolved_identity=$(resolve_identity)
+if [[ "$resolved_identity" == "-" ]]; then
+ codesign --force --sign - "$app_path"
+else
+ if ! codesign --force --options runtime --timestamp --sign "$resolved_identity" "$app_path"; then
+ echo "Developer signing failed; retrying ad-hoc signing." >&2
+ codesign --force --sign - "$app_path"
+ resolved_identity="-"
+ fi
+fi
+
+codesign --verify --deep --strict --verbose=2 "$app_path"
+spctl --assess --type execute --verbose=2 "$app_path" || true
+
+echo "Packaged: $app_path"
+echo "Executable: $product"
+echo "Bundle ID: $bundle_id"
+echo "Signing identity: $resolved_identity"