From 0496dc7a16d4836fec34a3f7ce63212a18398730 Mon Sep 17 00:00:00 2001 From: Chandler Fang Date: Sat, 15 Aug 2026 21:36:48 -0700 Subject: [PATCH 1/7] feat(desktop): always-on widget with machine-wide global face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay no longer lives only inside a bound Claude Desktop session. When no exact session binding exists, the widget can now show a "global" face with machine-wide totals across Claude Code, Codex, and Cline. - overlay-bridge: new global-snapshot command returning identity plus usage-history totals (today, lifetime, streak), memoized for 60s - runtime UI: data-mode=global face — TODAY label, identity header, lifetime/streak from meterStats, idle gauge; settings identity rendering shared between session and global faces - native overlay: menu-bar StatusBarController with show/always-visible toggles persisted to visibility.json, LaunchAgent bootout on quit so KeepAlive stops resurrecting the app, and Codex window following via codexBundleID Tests: 145/145 pass (node --test). Co-Authored-By: Claude Fable 5 --- .../native/TokenMeterClaudeOverlay.swift | 403 +++++++++++++++--- .../claude-desktop/src/overlay-bridge.mjs | 55 +++ runtime/token-meter-ui.js | 89 ++-- test/claude-overlay-bridge.test.mjs | 13 +- test/runtime-ui-layout.test.mjs | 76 ++++ 5 files changed, 550 insertions(+), 86 deletions(-) diff --git a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift index 84cefef..6a42a4b 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 @@ -209,6 +211,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 +596,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 +613,11 @@ 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 dragTimer: Timer? private var dragStartMouse = CGPoint.zero private var dragStartPanel = CGPoint.zero @@ -484,9 +626,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 +732,62 @@ 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 Claude (no session) or Codex window gets + // the machine-wide face pinned to that window; with neither 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) { + showGlobalFace(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 + health.update(sessionBound: false) + } + + private func showSessionFace( + surface: ClaudeCodeSurface, hostPosition: CGPoint, hostSize: CGSize + ) { let identifier = surface.sessionID - positionPanel(hostPosition: position, hostSize: size) + if showingGlobalFace { + showingGlobalFace = false + publishUnbound() + } + positionPanel(hostPosition: hostPosition, hostSize: hostSize) panel.orderFrontRegardless() if identifier != currentSessionID { @@ -638,7 +811,50 @@ 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 || !showingGlobalFace { + currentSessionID = nil + 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))") + } + } + private func positionPanel(hostPosition: CGPoint, hostSize: CGSize) { + desktopPositioned = false lastHostPosition = hostPosition lastHostSize = hostSize let hostCenter = CGPoint( @@ -675,6 +891,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 +1024,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 +1076,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 +1095,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 +1121,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 +1358,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 +1403,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 +1454,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/src/overlay-bridge.mjs b/integrations/claude-desktop/src/overlay-bridge.mjs index 44003c5..79f705e 100644 --- a/integrations/claude-desktop/src/overlay-bridge.mjs +++ b/integrations/claude-desktop/src/overlay-bridge.mjs @@ -115,6 +115,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 ?? @@ -183,6 +231,13 @@ 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 === "update-info") { await writeLine( updateInfo 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/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/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"); +}); From 9f70eeb5b89f0b0a42873d7971e99c86bf4b003d Mon Sep 17 00:00:00 2001 From: Chandler Fang Date: Sat, 15 Aug 2026 22:49:12 -0700 Subject: [PATCH 2/7] feat(codex): CDP-free snapshot runtime sourced from state DB + rollouts First slice of the Codex native-overlay migration (option 1). Resolves the active Codex thread without CDP by reading the desktop app-server's own state_5.sqlite (threads.recency_at_ms, filtered to thread_source='user'), then reuses the existing RolloutStore + MetricsEngine for token telemetry. - thread-state.mjs: read-only state DB reader, fails closed to null on a missing file, locked handle, or schema drift so a caller never binds to the wrong thread - snapshot-runtime.mjs: CodexSnapshotRuntime mirroring ClaudeSnapshotRuntime's interface; binding.exact reflects a proven bind, and a selected thread with no telemetry stays unbound rather than adopting another thread's numbers Verified: Token Widget's bundled node (v22.22) reads state_5 via node:sqlite with no flag. Tests 188/188, npm run check clean. Co-Authored-By: Claude Fable 5 --- .../codex-desktop/src/snapshot-runtime.mjs | 125 ++++++++++++++++++ .../codex-desktop/src/thread-state.mjs | 81 ++++++++++++ test/codex-snapshot-runtime.test.mjs | 99 ++++++++++++++ test/codex-thread-state.test.mjs | 118 +++++++++++++++++ 4 files changed, 423 insertions(+) create mode 100644 integrations/codex-desktop/src/snapshot-runtime.mjs create mode 100644 integrations/codex-desktop/src/thread-state.mjs create mode 100644 test/codex-snapshot-runtime.test.mjs create mode 100644 test/codex-thread-state.test.mjs diff --git a/integrations/codex-desktop/src/snapshot-runtime.mjs b/integrations/codex-desktop/src/snapshot-runtime.mjs new file mode 100644 index 0000000..c40c91f --- /dev/null +++ b/integrations/codex-desktop/src/snapshot-runtime.mjs @@ -0,0 +1,125 @@ +// 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, + // Injected in tests; in production the active thread is read from the + // Codex state database each snapshot. + resolveActiveThread = 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/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, + ); +}); From bbb43102f67dbbfbb5f8c20b4722cacc92e4421d Mon Sep 17 00:00:00 2001 From: Chandler Fang Date: Sat, 15 Aug 2026 22:57:41 -0700 Subject: [PATCH 3/7] feat(codex): serve bound Codex snapshots from the overlay bridge Extend the overlay bridge with a codex-snapshot command so one overlay app and one bridge serve both hosts. The Codex runtime is built lazily, so a Claude-only session never opens the state database or scans ~/.codex. Filter node:sqlite's experimental warning out of the bridge's stderr so it does not repeat into the LaunchAgent log on every start. Tests 189/189, npm run check clean. Co-Authored-By: Claude Fable 5 --- .../claude-desktop/src/overlay-bridge.mjs | 36 ++++++ .../codex-desktop/src/snapshot-runtime.mjs | 6 +- test/codex-overlay-bridge.test.mjs | 108 ++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 test/codex-overlay-bridge.test.mjs diff --git a/integrations/claude-desktop/src/overlay-bridge.mjs b/integrations/claude-desktop/src/overlay-bridge.mjs index 79f705e..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}`); } @@ -177,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, @@ -238,6 +267,13 @@ for await (const line of input) { 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/src/snapshot-runtime.mjs b/integrations/codex-desktop/src/snapshot-runtime.mjs index c40c91f..452f22a 100644 --- a/integrations/codex-desktop/src/snapshot-runtime.mjs +++ b/integrations/codex-desktop/src/snapshot-runtime.mjs @@ -21,9 +21,13 @@ export class CodexSnapshotRuntime { 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 = readActiveCodexThread, + resolveActiveThread = stateDatabasePath + ? () => readActiveCodexThread(stateDatabasePath) + : readActiveCodexThread, identity = undefined, identityDir = undefined, usageHistory = undefined, 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); +}); From b4dac28f62564b275e9e593e777dd2c7c49b73b7 Mon Sep 17 00:00:00 2001 From: Chandler Fang Date: Sat, 15 Aug 2026 23:00:02 -0700 Subject: [PATCH 4/7] feat(codex): render a bound Codex face in the native overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A frontmost Codex window now gets a bound face driven by the codex-snapshot bridge command instead of the machine-wide global face. The bridge resolves the active thread from the state database, so the native side supplies no session id and reads no Accessibility tree — it only pins the panel and polls. Adds a third overlay mode alongside the Claude session and global faces, with the mode transitions resetting each other so no stale numbers survive a host switch. swiftc -typecheck clean; JS suite 189/189. Co-Authored-By: Claude Fable 5 --- .../native/TokenMeterClaudeOverlay.swift | 62 +++++++++++++++++-- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift index 6a42a4b..e1c6eab 100644 --- a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift +++ b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift @@ -618,6 +618,9 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes 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 @@ -737,9 +740,11 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes return } // A frontmost Claude window with a Claude Code session gets the bound - // session face; a frontmost Claude (no session) or Codex window gets - // the machine-wide face pinned to that window; with neither in front, - // the machine-wide face parks on the desktop when the user wants it. + // 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) { @@ -753,7 +758,7 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes if let window = frontmostHostWindow(bundleID: codexBundleID), let position = axPoint(window, kAXPositionAttribute), let size = axSize(window, kAXSizeAttribute) { - showGlobalFace(hostPosition: position, hostSize: size) + showCodexFace(hostPosition: position, hostSize: size) return } if preferences.alwaysVisible { @@ -775,6 +780,7 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes panel.orderOut(nil) currentSessionID = nil showingGlobalFace = false + showingCodexFace = false health.update(sessionBound: false) } @@ -783,8 +789,9 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes ) { let identifier = surface.sessionID - if showingGlobalFace { + if showingGlobalFace || showingCodexFace { showingGlobalFace = false + showingCodexFace = false publishUnbound() } positionPanel(hostPosition: hostPosition, hostSize: hostSize) @@ -814,8 +821,9 @@ 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 || !showingGlobalFace { + if currentSessionID != nil || showingCodexFace || !showingGlobalFace { currentSessionID = nil + showingCodexFace = false visibleContextWindowTokens = nil contextScanCadence.reset() lastGlobalSnapshotAt = .distantPast @@ -853,6 +861,48 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes } } + // 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 From cf4caf474d58176586f5b51b8fc6c80a072c9af7 Mon Sep 17 00:00:00 2001 From: Chandler Fang Date: Sun, 16 Aug 2026 09:37:43 -0700 Subject: [PATCH 5/7] feat(codex): let the overlay install and run without Claude.app Make the shared overlay host-agnostic so Codex-only machines can install it: - drop the Claude model catalog (app.asar) from the overlay's required-files startup gate; ClaudeContextWindowResolver already degrades to nil when it is absent, and Codex context windows come from the rollout snapshot - install.sh verifies Claude.app only when present (preserving the genuine-app check) and installs Codex-only support when it is absent, instead of exiting Adds a regression test installing the overlay with no Claude.app. Suite 190/190, npm run check clean, swiftc -typecheck clean. Co-Authored-By: Claude Fable 5 --- .../native/TokenMeterClaudeOverlay.swift | 5 +- .../claude-desktop/scripts/install.sh | 14 +++-- test/claude-installer.test.mjs | 63 +++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift index e1c6eab..d785d24 100644 --- a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift +++ b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift @@ -185,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"), @@ -192,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)") diff --git a/integrations/claude-desktop/scripts/install.sh b/integrations/claude-desktop/scripts/install.sh index 7cbb424..2ddd55a 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 \ diff --git a/test/claude-installer.test.mjs b/test/claude-installer.test.mjs index dc1ee54..ad22057 100644 --- a/test/claude-installer.test.mjs +++ b/test/claude-installer.test.mjs @@ -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 for Claude.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); + }, +); From c5dea0783a4e23d9e19a86f55d97e04c45a726a9 Mon Sep 17 00:00:00 2001 From: Chandler Fang Date: Sun, 16 Aug 2026 09:53:07 -0700 Subject: [PATCH 6/7] refactor: rename the overlay app to "Token Widget" Now that the overlay serves both Claude and Codex, drop "for Claude" from the user-visible name. Sets CFBundleName to "Token Widget" (the display name shown in the Accessibility list and menu bar, previously "Token Widget for Claude"), renames the source-install bundle to "Token Widget.app" to match the DMG, and updates the permission-grant instructions in docs and scripts to suit. Bundle identifier, executable name, and LaunchAgent label are left unchanged so existing installs and the self-update flow keep working. "Token Widget for Claude Code" (the integration name) is preserved. Suite 190/190, check + swiftc clean, plist lints. Co-Authored-By: Claude Fable 5 --- INSTALL_WITH_AGENT.md | 2 +- README.md | 2 +- SECURITY.md | 4 ++-- docs/architecture.md | 2 +- docs/claude-code.md | 2 +- docs/install-claude-desktop.md | 10 +++++----- integrations/claude-desktop/native/Info.plist | 2 +- integrations/claude-desktop/scripts/install.sh | 8 ++++---- integrations/claude-desktop/scripts/status.sh | 2 +- scripts/package-macos.sh | 2 +- test/claude-install-lifecycle.test.mjs | 2 +- test/claude-installer.test.mjs | 6 +++--- test/claude-launch-agent.test.mjs | 4 ++-- test/claude-native-app-build.test.mjs | 2 +- web/install.sh | 2 +- 15 files changed, 26 insertions(+), 26 deletions(-) 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..1d32991 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,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/scripts/install.sh b/integrations/claude-desktop/scripts/install.sh index 2ddd55a..0a78116 100755 --- a/integrations/claude-desktop/scripts/install.sh +++ b/integrations/claude-desktop/scripts/install.sh @@ -175,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" \ @@ -217,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 @@ -305,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/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 ad22057..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); @@ -187,7 +187,7 @@ test( // 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 for Claude.app"); + const app = path.join(installRoot, "Token Widget.app"); await Promise.all([ access(path.join(app, "Contents", "MacOS", "TokenMeterClaudeOverlay")), access( 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/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." From 3d3a3934ed689ee72562b5d2b21320e3c30af1c4 Mon Sep 17 00:00:00 2001 From: Chandler Fang Date: Sun, 16 Aug 2026 09:55:08 -0700 Subject: [PATCH 7/7] docs(codex): deprecate the CDP adapter in favor of the native overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CDP source installer now prints a deprecation notice pointing users at the Token Widget app (silenceable via TOKEN_METER_CODEX_CDP_ACK=1) and still runs. README, the Codex integration README, and CHANGELOG present the native overlay as the recommended way to meter Codex and mark the adapter as legacy. The CDP code is left in place and functional — deprecate first, remove later, once the native path has shipped and proven itself against Codex's state schema. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++++++++++ README.md | 11 +++++++++-- integrations/codex-desktop/README.md | 7 +++++++ scripts/install-token-meter-macos.sh | 16 ++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) 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/README.md b/README.md index 1d32991..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 ``` 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/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