diff --git a/DESIGN.md b/DESIGN.md index 6c0e9c0..282411d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -155,3 +155,7 @@ Preserve the owner-selected sortable ledger and delegated answer-first direction Content comparison is explicit because it reads local document bodies. Matching contents link to counterpart locations; independent scope remains a review decision. Document changes require a before/after preview, with archive consequences and guarded recovery visible. Plugin action sheets show exact owner, scope and command before applying. Activity distinguishes filtered session history from unfiltered live aggregates and gives direct folder-preserving review actions. Responsive native renders cover 390, 768 and 1440 points. These are fixture renders, not evidence that cloud memories or unsupported agent adapters are resolved. Installed interactive checks are tracked separately in artifacts/design/complete-workflows/verification.md. + +## Claude reset detail completion + +Preserve the released two-provider-card composition. Within Claude Reset grants, label Full resets and 5-hour resets separately, put each reported expiry immediately below its scope, and qualify paused or currently unusable grants. Keep failed details unknown with a visible message and the existing Claude Usage handoff. Provenance and check time remain inside the source disclosure. No redemption control is introduced. diff --git a/PRODUCT.md b/PRODUCT.md index df4fecf..4cee684 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -31,6 +31,7 @@ The primary user is a developer running Codex, Claude, Cursor, Devin, or Grok on - Historical usage can be inspected by service, observed model or inferred model provider, time range, metric, and day/week/month scale. Model and provider mix comes from daily accounting. Project grouping and recent activity use separately labelled, verified session identities and may not reconcile to daily totals. Devin's indexed daily tokens join the same model and provider history, with a shared agent filter; cost and project attribution remain unavailable. - ccusage history is read directly and offline; CodeVetter is not a runtime dependency. Provider allowance has a manual check and an opt-in, throttled check on opening Usage. Devin's indexed daily tokens join local history independently of ccusage availability. Unavailable history is explicitly labelled. - Codex full-reset credit expiry is shown only when the provider returns detail rows, and is labelled the latest reported expiry because the provider may cap those rows. +- Claude full and 5-hour reset grants are scoped read-only usage readings, separate from scheduled reset times and paid credits. Grant expiry and paused/unusable states remain explicit; unavailable or unsupported responses never imply zero. The existing default Claude Code credential is read only during manual or opted-in allowance checks, without credential writes, refreshes or reset redemption. - OTEL sessions, tools, models, tokens, compactions, errors, and named skill injections remain distinct signals instead of being flattened into one activity score. - OTEL is directly reachable and reports a disconnected local source as unavailable, never as zero activity. - Overlapping operation durations are never added together as wall time. diff --git a/README.md b/README.md index b4f4cee..4e1a165 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ContextDaddy -ContextDaddy is a local-first macOS control plane for coding-agent context. It answers two questions without reading secrets or capturing prompt bodies: +ContextDaddy is a local-first macOS control plane for coding-agent context. It answers two questions through bounded local discovery without capturing prompt bodies: 1. Which skills can Codex, Claude, Cursor, Devin, and Grok discover, and how can each runtime invoke them? 2. What are those agents consuming in context tokens, logical network events, and tool calls when trustworthy local telemetry exists? @@ -28,7 +28,7 @@ The primary navigation is **Usage**, **Skills**, **Projects**, and **OpenTelemet - Usage first scan shows side-by-side Codex/Claude allowance followed by one local history chart including Devin. The historical chart has shared agent filters, model/model-provider/project grouping where supported, range, day/week/month scale, generated/cache/cost metrics where available, selectable periods and exact breakdown. Model, project, and session drill-downs remain below. Cache reads, generated tokens, and estimated cost stay separate; unpriced models are flagged. - Skills can run an explicit, read-only **Read local history** scan. Claude and Devin `skill` tool calls are counted separately from Cursor/Grok skill-file reads and Codex tool calls that reference a `SKILL.md` path. The library shows last-seen dates, agent and folder evidence, 30/90-day or all-history windows, and an evidence-only filter. Cursor dates use transcript modification time because its records lack event timestamps. Name-only tool calls cannot identify a particular same-name copy; no recorded event never means unused. This backfill is held in memory for the app session and does not automatically remove skills. - Project grouping is a separately labelled session ledger, joining ccusage session IDs to Codex's read-only thread-index `cwd`, Grok's project paths, and Claude's encoded project slugs. It queries only Codex rollout path and working directory metadata, never prompt bodies. Buckets use session last activity, not an invented daily allocation; totals may not reconcile to daily accounting. Missing session identity remains **Unattributed**. Model-provider labels are inferred from reported model names, not billing endpoints; unknown aliases remain unknown. Devin's indexed daily tokens participate in the shared model and provider grouping; project attribution remains unavailable. -- Provider allowance for Codex and Claude is fetched on **Check both allowances**, or on opening Usage after the user enables the opt-in automatic switch (at most once per 15 minutes). It is never added to local token history. Codex's reported credit balance appears in credit units, separately from full-reset grants; unlimited, unavailable, and unreported balances remain explicit. Codex reset-credit expiry is shown only when reported; detail rows may be capped. Claude's available usage-reset count is shown in the labelled Reset grants section when its CLI emits a limit-reset notice; absent counts are explicitly not reported, never assumed to be zero, with a direct link to Claude Settings > Usage. Scheduled reset times stay with their allowance meters, and Source & reading details discloses the provider source and full check time. These grants are separate from paid usage credits and may cover only particular limits. ContextDaddy only reads the notices and never invokes `/limit-reset` or consumes a grant. +- Provider allowance for Codex and Claude is fetched on **Check both allowances**, or on opening Usage after the user enables the opt-in automatic switch (at most once per 15 minutes). It is never added to local token history. Codex's reported credit balance appears in credit units, separately from full-reset grants; unlimited, unavailable, and unreported balances remain explicit. Codex reset-credit expiry is shown only when reported; detail rows may be capped. Claude's full and 5-hour reset grants are read from its usage endpoint with the existing default Claude Code sign-in and the installed CLI version. Each grant keeps its expiry and paused/unusable state. Confirmed empty grants show None; failed, ineligible or unsupported responses remain explicitly unavailable, with CLI notice counts and a Claude Settings > Usage link as fallbacks. Scheduled reset times stay with their allowance meters, and Source & reading details discloses the provider source and full check time. These grants are separate from paid usage credits and may cover only particular limits. ContextDaddy makes only a GET usage request for reset details. It never invokes `/limit-reset`, redeems a grant, refreshes/writes credentials, follows redirects, or stores credential/response bodies. Locked or unavailable Keychain access and custom Claude configuration homes remain explicit unavailable states. - Skills and OTEL review panels can **Copy all issues** into an agent-ready brief with IDs, evidence, source scope, and verification limits. After skill edits, **Verify after changes** rescans and distinguishes detector-cleared, still-detected, and unverified findings. The library supports previewed local skill edits with recovery; OTEL signals need a new comparable observation window. - Files & diagnostics configuration findings have the same copy-all and rescan handoff without copying configuration values or modifying files. This file audit detects the two misplaced `otel.*` keys but cannot detect launch-time `session-flags.token_budget`; that warning must be traced to the launcher supplying the flag. - Cursor remains inventory-only for usage until a verified source exists. @@ -60,7 +60,7 @@ swift run ContextDaddy The app requires macOS 14 or newer. Its Codex adapter reads the loopback-only local telemetry stack and renders Prometheus metrics and Tempo sessions inside ContextDaddy. A separate Claude adapter reads Claude Code metrics from the same local Prometheus path only if Claude exports to that collector; ContextDaddy does not enable or reroute Claude telemetry. The rest of the product still works when either source is absent or partial. -ContextDaddy runs [ccusage](https://github.com/ccusage/ccusage) 20.0.24 directly in offline mode for local history. The packaged app carries its own pinned helper and [MIT acknowledgement](CONTEXTDADDY_NOTICES.md); no CodeVetter installation or CLI is needed at runtime. A **Refresh history** action rescans local logs. **Check allowance** separately calls Codex app-server and Claude Code `/usage` through their installed CLIs; opt-in automatic checking uses the same adapters with a 15-minute minimum interval. These readings are not ccusage totals. +ContextDaddy runs [ccusage](https://github.com/ccusage/ccusage) 20.0.24 directly in offline mode for local history. The packaged app carries its own pinned helper and [MIT acknowledgement](CONTEXTDADDY_NOTICES.md); no CodeVetter installation or CLI is needed at runtime. A **Refresh history** action rescans local logs. **Check allowance** separately calls Codex app-server and Claude Code `/usage` through their installed CLIs. Claude's usage endpoint supplies allowance windows and reset grants from one read, while the CLI supplies plan and paid-credit details. If the CLI display fails, those unreported details remain unavailable. Opt-in automatic checking uses the same adapters with a 15-minute minimum interval. These readings are not ccusage totals. The general usage dashboard lives in ContextDaddy's Focus Desk. Devin's distinct indexed history comes from a bounded, read-only scan of the Devin CLI SQLite session index, separate from ccusage. It deduplicates repeated assistant message IDs and reports daily token classes and models for each range. Devin is included by default in Historical usage and can be filtered with the other agent chips. Devin cost remains explicitly unavailable until a provider-verified rate source exists; a missing or unreadable index is never presented as zero usage. @@ -79,7 +79,7 @@ ContextDaddy was extracted from StorageDaddy's MIT-licensed context work, with t ## Privacy boundary -ContextDaddy inspects well-known agent roots and opens at most 64 KiB of each `SKILL.md` to parse frontmatter and compute a one-way SHA-256 fingerprint; it does not retain or display the skill body during discovery. Other eligible document bodies open only after an explicit Preview action, through a guarded regular-file reader capped at 256 KiB, and MCP configuration bodies are never previewed. Configuration health reads only bounded structural fields and ignores credential, header, environment, and MCP argument values. It skips secret-shaped and generated directories during discovery. The explicit skill-history scan reads local transcript records, extracts structured skill tool calls and file-path evidence, and retains only names, agents, session keys, dates, project paths, and evidence classes in memory; prompt, response, and command bodies are discarded. Telemetry is fetched from a loopback-only local stack whose collector removes prompt, response, command, argument, result, account, and host fields before persistence. Offline ccusage history remains in memory for the current app session. ContextDaddy performs no config writes and makes no claim about exact internet bytes without a dedicated sensor. +ContextDaddy inspects well-known agent roots and opens at most 64 KiB of each `SKILL.md` to parse frontmatter and compute a one-way SHA-256 fingerprint; it does not retain or display the skill body during discovery. Other eligible document bodies open only after an explicit Preview action, through a guarded regular-file reader capped at 256 KiB, and MCP configuration bodies are never previewed. Configuration health reads only bounded structural fields and ignores credential, header, environment, and MCP argument values. It skips secret-shaped and generated directories during discovery. The explicit skill-history scan reads local transcript records, extracts structured skill tool calls and file-path evidence, and retains only names, agents, session keys, dates, project paths, and evidence classes in memory; prompt, response, and command bodies are discarded. Telemetry is fetched from a loopback-only local stack whose collector removes prompt, response, command, argument, result, account, and host fields before persistence. Offline ccusage history remains in memory for the current app session. Explicit or opted-in Claude allowance checks read only the default Claude Code Keychain sign-in into memory to fetch reset-grant availability from Anthropic; no credential value is displayed, logged or persisted. ContextDaddy performs no config writes and makes no claim about exact internet bytes without a dedicated sensor. ## Status diff --git a/Sources/ContextCore/ClaudeResetGrantReader.swift b/Sources/ContextCore/ClaudeResetGrantReader.swift new file mode 100644 index 0000000..9f40ce5 --- /dev/null +++ b/Sources/ContextCore/ClaudeResetGrantReader.swift @@ -0,0 +1,238 @@ +import Foundation +import Darwin + +public struct ClaudeResetGrant: Decodable, Sendable, Equatable { + public let kind: String + public let count: UInt64 + public let expiresAtUnix: Int64 + public let paused: Bool + public let usableNow: Bool +} + +public struct ClaudeResetGrantSummary: Decodable, Sendable, Equatable { + public let fullCount: UInt64 + public let fiveHourCount: UInt64 + public let grants: [ClaudeResetGrant] + public let checkedAt: String +} + +enum ClaudeResetGrantError: Error, LocalizedError { + case credentialUnavailable, cliVersionUnavailable, unsupportedResponse, requestFailed + + var errorDescription: String? { + switch self { + case .credentialUnavailable: "Reset details unavailable. Open Claude Code to check its sign-in." + case .cliVersionUnavailable: "Reset details unavailable. Update Claude Code and check again." + case .unsupportedResponse: "Claude did not provide supported reset-grant details." + case .requestFailed: "Claude reset-grant check failed. Try checking again." + } + } +} + +/// Reads the same usage endpoint as Claude Code. Never refreshes a credential, +/// follows a redirect, persists a response, or calls a reset/redemption endpoint. +struct ClaudeResetGrantReader: Sendable { + func load(cliVersion: String?) async throws -> ProviderQuotaStatus { + guard let cliVersion else { throw ClaudeResetGrantError.cliVersionUnavailable } + let request = try Self.request(credential: Self.credential(), cliVersion: cliVersion) + let config = URLSessionConfiguration.ephemeral + config.urlCache = nil + config.httpCookieStorage = nil + config.urlCredentialStorage = nil + config.timeoutIntervalForRequest = 5 + config.timeoutIntervalForResource = 8 + let session = URLSession(configuration: config, delegate: NoRedirect(), delegateQueue: nil) + defer { session.invalidateAndCancel() } + do { + let (bytes, response) = try await session.bytes(for: request) + guard let response = response as? HTTPURLResponse, response.statusCode == 200, + response.expectedContentLength <= 256 * 1024 else { + throw ClaudeResetGrantError.requestFailed + } + var data = Data() + for try await byte in bytes { + guard data.count < 256 * 1024 else { throw ClaudeResetGrantError.requestFailed } + data.append(byte) + } + return try Self.quotaStatus(data) + } catch let error as ClaudeResetGrantError { throw error } + catch { throw ClaudeResetGrantError.requestFailed } + } + + private static func credential() throws -> Data { + // Custom Claude homes have a different account/service. Do not silently + // substitute the default login for a custom CLI configuration. + guard ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"] == nil else { + throw ClaudeResetGrantError.credentialUnavailable + } + // Legacy Keychain calls can block in securityd even when UI is denied. + // Isolate the read in Apple's helper so the app can enforce a deadline. + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/security") + process.arguments = ["find-generic-password", "-s", "Claude Code-credentials", "-w"] + return try readCredential(using: process) + } + + static func readCredential(using process: Process, timeout: TimeInterval = 2) throws -> Data { + process.standardInput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + let pipe = Pipe() + process.standardOutput = pipe + let fd = pipe.fileHandleForReading.fileDescriptor + _ = fcntl(fd, F_SETFL, O_NONBLOCK) + do { try process.run() } catch { throw ClaudeResetGrantError.credentialUnavailable } + defer { + if process.isRunning { _ = kill(process.processIdentifier, SIGKILL) } + try? pipe.fileHandleForReading.close() + try? pipe.fileHandleForWriting.close() + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let count = read(fd, &buffer, buffer.count) + if count > 0 { + guard data.count + count <= 64 * 1024 else { throw ClaudeResetGrantError.credentialUnavailable } + data.append(contentsOf: buffer.prefix(count)) + } else if !process.isRunning { + guard process.terminationStatus == 0, !data.isEmpty else { throw ClaudeResetGrantError.credentialUnavailable } + return data + } else { + Thread.sleep(forTimeInterval: 0.01) + } + } + throw ClaudeResetGrantError.credentialUnavailable + } + + static func request(credential: Data, cliVersion: String, now: Date = Date()) throws -> URLRequest { + struct Credential: Decodable { + struct OAuth: Decodable { let accessToken: String; let expiresAt: Double } + let claudeAiOauth: OAuth + } + guard cliVersion.range(of: #"^[0-9]+\.[0-9]+\.[0-9]+$"#, options: .regularExpression) != nil, + credential.count <= 64 * 1024, + let oauth = try? JSONDecoder().decode(Credential.self, from: credential).claudeAiOauth, + oauth.expiresAt.isFinite, oauth.expiresAt > now.timeIntervalSince1970 * 1000, + !oauth.accessToken.isEmpty, oauth.accessToken.utf8.count <= 16 * 1024, + oauth.accessToken.unicodeScalars.allSatisfy({ (33...126).contains($0.value) }) else { + throw ClaudeResetGrantError.credentialUnavailable + } + var request = URLRequest(url: URL(string: "https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1")!, + cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 5) + request.httpMethod = "GET" + request.setValue("Bearer \(oauth.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") + // The grant block is surface/version gated. Use the installed CLI's + // actual version and header format, not a hardcoded supported version. + request.setValue("claude-cli/\(cliVersion) (external, cli)", forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + return request + } + + static func parse(_ data: Data, now: Date = Date()) throws -> ClaudeResetGrantSummary { + struct Response: Decodable { + struct Block: Decodable { + struct Grant: Decodable { + let resets_left: UInt64 + let ends_at: String + let starts_at: String? + let clears: [String] + let paused: Bool + let usable_now: Bool + } + let eligible: Bool + let grants: [Grant] + } + let cedar_ember: Block + } + guard data.count <= 256 * 1024, + let block = try? JSONDecoder().decode(Response.self, from: data).cedar_ember, + block.eligible, block.grants.count <= 128 else { + throw ClaudeResetGrantError.unsupportedResponse + } + var grants: [ClaudeResetGrant] = [] + var full: UInt64 = 0 + var fiveHour: UInt64 = 0 + for grant in block.grants { + guard let end = isoDate(grant.ends_at) else { throw ClaudeResetGrantError.unsupportedResponse } + let start = grant.starts_at.flatMap(isoDate) + guard grant.starts_at == nil || start != nil else { throw ClaudeResetGrantError.unsupportedResponse } + let kind: String + let clears = Set(grant.clears) + if clears.contains("five_hour"), clears.contains("seven_day") { kind = "full" } + else if clears == ["five_hour"] { kind = "five-hour" } + else { throw ClaudeResetGrantError.unsupportedResponse } + guard end > now, grant.resets_left > 0 else { continue } + let sum = (kind == "full" ? full : fiveHour).addingReportingOverflow(grant.resets_left) + guard !sum.overflow else { throw ClaudeResetGrantError.unsupportedResponse } + if kind == "full" { full = sum.partialValue } else { fiveHour = sum.partialValue } + grants.append(ClaudeResetGrant(kind: kind, count: grant.resets_left, + expiresAtUnix: Int64(end.timeIntervalSince1970), paused: grant.paused, + usableNow: grant.usable_now && (start == nil || start! <= now))) + } + guard !full.addingReportingOverflow(fiveHour).overflow else { throw ClaudeResetGrantError.unsupportedResponse } + grants.sort { ($0.kind, $0.expiresAtUnix) < ($1.kind, $1.expiresAtUnix) } + return ClaudeResetGrantSummary(fullCount: full, fiveHourCount: fiveHour, grants: grants, + checkedAt: ISO8601DateFormatter().string(from: now)) + } + + /// The same response can keep the grant reading available if the CLI's + /// interactive display fails. Unreported plan/paid-credit data stays nil. + static func quotaStatus(_ data: Data, now: Date = Date()) throws -> ProviderQuotaStatus { + struct Windows: Decodable { + struct Window: Decodable { let utilization: Double; let resets_at: String? } + let five_hour: Window + let seven_day: Window + } + let summary = try parse(data, now: now) + guard let reading = try? JSONDecoder().decode(Windows.self, from: data) else { + throw ClaudeResetGrantError.unsupportedResponse + } + var windows: [ProviderQuotaWindow] = [] + for (value, id, label) in [(reading.five_hour, "current", "Current window"), + (reading.seven_day, "weekly", "Weekly window")] { + let percent = value.utilization + guard percent.isFinite, (0...100).contains(percent) else { + throw ClaudeResetGrantError.unsupportedResponse + } + let reset = value.resets_at.flatMap(isoDate) + guard value.resets_at == nil || reset != nil else { throw ClaudeResetGrantError.unsupportedResponse } + windows.append(ProviderQuotaWindow(id: id, label: label, usedPercent: percent, remainingPercent: 100 - percent, + windowDurationMinutes: nil, resetsAtUnix: reset.map { Int64($0.timeIntervalSince1970) }, + resetDescription: nil)) + } + return ProviderQuotaStatus(provider: "claude", status: "ready", source: "Claude OAuth usage (CLI display unavailable)", + checkedAt: summary.checkedAt, plan: nil, windows: windows, credits: nil, + resetCredits: nil, latestReportedResetCreditExpiryUnix: nil, + resetCreditDetailsCount: nil, resetCreditsWithoutExpiryCount: nil, + claudeResetGrants: summary, + message: "CLI plan and paid-credit details were unavailable. Windows and reset grants were read from Claude's usage endpoint.") + } + + static func mergingCLIDetails(_ cli: ProviderQuotaStatus?, into remote: ProviderQuotaStatus) -> ProviderQuotaStatus { + guard let cli else { return remote } + // The terminal can show placeholder percentages before its request + // completes. Keep API windows and grants from one authoritative read. + return ProviderQuotaStatus(provider: "claude", status: "ready", + source: "Claude OAuth usage · windows and resets; Claude Code /usage · plan and paid credits", + checkedAt: remote.checkedAt, plan: cli.plan, windows: remote.windows, credits: cli.credits, + resetCredits: remote.claudeResetGrants.map { $0.fullCount + $0.fiveHourCount }, + latestReportedResetCreditExpiryUnix: nil, resetCreditDetailsCount: nil, + resetCreditsWithoutExpiryCount: nil, claudeResetGrants: remote.claudeResetGrants, message: nil) + } + + private static func isoDate(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: value) { return date } + formatter.formatOptions.insert(.withFractionalSeconds) + return formatter.date(from: value) + } + + private final class NoRedirect: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(nil) + } + } +} diff --git a/Sources/ContextCore/LocalUsage.swift b/Sources/ContextCore/LocalUsage.swift index 29630fb..04ec31c 100644 --- a/Sources/ContextCore/LocalUsage.swift +++ b/Sources/ContextCore/LocalUsage.swift @@ -383,6 +383,8 @@ public struct ProviderQuotaStatus: Decodable, Sendable { public let latestReportedResetCreditExpiryUnix: Int64? public let resetCreditDetailsCount: UInt64? public let resetCreditsWithoutExpiryCount: UInt64? + public var claudeResetGrants: ClaudeResetGrantSummary? = nil + public var resetGrantError: String? = nil public let message: String? enum CodingKeys: String, CodingKey { @@ -392,6 +394,8 @@ public struct ProviderQuotaStatus: Decodable, Sendable { case latestReportedResetCreditExpiryUnix = "latest_reported_reset_credit_expiry_unix" case resetCreditDetailsCount = "reset_credit_details_count" case resetCreditsWithoutExpiryCount = "reset_credits_without_expiry_count" + case claudeResetGrants = "claude_reset_grants" + case resetGrantError = "reset_grant_error" } } diff --git a/Sources/ContextCore/ProviderQuotaClient.swift b/Sources/ContextCore/ProviderQuotaClient.swift index 5e2fa2a..c4120c4 100644 --- a/Sources/ContextCore/ProviderQuotaClient.swift +++ b/Sources/ContextCore/ProviderQuotaClient.swift @@ -27,17 +27,62 @@ public struct ProviderQuotaClient: Sendable { public func loadQuota(for service: UsageService) async throws -> ProviderQuotaReceipt { guard let key = service.quotaKey else { throw ProviderQuotaError.unsupportedProvider } - let status = try await Task.detached(priority: .userInitiated) { + let reading = try await Task.detached(priority: .userInitiated) { () throws -> (status: ProviderQuotaStatus?, version: String?) in switch key { - case "codex": try collectCodex() - case "claude": try collectClaude() + case "codex": return (try collectCodex(), nil) + case "claude": + do { return try collectClaude() } + catch { + guard let url = resolve("claude", explicit: claudeURL) else { throw ProviderQuotaError.missingCLI("Claude") } + return (nil, claudeVersion(at: url)) + } default: throw ProviderQuotaError.unsupportedProvider } }.value + var status = reading.status + if key == "claude" { + do { + let remote = try await ClaudeResetGrantReader().load(cliVersion: reading.version) + status = ClaudeResetGrantReader.mergingCLIDetails(status, into: remote) + } catch { + guard status != nil else { throw ProviderQuotaError.requestFailed("Claude") } + status?.resetGrantError = (error as? ClaudeResetGrantError)?.errorDescription ?? "Claude reset-grant check failed." + } + } + guard let status else { throw ProviderQuotaError.requestFailed("Claude") } return ProviderQuotaReceipt(schemaVersion: "contextdaddy.provider-quota/v1", generatedAt: ISO8601DateFormatter().string(from: Date()), providers: [status]) } + private func claudeVersion(at url: URL) -> String? { + let filename = url.resolvingSymlinksInPath().lastPathComponent + if filename.range(of: #"^[0-9]+\.[0-9]+\.[0-9]+$"#, options: .regularExpression) != nil { return filename } + let process = Process() + process.executableURL = url + process.arguments = ["--version"] + process.standardInput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + let pipe = Pipe() + process.standardOutput = pipe + let fd = pipe.fileHandleForReading.fileDescriptor + _ = fcntl(fd, F_SETFL, O_NONBLOCK) + do { try process.run() } catch { return nil } + defer { stop(process) } + var data = Data() + let deadline = Date().addingTimeInterval(2) + while Date() < deadline, data.count < 4096 { + _ = readAvailable(fd, into: &data) + if !process.isRunning { break } + Thread.sleep(forTimeInterval: 0.02) + } + _ = readAvailable(fd, into: &data) + guard data.count < 4096 else { return nil } + let output = String(decoding: data, as: UTF8.self) + guard output.contains("Claude Code"), + let range = output.range(of: #"[0-9]+\.[0-9]+\.[0-9]+"#, options: .regularExpression) else { return nil } + return String(output[range]) + } + private func collectCodex() throws -> ProviderQuotaStatus { guard let url = resolve("codex", explicit: codexURL) else { throw ProviderQuotaError.missingCLI("Codex") } let process = Process() @@ -85,7 +130,7 @@ public struct ProviderQuotaClient: Sendable { throw ProviderQuotaError.requestFailed("Codex") } - private func collectClaude() throws -> ProviderQuotaStatus { + private func collectClaude() throws -> (status: ProviderQuotaStatus, version: String?) { guard let url = resolve("claude", explicit: claudeURL) else { throw ProviderQuotaError.missingCLI("Claude") } var master: Int32 = -1 var slave: Int32 = -1 @@ -96,9 +141,15 @@ public struct ProviderQuotaClient: Sendable { defer { if master >= 0 { close(master) }; if slave >= 0 { close(slave) } } _ = fcntl(master, F_SETFL, O_NONBLOCK) let process = Process() - process.executableURL = url - process.arguments = ["--safe-mode", "--ax-screen-reader"] + // A GUI Process has no controlling terminal. macOS script establishes + // one for the CLI; /dev/null prevents a terminal transcript on disk. + process.executableURL = URL(fileURLWithPath: "/usr/bin/script") + process.arguments = ["-q", "/dev/null", url.path, "--safe-mode", "--ax-screen-reader"] process.currentDirectoryURL = FileManager.default.temporaryDirectory + var environment = ProcessInfo.processInfo.environment + environment["TERM"] = "dumb" + environment.removeValue(forKey: "COLORTERM") + process.environment = environment let terminal = FileHandle(fileDescriptor: slave, closeOnDealloc: false) process.standardInput = terminal process.standardOutput = terminal @@ -106,14 +157,24 @@ public struct ProviderQuotaClient: Sendable { do { try process.run() } catch { throw ProviderQuotaError.requestFailed("Claude") } close(slave) slave = -1 - defer { stop(process) } + defer { + // Ask the interactive CLI to quit before closing script's terminal. + // Killing only script can otherwise leave its CLI child alive. + let quit = Array("\u{3}\u{3}".utf8) + _ = write(master, quit, quit.count) + let deadline = Date().addingTimeInterval(0.5) + while process.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.02) } + close(master) + master = -1 + stop(process) + } var bytes = Data() let startup = Date().addingTimeInterval(5) var lastOutput = Date() + // The version banner can precede the interactive command handler. A + // quiet gap after it is not proof that /usage can accept input yet. while Date() < startup { if readAvailable(master, into: &bytes) { lastOutput = Date() } - let display = ProviderQuotaParser.cleanTerminal(String(decoding: bytes, as: UTF8.self)) - if display.contains("Claude Code v"), Date().timeIntervalSince(lastOutput) >= 0.4 { break } if !process.isRunning { break } Thread.sleep(forTimeInterval: 0.05) } @@ -127,7 +188,7 @@ public struct ProviderQuotaClient: Sendable { let display = ProviderQuotaParser.cleanTerminal(String(decoding: bytes, as: UTF8.self)) if let status = try? ProviderQuotaParser.claude(display), Date().timeIntervalSince(lastOutput) >= 0.9 { - return status + return (status, ProviderQuotaParser.claudeVersion(display)) } if bytes.count >= 256 * 1024 || !process.isRunning { break } Thread.sleep(forTimeInterval: 0.05) @@ -171,6 +232,11 @@ public struct ProviderQuotaClient: Sendable { } enum ProviderQuotaParser { + static func claudeVersion(_ display: String) -> String? { + guard let range = display.range(of: #"Claude Code v[0-9]+\.[0-9]+\.[0-9]+"#, options: .regularExpression) else { return nil } + return String(display[range]).replacingOccurrences(of: "Claude Code v", with: "") + } + static func codex(_ response: [String: Any]) throws -> ProviderQuotaStatus { guard response["error"] == nil, let result = response["result"] as? [String: Any] else { throw ProviderQuotaError.requestFailed("Codex") diff --git a/Sources/ContextDaddy/UsageSourcePanels.swift b/Sources/ContextDaddy/UsageSourcePanels.swift index e22f7bb..19a1cd5 100644 --- a/Sources/ContextDaddy/UsageSourcePanels.swift +++ b/Sources/ContextDaddy/UsageSourcePanels.swift @@ -33,7 +33,7 @@ struct UsageAllowanceView: View { .onChange(of: model.autoCheckAllowance) { _, enabled in if enabled { Task { await model.autoRefreshQuotasIfNeeded() } } } - Text("Account-level allowance is separate from local tokens. Checks may contact Codex and Claude; automatic checks are opt-in and run at most once every 15 minutes.") + Text("Account-level allowance is separate from local tokens. Checks may contact Codex and Claude; Claude reset details use its existing sign-in, read-only. Automatic checks are opt-in and run at most once every 15 minutes.") .font(.caption2).foregroundStyle(DaddyTheme.muted) }.frame(maxWidth: .infinity, alignment: .leading) .task { await model.autoRefreshQuotasIfNeeded() } @@ -88,14 +88,22 @@ struct UsageAllowanceView: View { VStack(alignment: .leading, spacing: 6) { Text("RESET GRANTS") .font(.caption2.weight(.bold)).foregroundStyle(DaddyTheme.muted) - Text(Self.resetCountText(status)) - .font(.callout.weight(.semibold)).monospacedDigit() - .fixedSize(horizontal: false, vertical: true) - if provider == "claude", status.resetCredits == nil { + if let grants = status.claudeResetGrants, provider == "claude" { + claudeGrantRows(grants) + } else { + Text(Self.resetCountText(status)) + .font(.callout.weight(.semibold)).monospacedDigit() + .fixedSize(horizontal: false, vertical: true) + } + if provider == "claude", status.claudeResetGrants == nil, status.resetCredits == nil { Text("Claude Code did not report a count.") .font(.caption2).foregroundStyle(DaddyTheme.muted) claudeUsageLink } + if let error = status.resetGrantError { + Text(error).font(.caption2).foregroundStyle(DaddyTheme.amber) + .fixedSize(horizontal: false, vertical: true) + } if provider == "codex", status.resetCredits != nil { resetExpiry(status) } @@ -118,6 +126,11 @@ struct UsageAllowanceView: View { VStack(alignment: .leading, spacing: 5) { Text(status.source) Text("Checked · \(status.checkedAt)") + if let message = status.message { Text(message) } + if let grants = status.claudeResetGrants { + Text("Claude OAuth usage · reset grants") + Text("Reset details checked · \(grants.checkedAt)") + } if provider == "codex", let nonExpiring = status.resetCreditsWithoutExpiryCount, nonExpiring > 0, status.latestReportedResetCreditExpiryUnix != nil { @@ -231,6 +244,30 @@ struct UsageAllowanceView: View { return "\(count) \(status.provider == "claude" ? "usage" : "full") \(count == 1 ? "reset" : "resets") available" } + static func claudeGrantCountText(_ count: UInt64, kind: String) -> String { + "\(kind == "full" ? "Full resets" : "5-hour resets") · \(count == 0 ? "None" : "\(count) left")" + } + + @ViewBuilder private func claudeGrantRows(_ summary: ClaudeResetGrantSummary) -> some View { + ForEach(["full", "five-hour"], id: \.self) { kind in + VStack(alignment: .leading, spacing: 4) { + Text(Self.claudeGrantCountText(kind == "full" ? summary.fullCount : summary.fiveHourCount, kind: kind)) + .font(.callout.weight(.semibold)).monospacedDigit() + .fixedSize(horizontal: false, vertical: true) + ForEach(summary.grants.indices.filter { summary.grants[$0].kind == kind }, id: \.self) { index in + let grant = summary.grants[index] + Text("\(grant.count == 1 ? "Expires" : "\(grant.count) expire") · \(Date(timeIntervalSince1970: TimeInterval(grant.expiresAtUnix)).formatted(date: .abbreviated, time: .omitted))") + .font(.caption2).foregroundStyle(DaddyTheme.muted) + .fixedSize(horizontal: false, vertical: true) + if grant.paused || !grant.usableNow { + Text(grant.paused ? "Paused" : "Not usable right now") + .font(.caption2).foregroundStyle(DaddyTheme.amber) + } + } + } + } + } + static func windowResetText(_ window: ProviderQuotaWindow) -> String? { if let description = window.resetDescription { return "Resets \(description)" } guard let unix = window.resetsAtUnix else { return nil } diff --git a/Tests/ContextCoreTests/ClaudeResetGrantReaderTests.swift b/Tests/ContextCoreTests/ClaudeResetGrantReaderTests.swift new file mode 100644 index 0000000..cb83660 --- /dev/null +++ b/Tests/ContextCoreTests/ClaudeResetGrantReaderTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import ContextCore + +struct ClaudeResetGrantReaderTests { + private let now = Date(timeIntervalSince1970: 1_790_000_000) + + private func grant(_ count: String = "1", clears: String = #"["five_hour","seven_day","seven_day_overage_included"]"#, + end: String = "2030-10-22T16:00:00+00:00", paused: Bool = false, usable: Bool = true) -> String { + #"{"id":"private-grant-id","resets_left":\#(count),"ends_at":"\#(end)","clears":\#(clears),"paused":\#(paused),"usable_now":\#(usable)}"# + } + + private func parse(_ grants: [String], eligible: Bool = true) throws -> ClaudeResetGrantSummary { + let json = #"{"account_id":"private-account","cedar_ember":{"eligible":\#(eligible),"grants":[\#(grants.joined(separator: ","))]}}"# + return try ClaudeResetGrantReader.parse(Data(json.utf8), now: now) + } + + @Test func scopesAndExpiryAreProjectedWithoutIdentityOrDoubleCounting() throws { + let summary = try parse([grant("2"), grant("1", clears: #"["five_hour"]"#, end: "2030-11-01T16:00:00.000Z")]) + #expect(summary.fullCount == 2) + #expect(summary.fiveHourCount == 1) + #expect(summary.grants.count == 2) + #expect(summary.grants.first(where: { $0.kind == "full" })?.expiresAtUnix == 1_918_915_200) + #expect(!String(describing: summary).contains("private-grant-id")) + #expect(!String(describing: summary).contains("private-account")) + } + + @Test func confirmedEmptyAndDepletedAreZeroButIneligibleIsUnknown() throws { + #expect(try parse([]).fullCount == 0) + #expect(try parse([]).fiveHourCount == 0) + #expect(try parse([grant("0")]).grants.isEmpty) + #expect(throws: ClaudeResetGrantError.self) { try parse([], eligible: false) } + for json in ["{}", #"{"cedar_ember":null}"#, #"{"cedar_ember":{"eligible":true}}"#, + #"{"cedar_ember":{"eligible":true,"grants":[{}]}}"#] { + #expect(throws: ClaudeResetGrantError.self) { try ClaudeResetGrantReader.parse(Data(json.utf8), now: now) } + } + } + + @Test func expiredPausedAndUnusableGrantsRemainDistinct() throws { + let summary = try parse([grant("5", end: "2020-01-01T00:00:00Z"), grant(paused: true, usable: false), + grant("2", clears: #"["five_hour"]"#, usable: false)]) + #expect(summary.fullCount == 1) + #expect(summary.fiveHourCount == 2) + #expect(summary.grants.count == 2) + #expect(summary.grants.first(where: { $0.kind == "full" })?.paused == true) + #expect(summary.grants.allSatisfy { !$0.usableNow }) + } + + @Test func malformedUnknownAndOverflowedGrantsCannotLookEmpty() throws { + for row in [grant("-1"), grant("1.5"), grant("18446744073709551616"), grant(end: "not-a-date"), + grant(clears: #"["seven_day"]"#), grant(clears: #"["new_unknown_scope"]"#)] { + #expect(throws: ClaudeResetGrantError.self) { try parse([row]) } + } + #expect(throws: ClaudeResetGrantError.self) { try parse([grant("18446744073709551615"), grant()]) } + #expect(throws: ClaudeResetGrantError.self) { try parse([grant("18446744073709551615"), grant(clears: #"["five_hour"]"#)]) } + #expect(throws: ClaudeResetGrantError.self) { try parse(Array(repeating: grant(), count: 129)) } + } + + @Test func onlyPinnedReadEndpointReceivesValidUnexpiredOAuthCredential() throws { + let data = Data(#"{"claudeAiOauth":{"accessToken":"fixture-token","expiresAt":2000000000000}}"#.utf8) + let request = try ClaudeResetGrantReader.request(credential: data, cliVersion: "2.1.288", now: now) + #expect(request.httpMethod == "GET") + #expect(request.url?.absoluteString == "https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1") + #expect(request.httpBody == nil) + #expect(request.value(forHTTPHeaderField: "User-Agent") == "claude-cli/2.1.288 (external, cli)") + #expect(request.value(forHTTPHeaderField: "anthropic-beta") == "oauth-2025-04-20") + #expect(request.timeoutInterval == 5) + #expect(ProviderQuotaParser.claudeVersion("Claude Code v2.1.288\nCurrent session") == "2.1.288") + #expect(ProviderQuotaParser.claudeVersion("Current session\n3% used") == nil) + #expect(throws: ClaudeResetGrantError.self) { + try ClaudeResetGrantReader.request(credential: data, cliVersion: "2.1.288\r\nInjected: header", now: now) + } + for credential in [#"{"claudeAiOauth":{"accessToken":"fixture-token","expiresAt":1}}"#, + #"{"claudeAiOauth":{"accessToken":"token\nInjected","expiresAt":2000000000000}}"#, + #"{"claudeAiOauth":{"refreshToken":"never-used"}}"#, "not-json"] { + #expect(throws: ClaudeResetGrantError.self) { + try ClaudeResetGrantReader.request(credential: Data(credential.utf8), cliVersion: "2.1.288", now: now) + } + } + } + + @Test func credentialHelperIsBoundedAndRejectsEmptyReads() throws { + let stalled = Process() + stalled.executableURL = URL(fileURLWithPath: "/bin/sleep") + stalled.arguments = ["30"] + let start = Date() + #expect(throws: ClaudeResetGrantError.self) { + try ClaudeResetGrantReader.readCredential(using: stalled, timeout: 0.1) + } + #expect(Date().timeIntervalSince(start) < 2) + let empty = Process() + empty.executableURL = URL(fileURLWithPath: "/usr/bin/true") + #expect(throws: ClaudeResetGrantError.self) { try ClaudeResetGrantReader.readCredential(using: empty) } + } + + @Test func sameReadCanPreserveWindowsAndGrantsWithoutInventingCLICreditsOrPlan() throws { + let json = #"{"five_hour":{"utilization":20,"resets_at":"2030-10-22T16:00:00.123456+00:00"},"seven_day":{"utilization":60,"resets_at":null},"cedar_ember":{"eligible":true,"grants":[\#(grant())]}}"# + let status = try ClaudeResetGrantReader.quotaStatus(Data(json.utf8), now: now) + #expect(status.windows.map(\.remainingPercent) == [80, 40]) + #expect(status.windows.first?.resetsAtUnix == 1_918_915_200) + #expect(status.windows.last?.resetsAtUnix == nil) + #expect(status.claudeResetGrants?.fullCount == 1) + #expect(status.credits == nil) + #expect(status.plan == nil) + #expect(status.source.contains("CLI display unavailable")) + let cli = try ProviderQuotaParser.claude("Current session\n0% used\nResets soon\nCurrent week (all models)\n0% used\nResets later\nUsage credits\n0% used\n$0.00 / $150.00 spent") + let merged = ClaudeResetGrantReader.mergingCLIDetails(cli, into: status) + #expect(merged.windows.map(\.remainingPercent) == [80, 40]) + #expect(merged.credits?.limitAmount == 150) + #expect(merged.resetCredits == 1) + for invalid in [json.replacingOccurrences(of: "\"utilization\":20", with: "\"utilization\":-1"), + json.replacingOccurrences(of: "\"utilization\":20", with: "\"utilization\":true"), + json.replacingOccurrences(of: "\"utilization\":60", with: "\"utilization\":101"), + json.replacingOccurrences(of: "2030-10-22T16:00:00.123456+00:00", with: "bad-date")] { + #expect(throws: ClaudeResetGrantError.self) { try ClaudeResetGrantReader.quotaStatus(Data(invalid.utf8), now: now) } + } + } +} diff --git a/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift b/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift index ae24b25..fa90b9f 100644 --- a/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift +++ b/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift @@ -38,6 +38,40 @@ struct UsageAllowanceViewTests { let window = try JSONDecoder().decode(ProviderQuotaWindow.self, from: Data(#"{"id":"current","label":"Current window","remaining_percent":80,"reset_description":"8:30pm (Asia/Calcutta)"}"#.utf8)) #expect(UsageAllowanceView.windowResetText(window) == "Resets 8:30pm (Asia/Calcutta)") + #expect(UsageAllowanceView.claudeGrantCountText(1, kind: "full") == "Full resets · 1 left") + #expect(UsageAllowanceView.claudeGrantCountText(0, kind: "five-hour") == "5-hour resets · None") + #expect(UsageAllowanceView.claudeGrantCountText(2, kind: "five-hour") == "5-hour resets · 2 left") + } + + @Test func rendersScopedClaudeResetGrantsAndUnavailableDetails() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let directory = root.appendingPathComponent("artifacts/design/claude-grants/after", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let model = ContextDaddyModel(discover: { _ in throw CancellationError() }) + let originalAutoCheck = model.autoCheckAllowance + defer { model.autoCheckAllowance = originalAutoCheck } + model.autoCheckAllowance = false + model.show(.overview) + let fixture = #"{"schema_version":"contextdaddy.provider-quota/v1","generated_at":"2026-10-03T12:00:00Z","providers":[{"provider":"codex","status":"ready","source":"codex app-server account/rateLimits/read","checked_at":"2026-10-03T12:00:00Z","plan":"pro","windows":[{"id":"weekly","label":"Weekly window","remaining_percent":70,"reset_description":"Oct 10 at 2:43am"}],"credits":{"balance":12345.67},"reset_credits":2},{"provider":"claude","status":"ready","source":"Claude Code /usage","checked_at":"2026-10-03T12:00:00Z","plan":"Claude Team","windows":[{"id":"current","label":"Current window","remaining_percent":80,"reset_description":"8:30pm (Asia/Calcutta)"},{"id":"weekly","label":"Weekly window","remaining_percent":60,"reset_description":"Oct 4 at 5:30pm (Asia/Calcutta)"}],"credits":{"used_amount":25,"limit_amount":150},"claude_reset_grants":{"fullCount":1,"fiveHourCount":0,"checkedAt":"2026-10-03T12:00:01Z","grants":[{"kind":"full","count":1,"expiresAtUnix":1918915200,"paused":false,"usableNow":true}]}}]}"# + let receipt = try JSONDecoder().decode(ProviderQuotaReceipt.self, from: Data(fixture.utf8)) + model.quotaReceipts["codex"] = receipt + model.quotaReceipts["claude"] = receipt + for width in [960, 1180, 1440] { + try render(model, width: width, height: 740, to: directory.appendingPathComponent("grants-\(width).png")) + } + var unavailable = try #require(JSONSerialization.jsonObject(with: Data(fixture.utf8)) as? [String: Any]) + var providers = try #require(unavailable["providers"] as? [[String: Any]]) + providers[1].removeValue(forKey: "claude_reset_grants") + providers[1]["reset_grant_error"] = "Claude reset-grant check failed. Try checking again." + unavailable["providers"] = providers + model.quotaReceipts["claude"] = try JSONDecoder().decode(ProviderQuotaReceipt.self, + from: JSONSerialization.data(withJSONObject: unavailable)) + try render(model, width: 960, height: 740, to: directory.appendingPathComponent("unavailable-960.png")) + let pausedJSON = fixture.replacingOccurrences(of: "\"fiveHourCount\":0", with: "\"fiveHourCount\":2") + .replacingOccurrences(of: "\"grants\":[", with: "\"grants\":[{\"kind\":\"five-hour\",\"count\":2,\"expiresAtUnix\":1928915200,\"paused\":true,\"usableNow\":false},") + model.quotaReceipts["claude"] = try JSONDecoder().decode(ProviderQuotaReceipt.self, from: Data(pausedJSON.utf8)) + try render(model, width: 960, height: 740, to: directory.appendingPathComponent("paused-960.png")) } /// Focused native evidence; no local-history scan or provider call is needed.