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."