diff --git a/CHANGELOG.md b/CHANGELOG.md index 6974f9a..c86a1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ The top entry is the current source version. Binary release metadata appears at `/api/v1/latest` only after a signed and notarized DMG has actually been published. +## Unreleased + +- The Token Widget overlay now meters Codex natively. It reads the active thread + from Codex's local state database and its rollout files, so a frontmost Codex + window gets a bound face without a CDP debugging port, launch flags, or any + quit/relaunch of Codex. The overlay installs and runs on machines without + Claude installed, and the app is now named "Token Widget". +- Deprecated the Codex CDP adapter (`scripts/install-token-meter-macos.sh`). It + still works but will be removed in a future release; prefer the app. + ## 0.3.1 — 2026-08-15 - Fixed the first aggregate sync for installations whose local Codex history diff --git a/INSTALL_WITH_AGENT.md b/INSTALL_WITH_AGENT.md index 6159c62..ad7aacb 100644 --- a/INSTALL_WITH_AGENT.md +++ b/INSTALL_WITH_AGENT.md @@ -62,7 +62,7 @@ Finish only when each selected host is installed and its real runtime status has ./scripts/status-claude-meter-macos.sh --json ``` - - If `accessibilityGranted` is `false`, open System Settings > Privacy & Security > Accessibility and ask the user to enable **Token Widget for Claude**. + - If `accessibilityGranted` is `false`, open System Settings > Privacy & Security > Accessibility and ask the user to enable **Token Widget**. - After approval, wait up to 60 seconds, polling no faster than every five seconds, until `running`, `accessibilityGranted`, and `overlayReady` are all `true`. - Treat `bridgeHealthy` and `sessionBound` as foreground Session checks. They may correctly remain `false` while Claude is hidden or not showing Code. - Do not restart Claude while waiting. diff --git a/README.md b/README.md index 72ec4d0..c19efbd 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ The signed and Apple-notarized DMG is the primary release channel: - [Download Token Widget for macOS](https://www.tokenwidget.app/download/token-widget.dmg) - [Latest GitHub release](https://github.com/SergioChan/token-meter/releases/latest) -Open the DMG, drag **Token Widget.app** to **Applications**, and open it once. The app contains its own compatible Node.js runtime. The Claude Desktop overlay requires macOS Accessibility permission for **Token Widget**; it never asks for permission on behalf of Claude. +Open the DMG, drag **Token Widget.app** to **Applications**, and open it once. The app contains its own compatible Node.js runtime. The overlay requires macOS Accessibility permission for **Token Widget**; it never asks for permission on behalf of Claude or Codex. The same app meters both Claude Code and Codex — it follows the frontmost host window and reads each host's local telemetry. -The Codex Desktop adapter currently uses its per-user source installer described below. The repository also remains the development and recovery installation path for both hosts. +The legacy Codex CDP adapter (the per-user source installer described below) is deprecated in favor of this overlay and will be removed in a future release. The repository also remains the development and recovery installation path for both hosts. > **Multi-device release gate:** this source tree contains the next-release Profile/device model and v2 aggregate sync protocol. Do not enable multi-device Profile reads in production until the additive database migration, reconciliation, and verification gates in [the migration runbook](docs/multi-device-migration.md) have all passed. Older v1 clients remain compatible during the rollout. @@ -220,6 +220,13 @@ Or attach [INSTALL_WITH_AGENT.md](INSTALL_WITH_AGENT.md) to a capable local codi ### Codex Desktop +The recommended way to meter Codex is the **Token Widget** app above — it now +supports Codex natively, reading the active thread from Codex's local state with +no loopback debugging port, no launch flags, and no quit/relaunch of Codex. + +> **Deprecated:** the CDP adapter below is kept for now and will be removed in a +> future release. Prefer the app. + ```bash ./scripts/install-token-meter-macos.sh ``` @@ -244,7 +251,7 @@ Check local build prerequisites and install without quitting Claude: ./scripts/status-claude-meter-macos.sh --json ``` -If the status reports `accessibilityGranted: false`, enable **Token Widget for Claude** in **System Settings → Privacy & Security → Accessibility**. `bridgeHealthy` and `sessionBound` may correctly remain false while Claude is hidden or no Code Session is focused. +If the status reports `accessibilityGranted: false`, enable **Token Widget** in **System Settings → Privacy & Security → Accessibility**. `bridgeHealthy` and `sessionBound` may correctly remain false while Claude is hidden or no Code Session is focused. Use a specific compatible Node binary when required: diff --git a/SECURITY.md b/SECURITY.md index 15dbc41..539f58f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,7 +44,7 @@ The Claude integration is designed to defend against accidental Session confusio - Verifies the canonical Claude.app path, bundle ID, signature, Anthropic Team ID, executable, and packaged model catalog before installation. - Builds a separate background application with its own bundle ID; it does not patch, inject into, replace, or re-sign Claude.app. -- Requires Accessibility permission for `Token Widget for Claude.app` itself. +- Requires Accessibility permission for `Token Widget.app` itself. - Requires exactly one eligible `AXWebArea` in the frontmost Claude focused window; links, extra route components, and multiple candidates fail closed. - Reads Accessibility roles and the exact WebArea URL for identity, then only button titles inside that same web area for an optional strict Context-window ratio. It does not read static text, values, descriptions, or message bodies. - Hides when Claude is not frontmost or when exact Session identity is missing or ambiguous. @@ -57,7 +57,7 @@ Accessibility permission allows the companion to inspect UI elements exposed by Source builds are ad-hoc signed by default. Their code identity can change after rebuilding, which may require renewed Accessibility approval. They also record the selected external Node.js path; if that runtime is later removed, rerun the installer with a compatible runtime. -Deleting the application does not automatically delete its macOS TCC decision. Revoke **Token Widget for Claude** in System Settings, or uninstall with `./scripts/uninstall-claude-meter-macos.sh --purge-state --reset-accessibility`. +Deleting the application does not automatically delete its macOS TCC decision. Revoke **Token Widget** in System Settings, or uninstall with `./scripts/uninstall-claude-meter-macos.sh --purge-state --reset-accessibility`. ### Community identity and browser sessions diff --git a/docs/architecture.md b/docs/architecture.md index d34cca6..3a6868d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -149,7 +149,7 @@ The controller remains alive across later Codex launches and sleep/wake. `RunAtL ## Claude macOS companion lifecycle -The Claude installer builds and signs an independent `Token Widget for Claude.app`, copies the minimum runtime into the user's Application Support directory, and loads a per-user LaunchAgent. Source builds use ad-hoc signing unless a stable identity is supplied. The executable owns a non-activating `NSPanel`; it never executes code inside Claude's renderer. +The Claude installer builds and signs an independent `Token Widget.app`, copies the minimum runtime into the user's Application Support directory, and loads a per-user LaunchAgent. Source builds use ad-hoc signing unless a stable identity is supplied. The executable owns a non-activating `NSPanel`; it never executes code inside Claude's renderer. macOS Accessibility permission is granted to the companion, not to the repository shell and not to Claude.app. The process writes atomic `health.json` state from startup onward. Status validates its PID against the exact executable, checks Accessibility live, and reports UI readiness, bridge health, and exact Session binding independently. A permission-blocked companion remains alive and reports a waiting process without falsely reporting trust. It detects both grant and revocation quietly; it neither repeats the system prompt nor quits or relaunches Claude. diff --git a/docs/claude-code.md b/docs/claude-code.md index e14cca4..f60a16b 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -172,7 +172,7 @@ It does not read static text or conversation content to find the ratio. The mode The installer and runtime enforce these constraints: 1. Verify official Claude.app before installation. -2. Build a separate `Token Widget for Claude.app` with its own bundle identifier. +2. Build a separate `Token Widget.app` with its own bundle identifier. 3. Require Accessibility permission for that companion application itself. 4. Inspect only roles and URLs during the shallow focused-window identity scan; read only exact Context-window button titles inside the selected web area for optional numerical enrichment. 5. Keep local transcript and cloud event-cache parsing content-discarding. diff --git a/docs/install-claude-desktop.md b/docs/install-claude-desktop.md index 5545f90..31b44b1 100644 --- a/docs/install-claude-desktop.md +++ b/docs/install-claude-desktop.md @@ -10,7 +10,7 @@ The signed and Apple-notarized DMG is the normal installation path. Source build - macOS 13 or newer. - The official signed Claude Desktop application at `/Applications/Claude.app`. -- Permission to enable **Token Widget for Claude** in System Settings > Privacy & Security > Accessibility. +- Permission to enable **Token Widget** in System Settings > Privacy & Security > Accessibility. The DMG contains its own compatible Node.js runtime. Building from source additionally requires Git, Node.js 22.12 or newer, and Xcode Command Line Tools with Swift. @@ -48,7 +48,7 @@ Or select Node.js explicitly: The installer performs these operations: 1. Verifies the canonical Claude.app path, bundle identifier, Anthropic Team ID, code signature, executable, and packaged model catalog. -2. Builds and signs `Token Widget for Claude.app` as a separate background application. Source builds use ad-hoc signing unless a stable identity is supplied. +2. Builds and signs `Token Widget.app` as a separate background application. Source builds use ad-hoc signing unless a stable identity is supplied. 3. Copies the numerical collector, shared metrics core, and shared meter runtime into an isolated install root. 4. Writes and loads `com.sergiochan.token-meter.claude-desktop` as a per-user LaunchAgent. 5. Requests Accessibility permission for the companion application itself. @@ -57,7 +57,7 @@ To build only the local app bundle for inspection: ```bash ./integrations/claude-desktop/scripts/build-app.sh \ - --output "$PWD/local-artifacts/Token Widget for Claude.app" + --output "$PWD/local-artifacts/Token Widget.app" ``` That bundle still expects the repository runtime and a compatible local Node.js path. Run the installer for the complete runtime copy and LaunchAgent configuration. @@ -77,13 +77,13 @@ If installation reports `Accessibility permission: required`: 1. Open System Settings. 2. Go to **Privacy & Security > Accessibility**. -3. Enable **Token Widget for Claude**. +3. Enable **Token Widget**. 4. Wait up to two seconds for the already-running companion to observe the new permission. If the application is not listed, request the system prompt again: ```bash -open -n -a "$HOME/Library/Application Support/Token Meter/Claude Desktop/Token Widget for Claude.app" \ +open -n -a "$HOME/Library/Application Support/Token Meter/Claude Desktop/Token Widget.app" \ --args --prompt-accessibility ``` diff --git a/integrations/claude-desktop/native/Info.plist b/integrations/claude-desktop/native/Info.plist index 82e5558..284f799 100644 --- a/integrations/claude-desktop/native/Info.plist +++ b/integrations/claude-desktop/native/Info.plist @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Token Widget for Claude + Token Widget CFBundlePackageType APPL CFBundleShortVersionString diff --git a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift index 84cefef..d785d24 100644 --- a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift +++ b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift @@ -6,7 +6,9 @@ import Security import WebKit private let claudeBundleID = "com.anthropic.claudefordesktop" +private let codexBundleID = "com.openai.codex" private let defaultClaudeAppPath = "/Applications/Claude.app" +private let launchAgentLabel = "com.sergiochan.token-meter.claude-desktop" private let fileManager = FileManager.default private func accessibilityTrusted() -> Bool { @@ -69,7 +71,7 @@ private func absoluteURL(_ value: String, option: String) throws -> URL { // The freshly bootstrapped agent instance takes over; callers keep running // only for this session (KeepAlive restarts us on quit or next login). private func selfInstallLaunchAgent(rootPath: String, nodePath: String, statePath: String) throws { - let label = "com.sergiochan.token-meter.claude-desktop" + let label = launchAgentLabel let executable = Bundle.main.executablePath ?? CommandLine.arguments[0] let logDir = fileManager.homeDirectoryForCurrentUser .appendingPathComponent("Library/Logs/Token Meter/Claude Desktop").path @@ -183,6 +185,10 @@ private func parseCommand(_ arguments: [String]) throws -> AppCommand { sessionsDirectoryURL: try absoluteURL(sessionsPath, option: "--sessions-dir"), projectsDirectoryURL: try absoluteURL(projectsPath, option: "--projects-dir") ) + // The Claude model catalog (app.asar) is intentionally not required: the + // overlay also serves Codex windows on machines without Claude installed, + // and ClaudeContextWindowResolver already degrades to nil when it is + // absent. Codex context windows come from the rollout snapshot instead. let requiredFiles = [ configuration.rootURL.appendingPathComponent("src/cli.mjs"), configuration.rootURL.appendingPathComponent("runtime/token-meter-ui.js"), @@ -190,7 +196,6 @@ private func parseCommand(_ arguments: [String]) throws -> AppCommand { configuration.rootURL.appendingPathComponent( "integrations/claude-desktop/src/overlay-bridge.mjs" ), - configuration.modelCatalogURL, ] for fileURL in requiredFiles where !fileManager.fileExists(atPath: fileURL.path) { throw CommandError(description: "Required file is missing: \(fileURL.path)") @@ -209,6 +214,140 @@ final class OverlayPanel: NSPanel { override var canBecomeMain: Bool { false } } +// Unregisters the LaunchAgent (so KeepAlive stops resurrecting us) ahead of a +// deliberate quit. Shared by the settings power button and the menu-bar item. +private func bootOutOverlayLaunchAgent() { + let bootout = Process() + bootout.executableURL = URL(fileURLWithPath: "/bin/launchctl") + bootout.arguments = ["bootout", "gui/\(getuid())/\(launchAgentLabel)"] + try? bootout.run() + bootout.waitUntilExit() +} + +// User-facing visibility switches, shared by the status-bar menu and the +// overlay tick loop. Persisted so relaunches keep the user's choice. +private final class WidgetPreferences { + private let url: URL + var widgetVisible: Bool { didSet { save() } } + var alwaysVisible: Bool { didSet { save() } } + + init(stateDirectoryURL: URL) { + url = stateDirectoryURL.appendingPathComponent("visibility.json") + widgetVisible = true + alwaysVisible = true + guard let data = try? Data(contentsOf: url), + let value = try? JSONSerialization.jsonObject(with: data) as? [String: Bool] else { + return + } + widgetVisible = value["widgetVisible"] ?? true + alwaysVisible = value["alwaysVisible"] ?? true + } + + private func save() { + let value = ["widgetVisible": widgetVisible, "alwaysVisible": alwaysVisible] + guard let data = try? JSONSerialization.data(withJSONObject: value) else { return } + try? data.write(to: url, options: .atomic) + } +} + +// Menu-bar presence: the app is a Dock-less LaunchAgent accessory, so the +// status item is the one place the user can always find the widget — show or +// hide it, keep it on the desktop, open the dashboard, or quit. +private final class StatusBarController: NSObject, NSMenuDelegate { + private let preferences: WidgetPreferences + private let openDashboardHandler: () -> Void + private let quitHandler: () -> Void + private let statusItem: NSStatusItem + private let showItem: NSMenuItem + private let alwaysItem: NSMenuItem + private let dashboardItem: NSMenuItem + private let accessibilityItem: NSMenuItem + + init( + preferences: WidgetPreferences, + openDashboard: @escaping () -> Void, + quit: @escaping () -> Void + ) { + self.preferences = preferences + openDashboardHandler = openDashboard + quitHandler = quit + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + showItem = NSMenuItem(title: "Show Widget", action: nil, keyEquivalent: "") + alwaysItem = NSMenuItem( + title: "Always Show on Desktop", action: nil, keyEquivalent: "" + ) + dashboardItem = NSMenuItem(title: "Open Dashboard…", action: nil, keyEquivalent: "") + accessibilityItem = NSMenuItem( + title: "Grant Accessibility Access…", action: nil, keyEquivalent: "" + ) + super.init() + if let button = statusItem.button { + if let image = NSImage( + systemSymbolName: "gauge.medium", accessibilityDescription: "Token Widget" + ) ?? NSImage(systemSymbolName: "gauge", accessibilityDescription: "Token Widget") { + image.isTemplate = true + button.image = image + } else { + button.title = "TW" + } + button.toolTip = "Token Widget" + } + let menu = NSMenu() + menu.autoenablesItems = false + menu.delegate = self + let quitItem = NSMenuItem( + title: "Quit Token Widget", action: #selector(quitAction), keyEquivalent: "q" + ) + let actions: [(NSMenuItem, Selector)] = [ + (showItem, #selector(toggleShow)), + (alwaysItem, #selector(toggleAlways)), + (dashboardItem, #selector(openDashboardAction)), + (accessibilityItem, #selector(openAccessibilitySettings)), + (quitItem, #selector(quitAction)), + ] + for (item, action) in actions { + item.target = self + item.action = action + } + accessibilityItem.toolTip = + "The widget needs Accessibility access to find Claude and Codex windows." + alwaysItem.toolTip = + "Keep the widget on the desktop when Claude or Codex is not in front." + menu.addItem(showItem) + menu.addItem(alwaysItem) + menu.addItem(.separator()) + menu.addItem(dashboardItem) + menu.addItem(accessibilityItem) + menu.addItem(.separator()) + menu.addItem(quitItem) + statusItem.menu = menu + } + + func menuNeedsUpdate(_ menu: NSMenu) { + showItem.state = preferences.widgetVisible ? .on : .off + alwaysItem.state = preferences.alwaysVisible ? .on : .off + alwaysItem.isEnabled = preferences.widgetVisible + let trusted = accessibilityTrusted() + accessibilityItem.isHidden = trusted + dashboardItem.isEnabled = trusted + } + + @objc private func toggleShow() { preferences.widgetVisible.toggle() } + @objc private func toggleAlways() { preferences.alwaysVisible.toggle() } + @objc private func openDashboardAction() { openDashboardHandler() } + @objc private func openAccessibilitySettings() { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ) else { return } + NSWorkspace.shared.open(url) + } + @objc private func quitAction() { quitHandler() } + + deinit { + NSStatusBar.system.removeStatusItem(statusItem) + } +} + private struct SnapshotBridgeError: Error, CustomStringConvertible { let description: String } @@ -460,6 +599,7 @@ private final class SnapshotBridge { private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMessageHandler { private let configuration: AppConfiguration private let health: RuntimeHealth + private let preferences: WidgetPreferences private let snapshotBridge: SnapshotBridge private let contextWindowResolver: ClaudeContextWindowResolver private let expandedPanelSize = CGSize(width: 320, height: 250) @@ -476,6 +616,14 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes private var visibleContextWindowTokens: Int? private var defaultPanelOrigin = CGPoint.zero private var userOffset = CGPoint.zero + private var desktopOffset = CGPoint.zero + private var desktopPositioned = false + private var showingGlobalFace = false + private var globalSnapshotInFlight = false + private var lastGlobalSnapshotAt = Date.distantPast + private var showingCodexFace = false + private var codexSnapshotInFlight = false + private var lastCodexSnapshotAt = Date.distantPast private var dragTimer: Timer? private var dragStartMouse = CGPoint.zero private var dragStartPanel = CGPoint.zero @@ -484,9 +632,14 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes private var lastHostPosition: CGPoint? private var lastHostSize: CGSize? - init(configuration: AppConfiguration, health: RuntimeHealth) { + init( + configuration: AppConfiguration, + health: RuntimeHealth, + preferences: WidgetPreferences + ) { self.configuration = configuration self.health = health + self.preferences = preferences snapshotBridge = SnapshotBridge(configuration: configuration) contextWindowResolver = ClaudeContextWindowResolver( modelCatalogURL: configuration.modelCatalogURL @@ -585,36 +738,66 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes } private func tick() { - guard let claude = NSRunningApplication.runningApplications( - withBundleIdentifier: claudeBundleID - ).first else { - panel.orderOut(nil) - currentSessionID = nil - health.update(sessionBound: false) + guard preferences.widgetVisible else { + hidePanel() return } - - let appElement = AXUIElementCreateApplication(claude.processIdentifier) - guard claude.isActive, - let window = axElement(appElement, kAXFocusedWindowAttribute), - let position = axPoint(window, kAXPositionAttribute), - let size = axSize(window, kAXSizeAttribute) else { - panel.orderOut(nil) - health.update(sessionBound: false) + // A frontmost Claude window with a Claude Code session gets the bound + // session face; a frontmost Codex window gets the bound Codex face + // (thread resolved from the Codex state database by the bridge); a + // frontmost Claude with no session gets the machine-wide face pinned to + // that window; with none in front, the machine-wide face parks on the + // desktop when the user wants it. + if let window = frontmostHostWindow(bundleID: claudeBundleID), + let position = axPoint(window, kAXPositionAttribute), + let size = axSize(window, kAXSizeAttribute) { + if let surface = resolveClaudeCodeSurface(in: window) { + showSessionFace(surface: surface, hostPosition: position, hostSize: size) + } else { + showGlobalFace(hostPosition: position, hostSize: size) + } return } - - guard let surface = resolveClaudeCodeSurface(in: window) else { - panel.orderOut(nil) - currentSessionID = nil - visibleContextWindowTokens = nil - health.update(bridgeHealthy: false, sessionBound: false) - publishUnbound() + if let window = frontmostHostWindow(bundleID: codexBundleID), + let position = axPoint(window, kAXPositionAttribute), + let size = axSize(window, kAXSizeAttribute) { + showCodexFace(hostPosition: position, hostSize: size) + return + } + if preferences.alwaysVisible { + showGlobalFace(hostPosition: nil, hostSize: nil) return } + hidePanel() + } + + private func frontmostHostWindow(bundleID: String) -> AXUIElement? { + guard let app = NSRunningApplication.runningApplications( + withBundleIdentifier: bundleID + ).first(where: { $0.isActive }) else { return nil } + let appElement = AXUIElementCreateApplication(app.processIdentifier) + return axElement(appElement, kAXFocusedWindowAttribute) + } + + private func hidePanel() { + panel.orderOut(nil) + currentSessionID = nil + showingGlobalFace = false + showingCodexFace = false + health.update(sessionBound: false) + } + + private func showSessionFace( + surface: ClaudeCodeSurface, hostPosition: CGPoint, hostSize: CGSize + ) { let identifier = surface.sessionID - positionPanel(hostPosition: position, hostSize: size) + if showingGlobalFace || showingCodexFace { + showingGlobalFace = false + showingCodexFace = false + publishUnbound() + } + positionPanel(hostPosition: hostPosition, hostSize: hostSize) panel.orderFrontRegardless() if identifier != currentSessionID { @@ -638,7 +821,93 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes } } + // Shared face for Codex windows and the bare desktop: the bridge's + // machine-wide usage totals instead of a bound Claude session. + private func showGlobalFace(hostPosition: CGPoint?, hostSize: CGSize?) { + if currentSessionID != nil || showingCodexFace || !showingGlobalFace { + currentSessionID = nil + showingCodexFace = false + visibleContextWindowTokens = nil + contextScanCadence.reset() + lastGlobalSnapshotAt = .distantPast + health.update(sessionBound: false) + } + showingGlobalFace = true + if let hostPosition, let hostSize { + positionPanel(hostPosition: hostPosition, hostSize: hostSize) + } else { + positionPanelOnDesktop() + } + panel.orderFrontRegardless() + if Date().timeIntervalSince(lastGlobalSnapshotAt) >= 5 { + fetchGlobalSnapshot() + } + } + + private func fetchGlobalSnapshot() { + guard !globalSnapshotInFlight, pageReady else { return } + globalSnapshotInFlight = true + lastGlobalSnapshotAt = Date() + snapshotBridge.command(["command": "global-snapshot"]) { [weak self] result in + guard let self else { return } + self.globalSnapshotInFlight = false + guard self.showingGlobalFace else { return } + guard case .success(let snapshot) = result, + let data = try? JSONSerialization.data(withJSONObject: snapshot) else { + self.health.update(bridgeHealthy: false) + self.publishUnbound() + return + } + self.health.update(bridgeHealthy: true) + let json = String(decoding: data, as: UTF8.self) + self.webView.evaluateJavaScript("window.__tokenMeter?.update(\(json))") + } + } + + // Bound face for a frontmost Codex window. The bridge resolves which Codex + // thread is active from the state database, so — unlike the Claude session + // face — the native side supplies no session identifier and reads no + // Accessibility tree; it only pins the panel to the window and polls. + private func showCodexFace(hostPosition: CGPoint, hostSize: CGSize) { + if currentSessionID != nil || showingGlobalFace || !showingCodexFace { + currentSessionID = nil + showingGlobalFace = false + visibleContextWindowTokens = nil + contextScanCadence.reset() + lastCodexSnapshotAt = .distantPast + publishUnbound() + } + showingCodexFace = true + positionPanel(hostPosition: hostPosition, hostSize: hostSize) + panel.orderFrontRegardless() + if Date().timeIntervalSince(lastCodexSnapshotAt) >= 0.8 { + fetchCodexSnapshot() + } + } + + private func fetchCodexSnapshot() { + guard !codexSnapshotInFlight, pageReady else { return } + codexSnapshotInFlight = true + lastCodexSnapshotAt = Date() + snapshotBridge.command(["command": "codex-snapshot"]) { [weak self] result in + guard let self else { return } + self.codexSnapshotInFlight = false + guard self.showingCodexFace else { return } + guard case .success(let snapshot) = result, + let data = try? JSONSerialization.data(withJSONObject: snapshot) else { + self.health.update(bridgeHealthy: false, sessionBound: false) + self.publishUnbound() + return + } + let bound = (snapshot["binding"] as? [String: Any])?["exact"] as? Bool ?? false + self.health.update(bridgeHealthy: true, sessionBound: bound) + let json = String(decoding: data, as: UTF8.self) + self.webView.evaluateJavaScript("window.__tokenMeter?.update(\(json))") + } + } + private func positionPanel(hostPosition: CGPoint, hostSize: CGSize) { + desktopPositioned = false lastHostPosition = hostPosition lastHostSize = hostSize let hostCenter = CGPoint( @@ -675,6 +944,35 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes } } + // With no host window to follow, the widget parks in the bottom-right + // corner of the primary screen (screens.first is deterministic; .main + // follows other apps' key windows across displays). Its drag offset is + // remembered separately from the window-following offset. + private func positionPanelOnDesktop() { + desktopPositioned = true + lastHostPosition = nil + lastHostSize = nil + guard let screen = NSScreen.screens.first else { return } + let frame = screen.visibleFrame + defaultPanelOrigin = CGPoint( + x: frame.maxX - currentPanelSize.width - 24, + y: frame.minY + 24 + ) + guard !dragging else { return } + var origin = CGPoint( + x: defaultPanelOrigin.x + desktopOffset.x, + y: defaultPanelOrigin.y + desktopOffset.y + ) + // A stale drag offset can point at a display that is no longer + // attached; fall back to the default corner instead of parking the + // widget somewhere invisible. + let target = CGRect(origin: origin, size: currentPanelSize) + if !NSScreen.screens.contains(where: { $0.visibleFrame.intersects(target) }) { + origin = defaultPanelOrigin + } + panel.setFrameOrigin(origin) + } + private func fetchSnapshot(for identifier: String, contextWindowTokens: Int?) { guard !snapshotInFlight else { return } snapshotInFlight = true @@ -779,6 +1077,8 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes resizePanel() if let position = lastHostPosition, let size = lastHostSize { positionPanel(hostPosition: position, hostSize: size) + } else if desktopPositioned { + positionPanelOnDesktop() } saveCollapsedState() } @@ -829,22 +1129,7 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes private func handleAction(type: String, body: [String: Any]) { switch type { case "open-dashboard": - // The dashboard is served by the bridge's loopback server so the - // page can read the profile and claim a handle. Only a loopback - // URL returned by our own bridge is ever opened. The optional - // view ("share" / "withdraw") selects the consent wizard page. - var command: [String: Any] = ["command": "dashboard-url"] - if let view = body["view"] as? String, view == "share" || view == "withdraw" { - command["view"] = view - } - snapshotBridge.command(command) { result in - guard case .success(let payload) = result, - let urlString = payload["url"] as? String, - let url = URL(string: urlString), - url.scheme == "http", - url.host == "127.0.0.1" else { return } - DispatchQueue.main.async { NSWorkspace.shared.open(url) } - } + openDashboard(view: body["view"] as? String) case "open-leaderboard": snapshotBridge.command(["command": "leaderboard-url"]) { result in guard case .success(let payload) = result, @@ -863,14 +1148,7 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) case "quit-widget": - let bootout = Process() - bootout.executableURL = URL(fileURLWithPath: "/bin/launchctl") - bootout.arguments = ["bootout", "gui/\(getuid())/com.sergiochan.token-meter.claude-desktop"] - try? bootout.run() - bootout.waitUntilExit() - // Same reason as the updater: the bridge outlives a bare exit. - snapshotBridge.stop() - exit(0) + quitWidget() case "set-sharing": let enabled = body["enabled"] as? Bool ?? false snapshotBridge.command(["command": "set-sharing", "enabled": enabled]) { _ in } @@ -896,6 +1174,35 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes } } + // The dashboard is served by the bridge's loopback server so the page can + // read the profile and claim a handle. Only a loopback URL returned by our + // own bridge is ever opened. The optional view ("share" / "withdraw") + // selects the consent wizard page. Reached from the overlay's settings + // panel and from the status-bar menu. + func openDashboard(view: String? = nil) { + var command: [String: Any] = ["command": "dashboard-url"] + if let view, view == "share" || view == "withdraw" { + command["view"] = view + } + snapshotBridge.command(command) { result in + guard case .success(let payload) = result, + let urlString = payload["url"] as? String, + let url = URL(string: urlString), + url.scheme == "http", + url.host == "127.0.0.1" else { return } + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + } + + // Deliberate, user-initiated quit: unregister the LaunchAgent so KeepAlive + // does not resurrect the widget, then take the bridge down — it outlives a + // bare exit otherwise. + func quitWidget() -> Never { + bootOutOverlayLaunchAgent() + snapshotBridge.stop() + exit(0) + } + // Pushes install progress to the banner. States come from string literals // in this file only, so they are safe to interpolate into the page. private func postUpdateState(_ state: String) { @@ -1104,29 +1411,44 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes dragging = false dragTimer?.invalidate() dragTimer = nil - userOffset = CGPoint( + let offset = CGPoint( x: panel.frame.origin.x - defaultPanelOrigin.x, y: panel.frame.origin.y - defaultPanelOrigin.y ) - saveOffset() + if desktopPositioned { + desktopOffset = offset + saveOffset(offset, to: desktopOffsetURL) + } else { + userOffset = offset + saveOffset(offset, to: offsetURL) + } } private var offsetURL: URL { configuration.stateDirectoryURL.appendingPathComponent("position.json") } + private var desktopOffsetURL: URL { + configuration.stateDirectoryURL.appendingPathComponent("position-desktop.json") + } + private func loadOffset() { - guard let data = try? Data(contentsOf: offsetURL), + userOffset = readOffset(from: offsetURL) + desktopOffset = readOffset(from: desktopOffsetURL) + } + + private func readOffset(from url: URL) -> CGPoint { + guard let data = try? Data(contentsOf: url), let value = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else { - return + return .zero } - userOffset = CGPoint(x: value["x"] ?? 0, y: value["y"] ?? 0) + return CGPoint(x: value["x"] ?? 0, y: value["y"] ?? 0) } - private func saveOffset() { - let value = ["x": userOffset.x, "y": userOffset.y] + private func saveOffset(_ offset: CGPoint, to url: URL) { + let value = ["x": offset.x, "y": offset.y] guard let data = try? JSONSerialization.data(withJSONObject: value) else { return } - try? data.write(to: offsetURL, options: .atomic) + try? data.write(to: url, options: .atomic) } } @@ -1134,14 +1456,30 @@ private final class CompanionRuntime { private let configuration: AppConfiguration private var permissionTimer: Timer? private let health: RuntimeHealth + private let preferences: WidgetPreferences + private var statusBar: StatusBarController? private var meterController: MeterController? init(configuration: AppConfiguration) throws { self.configuration = configuration health = try RuntimeHealth(stateDirectoryURL: configuration.stateDirectoryURL) + preferences = WidgetPreferences(stateDirectoryURL: configuration.stateDirectoryURL) } func start() { + // The status item exists even while Accessibility is missing, so the + // user can always find the widget, grant access, or quit. + statusBar = StatusBarController( + preferences: preferences, + openDashboard: { [weak self] in self?.meterController?.openDashboard() }, + quit: { [weak self] in + if let controller = self?.meterController { + controller.quitWidget() + } + bootOutOverlayLaunchAgent() + exit(0) + } + ) reconcileAccessibility(logWaiting: true) permissionTimer = Timer.scheduledTimer( withTimeInterval: 2, @@ -1169,7 +1507,11 @@ private final class CompanionRuntime { return } guard meterController == nil else { return } - let controller = MeterController(configuration: configuration, health: health) + let controller = MeterController( + configuration: configuration, + health: health, + preferences: preferences + ) meterController = controller controller.start() } diff --git a/integrations/claude-desktop/scripts/install.sh b/integrations/claude-desktop/scripts/install.sh index 7cbb424..0a78116 100755 --- a/integrations/claude-desktop/scripts/install.sh +++ b/integrations/claude-desktop/scripts/install.sh @@ -106,10 +106,16 @@ if [ ! -x "$VERIFIER" ]; then printf 'Claude verifier is not executable: %s\n' "$VERIFIER" >&2 exit 1 fi -"$VERIFIER" "$CLAUDE_APP_PATH" >/dev/null -if [ ! -f "$CLAUDE_APP_PATH/Contents/Resources/app.asar" ]; then - printf 'Claude model catalog is missing from %s\n' "$CLAUDE_APP_PATH" >&2 - exit 1 +# The overlay also serves Codex windows, so Claude.app is no longer required. +# When it is present we still verify it is the genuine, unmodified app before +# reading its model catalog; when it is absent we install Codex-only support. +if [ -e "$CLAUDE_APP_PATH" ]; then + "$VERIFIER" "$CLAUDE_APP_PATH" >/dev/null + if [ ! -f "$CLAUDE_APP_PATH/Contents/Resources/app.asar" ]; then + printf 'Claude model catalog is missing from %s; context-window sizing falls back to live readings.\n' "$CLAUDE_APP_PATH" >&2 + fi +else + printf 'Claude.app not found at %s; installing without the Claude model catalog (Codex support only).\n' "$CLAUDE_APP_PATH" >&2 fi /bin/mkdir -p \ @@ -169,9 +175,9 @@ trap cleanup EXIT /usr/bin/install -m 600 "$ROOT/LICENSE" "$STAGING/LICENSE" token_meter_mark_installation_directory "$STAGING" "$LABEL" "$ROOT/integrations/claude-desktop/scripts/build-app.sh" \ - --output "$STAGING/Token Widget for Claude.app" + --output "$STAGING/Token Widget.app" -EXECUTABLE="$INSTALL_ROOT/Token Widget for Claude.app/Contents/MacOS/TokenMeterClaudeOverlay" +EXECUTABLE="$INSTALL_ROOT/Token Widget.app/Contents/MacOS/TokenMeterClaudeOverlay" "$NODE_PATH" "$ROOT/integrations/claude-desktop/scripts/render-launch-agent.mjs" \ --output "$PLIST_TEMP" \ --label "$LABEL" \ @@ -211,7 +217,7 @@ fi /bin/mv "$PLIST_TEMP" "$PLIST" ACTIVATED=true -APP_BUNDLE="$INSTALL_ROOT/Token Widget for Claude.app" +APP_BUNDLE="$INSTALL_ROOT/Token Widget.app" if [ "$PROMPT" = true ]; then /usr/bin/open -n -a "$APP_BUNDLE" --args --prompt-accessibility >/dev/null 2>&1 || true /bin/sleep 1 @@ -299,7 +305,7 @@ fi if [ "$ACCESSIBILITY_GRANTED" = true ]; then printf 'Accessibility permission: granted.\n' else - printf 'Accessibility permission: required. Enable Token Widget for Claude in System Settings > Privacy & Security > Accessibility.\n' + printf 'Accessibility permission: required. Enable Token Widget in System Settings > Privacy & Security > Accessibility.\n' fi printf 'Logs: %s\n' "$LOG_DIR" trap - EXIT diff --git a/integrations/claude-desktop/scripts/status.sh b/integrations/claude-desktop/scripts/status.sh index fedbfae..39cdccb 100755 --- a/integrations/claude-desktop/scripts/status.sh +++ b/integrations/claude-desktop/scripts/status.sh @@ -13,7 +13,7 @@ LABEL="com.sergiochan.token-meter.claude-desktop" PLIST="$LAUNCH_AGENTS_DIR/$LABEL.plist" DOMAIN="gui/$(/usr/bin/id -u)" LAUNCHCTL="${TOKEN_METER_LAUNCHCTL:-/bin/launchctl}" -EXECUTABLE="$INSTALL_ROOT/Token Widget for Claude.app/Contents/MacOS/TokenMeterClaudeOverlay" +EXECUTABLE="$INSTALL_ROOT/Token Widget.app/Contents/MacOS/TokenMeterClaudeOverlay" HEALTH_FILE="$STATE_DIR/health.json" JSON=false diff --git a/integrations/claude-desktop/src/overlay-bridge.mjs b/integrations/claude-desktop/src/overlay-bridge.mjs index 44003c5..db26114 100644 --- a/integrations/claude-desktop/src/overlay-bridge.mjs +++ b/integrations/claude-desktop/src/overlay-bridge.mjs @@ -5,6 +5,19 @@ import readline from "node:readline"; import { once } from "node:events"; import { fileURLToPath } from "node:url"; import { ClaudeSnapshotRuntime } from "./snapshot-runtime.mjs"; +import { CodexSnapshotRuntime } from "../../codex-desktop/src/snapshot-runtime.mjs"; + +// The Codex host path reads state_5.sqlite through node:sqlite, whose +// experimental warning would otherwise repeat into the LaunchAgent log on +// every process start. Replace Node's default warning printer with one that +// drops only that warning and prints everything else unchanged. +process.removeAllListeners("warning"); +process.on("warning", (warning) => { + if (warning.name === "ExperimentalWarning" && /SQLite/i.test(warning.message)) { + return; + } + process.stderr.write(`${warning.name}: ${warning.message}\n`); +}); import { runCommunitySyncWorker } from "../../../src/core/community-sync.mjs"; import { loadOrCreateIdentity, @@ -90,6 +103,8 @@ function parseArguments(argv) { const value = argv[index]; if (value === "--sessions-dir") options.sessionsDirectory = argv[++index]; else if (value === "--projects-dir") options.projectsDirectory = argv[++index]; + else if (value === "--codex-sessions-dir") options.codexSessionsDirectory = argv[++index]; + else if (value === "--codex-state-db") options.codexStateDatabase = argv[++index]; else if (value === "--help" || value === "-h") options.help = true; else throw new Error(`Unknown argument: ${value}`); } @@ -115,6 +130,54 @@ if (options.help) { } const cachedUsageHistory = new UsageHistory(); + +// Aggregate face for the always-on desktop widget and non-Claude hosts: no +// session binding, just machine-wide totals from the usage-history cache. +// Memoized because the native layer polls it on a steady cadence. +let globalSnapshotMemo = null; +function globalSnapshot() { + const nowMs = Date.now(); + if (globalSnapshotMemo && nowMs - globalSnapshotMemo.atMs < 60_000) { + return globalSnapshotMemo.value; + } + let identity = null; + try { + identity = loadOrCreateIdentity(); + } catch { + identity = null; + } + let meterStats = null; + let todayTokens = null; + try { + const collected = cachedUsageHistory.collectCached(); + meterStats = { + lifetimeTokens: collected.stats.lifetimeTokens, + currentStreakDays: collected.stats.currentStreakDays, + }; + const today = new Date(nowMs); + const todayKey = [ + today.getFullYear(), + String(today.getMonth() + 1).padStart(2, "0"), + String(today.getDate()).padStart(2, "0"), + ].join("-"); + todayTokens = collected.days.find((day) => day.date === todayKey)?.total ?? 0; + } catch { + meterStats = null; + todayTokens = null; + } + const value = { + status: "global", + binding: { exact: false }, + meterId: identity?.meterId ?? null, + meterHandle: identity?.handle ?? null, + sharingEnabled: identity?.sharing?.enabled ?? false, + handlePrompted: identity?.handlePromptedAtMs != null, + meterStats, + todayTokens, + }; + globalSnapshotMemo = { atMs: nowMs, value }; + return value; +} const runtime = new ClaudeSnapshotRuntime({ sessionsDirectory: options.sessionsDirectory ?? @@ -129,6 +192,20 @@ const runtime = new ClaudeSnapshotRuntime({ options.projectsDirectory ?? path.join(os.homedir(), ".claude", "projects"), usageHistory: { collect: () => cachedUsageHistory.collectCached() }, }); + +// The same overlay serves Codex windows. Constructed lazily so a Claude-only +// session never opens the Codex state database or scans ~/.codex. +let codexRuntime = null; +function ensureCodexRuntime() { + if (codexRuntime == null) { + codexRuntime = new CodexSnapshotRuntime({ + sessionsDirectory: options.codexSessionsDirectory, + stateDatabasePath: options.codexStateDatabase, + usageHistory: { collect: () => cachedUsageHistory.collectCached() }, + }); + } + return codexRuntime; +} let dashboardServer = null; const input = readline.createInterface({ input: process.stdin, @@ -183,6 +260,20 @@ for await (const line of input) { if (identity.sharing.enabled) void syncCommunity("consent"); continue; } + if (request?.command === "global-snapshot") { + const snapshot = { ...globalSnapshot() }; + snapshot.appVersion = installedVersion; + if (updateInfo) snapshot.updateInfo = { version: updateInfo.version }; + await writeLine({ requestId, snapshot }); + continue; + } + if (request?.command === "codex-snapshot") { + const snapshot = await ensureCodexRuntime().snapshot(); + snapshot.appVersion = installedVersion; + if (updateInfo) snapshot.updateInfo = { version: updateInfo.version }; + await writeLine({ requestId, snapshot }); + continue; + } if (request?.command === "update-info") { await writeLine( updateInfo diff --git a/integrations/codex-desktop/README.md b/integrations/codex-desktop/README.md index 8ab3ad0..3380636 100644 --- a/integrations/codex-desktop/README.md +++ b/integrations/codex-desktop/README.md @@ -1,5 +1,12 @@ # Codex Desktop integration +> **Deprecated.** This CDP adapter is superseded by the native Token Widget +> overlay, which meters Codex by reading the active thread from Codex's local +> state (`~/.codex/state_5.sqlite`) and its rollout files — with no loopback +> debugging port, no launch flags, and no quit/relaunch of Codex. The adapter +> here still works but will be removed in a future release. See the repository +> README for the app install. + This integration injects the shared Token Widget runtime into the verified Codex Desktop renderer on macOS. ## Interface diff --git a/integrations/codex-desktop/src/snapshot-runtime.mjs b/integrations/codex-desktop/src/snapshot-runtime.mjs new file mode 100644 index 0000000..452f22a --- /dev/null +++ b/integrations/codex-desktop/src/snapshot-runtime.mjs @@ -0,0 +1,129 @@ +// Produces Codex meter snapshots without CDP. Identity comes from the Codex +// state database (the active user thread), token telemetry from the same +// rollout files the CDP path already read. This mirrors ClaudeSnapshotRuntime's +// interface so the native overlay and its bridge treat both hosts the same way. + +import os from "node:os"; +import path from "node:path"; +import { MetricsEngine } from "../../../src/core/metrics-engine.mjs"; +import { RolloutStore } from "../../../src/core/rollout-store.mjs"; +import { loadOrCreateIdentity } from "../../../src/core/identity.mjs"; +import { UsageHistory } from "../../../src/core/usage-history.mjs"; +import { readActiveCodexThread } from "./thread-state.mjs"; + +export function defaultSessionsDirectory() { + return path.join(os.homedir(), ".codex", "sessions"); +} + +export class CodexSnapshotRuntime { + constructor({ + sessionsDirectory = defaultSessionsDirectory(), + now = Date.now, + rolloutStore = null, + metricsEngine = null, + // Path to the Codex state database; defaults inside readActiveCodexThread. + stateDatabasePath = undefined, + // Injected in tests; in production the active thread is read from the + // Codex state database each snapshot. + resolveActiveThread = stateDatabasePath + ? () => readActiveCodexThread(stateDatabasePath) + : readActiveCodexThread, + identity = undefined, + identityDir = undefined, + usageHistory = undefined, + usageTtlMs = 300_000, + historyFileLimit = 100, + } = {}) { + if (typeof now !== "function") throw new TypeError("now must be a function"); + this.now = now; + this.rolloutStore = + rolloutStore ?? new RolloutStore({ sessionsDirectory, historyFileLimit }); + this.metricsEngine = metricsEngine ?? new MetricsEngine(); + this.resolveActiveThread = resolveActiveThread; + this.identity = identity; + this.identityDir = identityDir; + this.identityMemo = null; + this.usageHistory = usageHistory; + this.usageTtlMs = usageTtlMs; + this.usageMemo = null; + } + + async snapshot() { + const active = this.resolveActiveThread(); + if (active?.threadId == null) { + const unbound = { + status: "unbound", + binding: { source: "codex-state-db", exact: false }, + reason: "No active Codex user thread was found in the state database.", + }; + this.#decorate(unbound); + return unbound; + } + const files = await this.rolloutStore.refresh({ + activeThreadIds: [active.threadId], + }); + const snapshot = this.metricsEngine.snapshot(files, { + threadId: active.threadId, + nowMs: this.now(), + hostName: "Codex", + }); + snapshot.binding = { + source: "codex-state-db", + exact: snapshot.status === "bound", + threadId: active.threadId, + }; + snapshot.usageMethod = "codex-rollout-raw"; + this.#decorate(snapshot); + return snapshot; + } + + // Streak and lifetime stats come from the full-history scan; refresh on a + // slow TTL so the per-tick snapshot path never pays the scan cost. + #usageStats() { + if (this.usageHistory === null) return null; + const nowMs = Date.now(); + if (this.usageMemo == null || nowMs - this.usageMemo.atMs > this.usageTtlMs) { + let stats = null; + try { + this.usageHistory ??= new UsageHistory(); + const collected = this.usageHistory.collect().stats; + stats = { + lifetimeTokens: collected.lifetimeTokens, + currentStreakDays: collected.currentStreakDays, + }; + } catch { + stats = null; + } + this.usageMemo = { atMs: nowMs, stats }; + } + return this.usageMemo.stats; + } + + // Injected identities are fixed (tests); otherwise re-read on a short TTL so + // consent granted from the dashboard page reaches the overlay promptly. + #identity() { + if (this.identity !== undefined) return this.identity; + const nowMs = Date.now(); + if (this.identityMemo == null || nowMs - this.identityMemo.atMs > 5_000) { + let value = null; + try { + value = this.identityDir + ? loadOrCreateIdentity(this.identityDir) + : loadOrCreateIdentity(); + } catch { + value = null; + } + this.identityMemo = { atMs: nowMs, value }; + } + return this.identityMemo.value; + } + + #decorate(snapshot) { + const identity = this.#identity(); + snapshot.meterId = identity?.meterId ?? null; + snapshot.meterHandle = identity?.handle ?? null; + snapshot.sharingEnabled = identity?.sharing?.enabled ?? false; + snapshot.handlePrompted = identity?.handlePromptedAtMs != null; + snapshot.meterStats = this.#usageStats(); + } +} diff --git a/integrations/codex-desktop/src/thread-state.mjs b/integrations/codex-desktop/src/thread-state.mjs new file mode 100644 index 0000000..254a5ef --- /dev/null +++ b/integrations/codex-desktop/src/thread-state.mjs @@ -0,0 +1,81 @@ +// Resolves which Codex thread the user is actively working in, without CDP. +// +// The Codex Desktop app runs its own long-lived app-server that owns +// ~/.codex/state_5.sqlite and writes it live. The `threads` table records a +// per-thread `recency_at_ms` stamped when the user submits a turn, plus a +// `thread_source` that separates real user threads from spawned sub-agents. +// Reading that table read-only gives the active user thread — the one that is +// actually consuming tokens — without injecting into or restarting Codex. +// +// This deliberately binds to "the thread that most recently started a turn", +// not "the thread the user is looking at": token flow follows the former, and +// the latter is only recorded in a debounced UI atom that lags disk by minutes. + +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +export function defaultStateDatabasePath() { + return path.join(os.homedir(), ".codex", "state_5.sqlite"); +} + +// Pure selection over already-read rows, split out so the ranking rule can be +// tested without a database. Rows mirror the `threads` columns we select. +export function pickActiveThread(rows) { + if (!Array.isArray(rows)) return null; + const eligible = rows.filter( + (row) => + row != null && + row.thread_source === "user" && + Number(row.archived) === 0 && + typeof row.id === "string" && + row.id.length > 0, + ); + if (eligible.length === 0) return null; + eligible.sort( + (left, right) => Number(right.recency_at_ms) - Number(left.recency_at_ms), + ); + const active = eligible[0]; + return { + threadId: active.id, + title: typeof active.name === "string" && active.name.length > 0 + ? active.name + : typeof active.title === "string" + ? active.title + : null, + tokensUsed: Number.isFinite(Number(active.tokens_used)) + ? Number(active.tokens_used) + : null, + recencyAtMs: Number.isFinite(Number(active.recency_at_ms)) + ? Number(active.recency_at_ms) + : null, + }; +} + +// Reads the active user thread from the Codex state database. Fails closed: +// any missing file, locked handle, absent column, or schema drift returns null +// rather than throwing, so a caller never falls back to a wrong thread. +export function readActiveCodexThread(databasePath = defaultStateDatabasePath()) { + let database = null; + try { + database = new DatabaseSync(databasePath, { readOnly: true }); + const rows = database + .prepare( + `SELECT id, name, title, tokens_used, recency_at_ms, thread_source, archived + FROM threads + WHERE thread_source = 'user' AND archived = 0 + ORDER BY recency_at_ms DESC + LIMIT 8`, + ) + .all(); + return pickActiveThread(rows); + } catch { + return null; + } finally { + try { + database?.close(); + } catch { + // A close failure on a read-only handle is not actionable. + } + } +} diff --git a/runtime/token-meter-ui.js b/runtime/token-meter-ui.js index 15e7720..17f795c 100644 --- a/runtime/token-meter-ui.js +++ b/runtime/token-meter-ui.js @@ -63,7 +63,7 @@
- 24H TOTAL + 24H TOTAL CURRENT STREAK
@@ -147,6 +147,7 @@ gauge: card.querySelector(".gauge"), sessionId: card.querySelector(".session-id"), sessionTotal: card.querySelector(".session-total"), + dayLabel: card.querySelector(".day-label"), dayTotal: card.querySelector(".day-total"), streak: card.querySelector(".streak"), lifetime: card.querySelector(".lifetime"), @@ -533,26 +534,79 @@ !wanted || !elements.warning.hidden || !elements.updateBanner.hidden; }; + // Settings-panel identity, version line, and privacy toggle are shared by + // the session face and the machine-wide global face. + const renderSettingsIdentity = (snapshot) => { + if (snapshot?.meterHandle) { + elements.settingsIdentityLink.hidden = false; + elements.settingsIdentityLink.textContent = `@${snapshot.meterHandle}`; + elements.settingsAnon.hidden = true; + } else { + elements.settingsIdentityLink.hidden = true; + elements.settingsAnon.hidden = false; + } + elements.settingsClaim.hidden = + Boolean(snapshot?.meterHandle) || !snapshot?.meterId; + versionLabel = snapshot?.appVersion + ? `Token Widget v${snapshot.appVersion}` + + (snapshot.updateInfo?.version ? ` · v${snapshot.updateInfo.version} available` : "") + : ""; + const tipText = elements.settingsTip.textContent; + if (!tipText || tipText.startsWith("Token Widget v")) { + elements.settingsTip.textContent = versionLabel; + } + if (Date.now() - sharingToggledAtMs > 3000) { + setPrivacyUI(Boolean(snapshot?.sharingEnabled)); + } + }; + const update = (snapshot) => { ensureMounted(); const bound = snapshot?.status === "bound" && snapshot?.binding?.exact; + const global = !bound && snapshot?.status === "global"; card.dataset.bound = String(bound); - elements.unbound.hidden = bound; + card.dataset.mode = bound ? "session" : global ? "global" : "unbound"; + elements.unbound.hidden = bound || global; + elements.dayLabel.textContent = global ? "TODAY" : "24H TOTAL"; if (!bound) { elements.warning.hidden = true; renderUpdateBanner(snapshot); renderHandlePrompt(snapshot); - elements.sessionId.textContent = "UNBOUND"; + if (global) { + // Desktop / non-Claude host face: identity plus machine-wide totals + // from the usage history; no live session, so the gauge idles. + renderSettingsIdentity(snapshot); + const identityLabel = snapshot.meterHandle + ? `@${snapshot.meterHandle}` + : snapshot.meterId; + elements.sessionId.textContent = identityLabel ?? "ALL AGENTS"; + elements.sessionId.title = + "Machine-wide usage across Claude Code, Codex, and Cline" + + (nativeActions() ? " · Click to open your dashboard" : ""); + elements.dayTotal.textContent = + snapshot.todayTokens == null ? "—" : format(snapshot.todayTokens); + const stats = snapshot.meterStats; + elements.streak.textContent = + stats?.currentStreakDays == null + ? "—" + : `${stats.currentStreakDays} day${stats.currentStreakDays === 1 ? "" : "s"}`; + elements.lifetime.textContent = + stats?.lifetimeTokens == null ? "—" : format(stats.lifetimeTokens); + elements.rate.textContent = "Idle"; + } else { + elements.sessionId.textContent = "UNBOUND"; + elements.sessionId.title = ""; + elements.dayTotal.textContent = "—"; + elements.streak.textContent = "—"; + elements.lifetime.textContent = "—"; + elements.rate.textContent = "Awaiting session"; + } elements.sessionTotal.textContent = "—"; - elements.dayTotal.textContent = "—"; - elements.streak.textContent = "—"; - elements.lifetime.textContent = "—"; elements.turnTotal.textContent = "—"; elements.contextTotal.textContent = "—"; elements.contextExtra.textContent = ""; elements.compactionCount.textContent = "—"; elements.accountHour.textContent = "—"; - elements.rate.textContent = "Awaiting session"; elements.baseline.textContent = "—"; elements.agentCount.textContent = ""; elements.usageDelta.textContent = ""; @@ -588,26 +642,7 @@ ? `Meter ${snapshot.meterId} · Session ${snapshot.sessionId}` : `Session ${snapshot.sessionId}`) + (nativeActions() ? " · Click to open your dashboard" : ""); - if (snapshot.meterHandle) { - elements.settingsIdentityLink.hidden = false; - elements.settingsIdentityLink.textContent = `@${snapshot.meterHandle}`; - elements.settingsAnon.hidden = true; - } else { - elements.settingsIdentityLink.hidden = true; - elements.settingsAnon.hidden = false; - } - elements.settingsClaim.hidden = Boolean(snapshot.meterHandle) || !snapshot.meterId; - versionLabel = snapshot.appVersion - ? `Token Widget v${snapshot.appVersion}` + - (snapshot.updateInfo?.version ? ` · v${snapshot.updateInfo.version} available` : "") - : ""; - const tipText = elements.settingsTip.textContent; - if (!tipText || tipText.startsWith("Token Widget v")) { - elements.settingsTip.textContent = versionLabel; - } - if (Date.now() - sharingToggledAtMs > 3000) { - setPrivacyUI(Boolean(snapshot.sharingEnabled)); - } + renderSettingsIdentity(snapshot); const delta = Math.max(0, snapshot.session.totalTokens - lastSessionTotal); lastSessionTotal = snapshot.session.totalTokens; if (delta > 0 && !sessionChanged) { diff --git a/scripts/install-token-meter-macos.sh b/scripts/install-token-meter-macos.sh index c7e48ab..02148b8 100755 --- a/scripts/install-token-meter-macos.sh +++ b/scripts/install-token-meter-macos.sh @@ -43,6 +43,22 @@ while [ "$#" -gt 0 ]; do esac done +# The CDP adapter is deprecated: the native Token Widget overlay now meters +# Codex without a loopback debugging port, without launch flags, and without +# ever quitting or relaunching Codex. This source installer is kept working for +# now but will be removed in a future release. Set TOKEN_METER_CODEX_CDP_ACK=1 +# to silence this notice in existing automation. +if [ "${TOKEN_METER_CODEX_CDP_ACK:-}" != "1" ]; then + cat >&2 <<'EOF' +NOTICE: The Codex CDP adapter is deprecated. + The Token Widget app (the notarized DMG) now supports Codex natively — it + reads the active thread from Codex's local state and never touches the running + Codex process. Prefer installing that app instead of this CDP adapter. + This installer still works for now and will be removed in a future release. + Set TOKEN_METER_CODEX_CDP_ACK=1 to silence this notice. +EOF +fi + case "$PORT" in ''|*[!0-9]*) printf 'Port must be numeric.\n' >&2 diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 80ca64a..933c1e1 100755 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -50,7 +50,7 @@ npm run claude:install echo bold "Installed!" echo "Final step (one time): System Settings > Privacy & Security > Accessibility" -echo " -> enable 'Token Widget for Claude' (add it with + from" +echo " -> enable 'Token Widget' (add it with + from" echo " ~/Library/Application Support/Token Meter/Claude Desktop/ if missing)." echo echo "Then focus a Claude Code session in Claude Desktop and the meter appears." diff --git a/test/claude-install-lifecycle.test.mjs b/test/claude-install-lifecycle.test.mjs index 5a1bcc4..f23cefd 100644 --- a/test/claude-install-lifecycle.test.mjs +++ b/test/claude-install-lifecycle.test.mjs @@ -98,7 +98,7 @@ test( const launchAgentsDirectory = path.join(directory, "agents"); const executable = path.join( installRoot, - "Token Widget for Claude.app", + "Token Widget.app", "Contents", "MacOS", "TokenMeterClaudeOverlay", diff --git a/test/claude-installer.test.mjs b/test/claude-installer.test.mjs index dc1ee54..a4b599a 100644 --- a/test/claude-installer.test.mjs +++ b/test/claude-installer.test.mjs @@ -68,7 +68,7 @@ test( ); assert.doesNotMatch(installResult.stderr, /grep:/i); - const app = path.join(installRoot, "Token Widget for Claude.app"); + const app = path.join(installRoot, "Token Widget.app"); await Promise.all([ access(path.join(app, "Contents", "MacOS", "TokenMeterClaudeOverlay")), access(path.join(installRoot, "src", "cli.mjs")), @@ -89,7 +89,7 @@ test( ), "utf8", ); - assert.match(plist, /Token Widget for Claude\.app/); + assert.match(plist, /Token Widget\.app/); assert.match(plist, new RegExp(nodePath.replaceAll("/", "\\/"))); assert.equal(existsSync(stateDirectory), true); @@ -140,3 +140,66 @@ test( assert.equal(existsSync(claudeExecutable), true); }, ); + +test( + "overlay installs on a Codex-only machine without Claude.app", + { skip: process.platform !== "darwin" }, + async (context) => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "token-meter-codex-install-"), + ); + context.after(() => rm(directory, { recursive: true, force: true })); + const installRoot = path.join(directory, "Application Support", "Codex Meter"); + const stateDirectory = path.join(directory, "Application Support", "State"); + const launchAgentsDirectory = path.join(directory, "LaunchAgents"); + const logDirectory = path.join(directory, "Logs"); + // Deliberately never created: this machine has no Claude.app. + const absentClaudeApp = path.join(directory, "Claude.app"); + const launchctl = path.join(directory, "launchctl"); + await writeFile(launchctl, "#!/bin/bash\nexit 1\n"); + await chmod(launchctl, 0o755); + const nodePath = existsSync("/opt/homebrew/bin/node") + ? "/opt/homebrew/bin/node" + : process.execPath; + + const installResult = await execFileAsync( + "/bin/bash", + [ + "integrations/claude-desktop/scripts/install.sh", + "--node", + nodePath, + "--claude-app", + absentClaudeApp, + "--no-load", + "--no-prompt", + ], + { + env: { + ...process.env, + TOKEN_METER_CLAUDE_INSTALL_ROOT: installRoot, + TOKEN_METER_CLAUDE_STATE_DIR: stateDirectory, + TOKEN_METER_LAUNCH_AGENTS_DIR: launchAgentsDirectory, + TOKEN_METER_CLAUDE_LOG_DIR: logDirectory, + TOKEN_METER_LAUNCHCTL: launchctl, + }, + }, + ); + // The install explains the Codex-only path rather than failing. + assert.match(installResult.stderr, /Codex support only/i); + + const app = path.join(installRoot, "Token Widget.app"); + await Promise.all([ + access(path.join(app, "Contents", "MacOS", "TokenMeterClaudeOverlay")), + access( + path.join( + installRoot, + "integrations", + "codex-desktop", + "src", + "snapshot-runtime.mjs", + ), + ), + ]); + assert.equal(existsSync(absentClaudeApp), false); + }, +); diff --git a/test/claude-launch-agent.test.mjs b/test/claude-launch-agent.test.mjs index 017bb58..e8dcaf8 100644 --- a/test/claude-launch-agent.test.mjs +++ b/test/claude-launch-agent.test.mjs @@ -15,7 +15,7 @@ test("Claude LaunchAgent starts only the native companion", async (context) => { context.after(() => rm(directory, { recursive: true, force: true })); const output = path.join(directory, "claude-meter.plist"); const root = path.join(directory, "Token & Meter"); - const app = path.join(root, "Token Widget for Claude.app"); + const app = path.join(root, "Token Widget.app"); await execFileAsync(process.execPath, [ "integrations/claude-desktop/scripts/render-launch-agent.mjs", @@ -52,7 +52,7 @@ test("Claude LaunchAgent starts only the native companion", async (context) => { test("Claude readiness requires an exact executable command boundary", async () => { const helper = "integrations/claude-desktop/scripts/process-identity.sh"; - const executable = "/Applications/Token Widget for Claude.app/Contents/MacOS/Overlay"; + const executable = "/Applications/Token Widget.app/Contents/MacOS/Overlay"; await execFileAsync("/bin/bash", [ "-c", 'source "$1"; token_meter_command_matches_executable "$2 --root /tmp" "$2"', diff --git a/test/claude-native-app-build.test.mjs b/test/claude-native-app-build.test.mjs index e97adf4..08c6c10 100644 --- a/test/claude-native-app-build.test.mjs +++ b/test/claude-native-app-build.test.mjs @@ -16,7 +16,7 @@ test( path.join(os.tmpdir(), "token-meter-claude-app-"), ); context.after(() => rm(directory, { recursive: true, force: true })); - const output = path.join(directory, "Token Widget for Claude.app"); + const output = path.join(directory, "Token Widget.app"); const builder = "integrations/claude-desktop/scripts/build-app.sh"; await chmod(builder, 0o755); await execFileAsync(builder, ["--output", output]); diff --git a/test/claude-overlay-bridge.test.mjs b/test/claude-overlay-bridge.test.mjs index 37f066a..d4bd1bc 100644 --- a/test/claude-overlay-bridge.test.mjs +++ b/test/claude-overlay-bridge.test.mjs @@ -71,7 +71,8 @@ test("Claude overlay bridge serves multiple snapshots in one process", async (co child.stderr.on("data", (chunk) => (stderr += chunk)); child.stdin.end( `${JSON.stringify({ requestId: 1, desktopSessionId })}\n` + - `${JSON.stringify({ requestId: 2, desktopSessionId: "invalid" })}\n`, + `${JSON.stringify({ requestId: 2, desktopSessionId: "invalid" })}\n` + + `${JSON.stringify({ requestId: 3, command: "global-snapshot" })}\n`, ); const exitCode = await new Promise((resolve, reject) => { child.once("error", reject); @@ -80,12 +81,20 @@ test("Claude overlay bridge serves multiple snapshots in one process", async (co assert.equal(exitCode, 0, stderr); const responses = stdout.trim().split("\n").map(JSON.parse); - assert.equal(responses.length, 2); + assert.equal(responses.length, 3); assert.equal(responses[0].requestId, 1); assert.equal(responses[0].snapshot.status, "bound"); assert.equal(responses[0].snapshot.session.totalTokens, 10); assert.equal(responses[1].requestId, 2); assert.equal(responses[1].snapshot.status, "unbound"); + // The machine-wide face for the desktop widget and non-Claude hosts: no + // session binding, identity plus usage-history totals only. + assert.equal(responses[2].requestId, 3); + assert.equal(responses[2].snapshot.status, "global"); + assert.equal(responses[2].snapshot.binding.exact, false); + assert.equal(typeof responses[2].snapshot.meterId, "string"); + assert.equal(typeof responses[2].snapshot.todayTokens, "number"); + assert.equal(typeof responses[2].snapshot.meterStats.lifetimeTokens, "number"); }); test("Claude overlay bridge asks the registry for a signed browser pairing URL", async () => { diff --git a/test/codex-overlay-bridge.test.mjs b/test/codex-overlay-bridge.test.mjs new file mode 100644 index 0000000..14d1fa7 --- /dev/null +++ b/test/codex-overlay-bridge.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; + +const THREAD_ID = "01a006f3-80e6-76d3-9f5b-c23d709d9d9c"; + +async function writeRollout(directory, threadId, totalTokens) { + await writeFile( + path.join(directory, `rollout-2026-08-15T12-43-36-${threadId}.jsonl`), + [ + { + timestamp: "2026-08-15T19:43:48.000Z", + type: "session_meta", + payload: { + id: threadId, + session_id: threadId, + source: "vscode", + thread_source: "user", + }, + }, + { + timestamp: "2026-08-15T19:43:53.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { total_token_usage: { total_tokens: totalTokens } }, + }, + }, + ] + .map((line) => JSON.stringify(line)) + .join("\n") + "\n", + ); +} + +function writeStateDb(dbPath, activeThreadId) { + const db = new DatabaseSync(dbPath); + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, name TEXT, title TEXT NOT NULL DEFAULT '', + tokens_used INTEGER NOT NULL DEFAULT 0, recency_at_ms INTEGER NOT NULL DEFAULT 0, + thread_source TEXT, archived INTEGER NOT NULL DEFAULT 0)`); + db.prepare( + `INSERT INTO threads (id, name, tokens_used, recency_at_ms, thread_source, archived) + VALUES (?, ?, ?, ?, 'user', 0)`, + ).run(activeThreadId, "token-meter", 21019, 9000); + db.close(); +} + +test("overlay bridge serves a bound Codex snapshot from the state DB", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "token-meter-codex-bridge-")); + context.after(() => rm(root, { recursive: true, force: true })); + const codexSessions = path.join(root, "codex-sessions"); + const claudeSessions = path.join(root, "claude-sessions"); + const claudeProjects = path.join(root, "claude-projects"); + const stateDb = path.join(root, "state_5.sqlite"); + await Promise.all([ + mkdir(codexSessions, { recursive: true }), + mkdir(claudeSessions, { recursive: true }), + mkdir(claudeProjects, { recursive: true }), + ]); + await writeRollout(codexSessions, THREAD_ID, 21019); + writeStateDb(stateDb, THREAD_ID); + + const child = spawn( + process.execPath, + [ + "integrations/claude-desktop/src/overlay-bridge.mjs", + "--sessions-dir", + claudeSessions, + "--projects-dir", + claudeProjects, + "--codex-sessions-dir", + codexSessions, + "--codex-state-db", + stateDb, + ], + { + cwd: path.resolve("."), + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, HOME: root, TOKEN_METER_REGISTRY_URL: "" }, + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.stdin.end(`${JSON.stringify({ requestId: 1, command: "codex-snapshot" })}\n`); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + + assert.equal(exitCode, 0, stderr); + // The SQLite experimental warning must be filtered out of the log stream. + assert.doesNotMatch(stderr, /ExperimentalWarning.*SQLite/i); + const responses = stdout.trim().split("\n").map((line) => JSON.parse(line)); + assert.equal(responses[0].requestId, 1); + assert.equal(responses[0].snapshot.status, "bound"); + assert.equal(responses[0].snapshot.binding.source, "codex-state-db"); + assert.equal(responses[0].snapshot.binding.exact, true); + assert.equal(responses[0].snapshot.binding.threadId, THREAD_ID); + assert.equal(responses[0].snapshot.session.totalTokens, 21019); +}); diff --git a/test/codex-snapshot-runtime.test.mjs b/test/codex-snapshot-runtime.test.mjs new file mode 100644 index 0000000..68e80d2 --- /dev/null +++ b/test/codex-snapshot-runtime.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { CodexSnapshotRuntime } from "../integrations/codex-desktop/src/snapshot-runtime.mjs"; + +const THREAD_ID = "01a006f3-80e6-76d3-9f5b-c23d709d9d9c"; + +async function writeRollout(directory, threadId, totalTokens) { + const filePath = path.join( + directory, + `rollout-2026-08-15T12-43-36-${threadId}.jsonl`, + ); + const lines = [ + { + timestamp: "2026-08-15T19:43:48.000Z", + type: "session_meta", + payload: { + id: threadId, + session_id: threadId, + source: "vscode", + thread_source: "user", + }, + }, + { + timestamp: "2026-08-15T19:43:53.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { total_tokens: totalTokens }, + last_token_usage: { total_tokens: totalTokens }, + model_context_window: 258400, + }, + }, + }, + ]; + await writeFile(filePath, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`); +} + +test("CodexSnapshotRuntime binds to the active thread and reports its tokens", async (context) => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codex-runtime-")); + context.after(() => rm(directory, { recursive: true, force: true })); + await writeRollout(directory, THREAD_ID, 21019); + + const runtime = new CodexSnapshotRuntime({ + sessionsDirectory: directory, + resolveActiveThread: () => ({ threadId: THREAD_ID, tokensUsed: 21019 }), + identity: null, + usageHistory: null, + now: () => Date.parse("2026-08-15T19:44:00.000Z"), + }); + + const snapshot = await runtime.snapshot(); + assert.equal(snapshot.status, "bound"); + assert.equal(snapshot.binding.source, "codex-state-db"); + assert.equal(snapshot.binding.exact, true); + assert.equal(snapshot.binding.threadId, THREAD_ID); + assert.equal(snapshot.usageMethod, "codex-rollout-raw"); + assert.equal(snapshot.session.totalTokens, 21019); +}); + +test("CodexSnapshotRuntime returns unbound (exact=false) when no thread is active", async (context) => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codex-runtime-")); + context.after(() => rm(directory, { recursive: true, force: true })); + + const runtime = new CodexSnapshotRuntime({ + sessionsDirectory: directory, + resolveActiveThread: () => null, + identity: null, + usageHistory: null, + }); + + const snapshot = await runtime.snapshot(); + assert.equal(snapshot.status, "unbound"); + assert.equal(snapshot.binding.exact, false); + assert.equal(snapshot.binding.source, "codex-state-db"); +}); + +test("CodexSnapshotRuntime never falls back to a different thread when the active one is absent", async (context) => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codex-runtime-")); + context.after(() => rm(directory, { recursive: true, force: true })); + // A rollout exists on disk, but for a DIFFERENT thread than the active one. + await writeRollout(directory, "ffffffff-0000-0000-0000-000000000000", 500); + + const runtime = new CodexSnapshotRuntime({ + sessionsDirectory: directory, + resolveActiveThread: () => ({ threadId: THREAD_ID }), + identity: null, + usageHistory: null, + }); + + const snapshot = await runtime.snapshot(); + // Fail closed: the selected thread has no telemetry, so it must not adopt + // the unrelated rollout's numbers. + assert.equal(snapshot.status, "unbound"); + assert.equal(snapshot.binding.exact, false); +}); diff --git a/test/codex-thread-state.test.mjs b/test/codex-thread-state.test.mjs new file mode 100644 index 0000000..5c04154 --- /dev/null +++ b/test/codex-thread-state.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; +import { + pickActiveThread, + readActiveCodexThread, +} from "../integrations/codex-desktop/src/thread-state.mjs"; + +test("pickActiveThread selects the most recent user thread and drops sub-agents", () => { + const active = pickActiveThread([ + { + id: "aaaaaaaa-0000-0000-0000-000000000000", + name: "gmail triage", + title: "gmail triage", + tokens_used: 100, + recency_at_ms: 1000, + thread_source: "user", + archived: 0, + }, + { + id: "bbbbbbbb-0000-0000-0000-000000000000", + name: "token-meter", + title: "token-meter", + tokens_used: 999, + recency_at_ms: 5000, + thread_source: "user", + archived: 0, + }, + { + id: "cccccccc-0000-0000-0000-000000000000", + name: "guardian child", + title: "guardian child", + tokens_used: 4242, + recency_at_ms: 9000, + thread_source: "subagent", + archived: 0, + }, + ]); + assert.equal(active.threadId, "bbbbbbbb-0000-0000-0000-000000000000"); + assert.equal(active.title, "token-meter"); + assert.equal(active.tokensUsed, 999); + assert.equal(active.recencyAtMs, 5000); +}); + +test("pickActiveThread ignores archived threads and returns null when none remain", () => { + assert.equal( + pickActiveThread([ + { + id: "aaaaaaaa-0000-0000-0000-000000000000", + name: "archived", + title: "archived", + tokens_used: 5, + recency_at_ms: 8000, + thread_source: "user", + archived: 1, + }, + ]), + null, + ); + assert.equal(pickActiveThread([]), null); + assert.equal(pickActiveThread(null), null); +}); + +test("pickActiveThread falls back to title when name is empty", () => { + const active = pickActiveThread([ + { + id: "aaaaaaaa-0000-0000-0000-000000000000", + name: "", + title: "derived from first message", + tokens_used: 0, + recency_at_ms: 1, + thread_source: "user", + archived: 0, + }, + ]); + assert.equal(active.title, "derived from first message"); +}); + +test("readActiveCodexThread reads the active thread from a live-shaped database", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "codex-state-")); + const dbPath = path.join(dir, "state_5.sqlite"); + try { + const db = new DatabaseSync(dbPath); + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, + name TEXT, + title TEXT NOT NULL DEFAULT '', + tokens_used INTEGER NOT NULL DEFAULT 0, + recency_at_ms INTEGER NOT NULL DEFAULT 0, + thread_source TEXT, + archived INTEGER NOT NULL DEFAULT 0 + )`); + const insert = db.prepare( + `INSERT INTO threads (id, name, title, tokens_used, recency_at_ms, thread_source, archived) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ); + insert.run("old", "old user", "old user", 10, 1000, "user", 0); + insert.run("current", "token-meter", "token-meter", 25712557, 9000, "user", 0); + insert.run("child", "sub", "sub", 5000, 9999, "subagent", 0); + db.close(); + + const active = readActiveCodexThread(dbPath); + assert.equal(active.threadId, "current"); + assert.equal(active.tokensUsed, 25712557); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("readActiveCodexThread fails closed to null on a missing database", () => { + assert.equal( + readActiveCodexThread("/nonexistent/path/state_5.sqlite"), + null, + ); +}); diff --git a/test/runtime-ui-layout.test.mjs b/test/runtime-ui-layout.test.mjs index cfec926..355f784 100644 --- a/test/runtime-ui-layout.test.mjs +++ b/test/runtime-ui-layout.test.mjs @@ -478,3 +478,79 @@ test("opening settings closes the stats view and shows the installed version", a "Token Widget v9.9.9 · v9.9.10 available", ); }); + +test("the global face renders machine-wide totals without a session", async () => { + const source = ( + await readFile(new URL("../runtime/token-meter-ui.js", import.meta.url), "utf8") + ).replace("__TOKEN_METER_CSS_JSON__", JSON.stringify("")); + const created = []; + const documentElement = new FakeElement("html"); + documentElement.isConnected = true; + const window = { + innerWidth: 1_200, + innerHeight: 800, + addEventListener() {}, + localStorage: { getItem() { return null; }, setItem() {} }, + webkit: { + messageHandlers: { + tokenMeterAction: { postMessage() {} }, + }, + }, + }; + const context = vm.createContext({ + document: { + createElement(tagName) { + const element = new FakeElement(tagName); + created.push(element); + return element; + }, + documentElement, + }, + window, + MutationObserver: class { + observe() {} + disconnect() {} + }, + performance: { now: () => 0 }, + requestAnimationFrame() {}, + clearTimeout() {}, + setTimeout() {}, + }); + + vm.runInContext(source, context); + const card = created.find((element) => element.tagName === "section"); + const sessionId = card.querySelector(".session-id"); + const dayLabel = card.querySelector(".day-label"); + const dayTotal = card.querySelector(".day-total"); + const lifetime = card.querySelector(".lifetime"); + const streak = card.querySelector(".streak"); + const rate = card.querySelector(".rate"); + const unbound = card.querySelector(".unbound"); + + window.__tokenMeter.update({ + status: "global", + binding: { exact: false }, + meterId: "TM-TEST-0000-0000", + meterHandle: "chandler", + sharingEnabled: false, + todayTokens: 500, + meterStats: { lifetimeTokens: 2_000_000, currentStreakDays: 3 }, + appVersion: "9.9.9", + }); + + assert.equal(card.dataset.mode, "global"); + assert.equal(unbound.hidden, true); + assert.equal(sessionId.textContent, "@chandler"); + assert.equal(dayLabel.textContent, "TODAY"); + assert.equal(dayTotal.textContent, "500"); + assert.equal(lifetime.textContent, "2.000M"); + assert.equal(streak.textContent, "3 days"); + assert.equal(rate.textContent, "Idle"); + + // Losing the bridge falls back to the honest unknown-session face. + window.__tokenMeter.update({ status: "unbound", binding: { exact: false } }); + assert.equal(card.dataset.mode, "unbound"); + assert.equal(unbound.hidden, false); + assert.equal(dayLabel.textContent, "24H TOTAL"); + assert.equal(sessionId.textContent, "UNBOUND"); +}); diff --git a/web/install.sh b/web/install.sh index b722488..bf76ee7 100644 --- a/web/install.sh +++ b/web/install.sh @@ -32,5 +32,5 @@ npm run claude:install echo bold "Installed." echo "One-time step: System Settings > Privacy & Security > Accessibility" -echo " -> enable 'Token Widget for Claude'." +echo " -> enable 'Token Widget'." echo "Then focus a Claude Code session and the meter appears."