diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..7203b07b --- /dev/null +++ b/Makefile @@ -0,0 +1,93 @@ +# CodeIsland — convenience targets wrapping build.sh and scripts/dev-hot-restart.sh. +# +# Common usage: +# make # = make build (release .app bundle, signed) +# make debug # swift build (debug, fastest incremental) +# make run # quit any running app, then launch the debug binary +# make restart # = debug + run +# make hot # file-watch loop: rebuild + relaunch on source change +# make release # release .app bundle, no notarization +# make dmg # release bundle + notarized DMG (needs SIGN_ID / keychain) +# make clean # swift package clean + remove .build +# make test # swift test +# +# Override vars from the command line, e.g.: +# make run APP_PATH=/Applications/CodeIsland.app +# make release SIGN_ID="Developer ID Application: ..." +# +# If Xcode.app is installed, use its toolchain even if xcode-select points at CLT. +ifneq ($(wildcard /Applications/Xcode.app/Contents/Developer),) +export DEVELOPER_DIR := /Applications/Xcode.app/Contents/Developer +endif + +APP_NAME := CodeIsland +DEBUG_BIN := .build/debug/$(APP_NAME) +RELEASE_APP := .build/release/$(APP_NAME).app +# Allow overriding the app path to launch (e.g. /Applications/CodeIsland.app). +APP_PATH ?= $(DEBUG_BIN) +BUILD_CONFIG ?= debug + +.DEFAULT_GOAL := build + +.PHONY: build debug release run restart relaunch hot test clean help + +# --- Build ----------------------------------------------------------------- + +# Default: build the signed release .app bundle (same as ./build.sh). +build: + ./build.sh + +# Fastest incremental build — just swift build, no bundling or signing. +debug: + swift build + +# Release .app bundle without notarization. +release: + ./build.sh + +# Release .app bundle + notarized DMG. Requires a Developer ID and the +# "CodeIsland" notarytool keychain profile. Pass SIGN_ID to pick the identity. +dmg: + ./build.sh --notarize + +# --- Run / Restart --------------------------------------------------------- + +# Quit any running CodeIsland process and launch $(APP_PATH). +# Accepts a signed .app bundle OR a bare executable (the latter warns that +# Buddy Bluetooth entitlements won't be available). +run: + @bin/run-quit-launch.sh "$(APP_PATH)" + +# Build debug, then run. +restart: debug run + +# Alias matching the dev-hot-restart.sh script's vocabulary. +relaunch: restart + +# File-watch loop: rebuild + relaunch on source change. +hot: + scripts/dev-hot-restart.sh $(HOT_ARGS) + +# --- Misc ------------------------------------------------------------------ + +test: + swift test + +clean: + swift package clean + @rm -rf .build + +help: + @printf '%s\n' \ + 'CodeIsland Makefile — common targets:' \ + ' make build (release .app bundle, signed — default)' \ + ' make debug swift build (debug, fastest)' \ + ' make run quit running app, launch APP_PATH ($(APP_PATH))' \ + ' make restart debug + run' \ + ' make hot file-watch: rebuild + relaunch on source change' \ + ' make release release .app bundle (no notarization)' \ + ' make dmg release bundle + notarized DMG (needs SIGN_ID)' \ + ' make test swift test' \ + ' make clean swift package clean + remove .build' \ + '' \ + 'Overrides: APP_PATH= SIGN_ID=' diff --git a/README.md b/README.md index 7af69dc2..49529193 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,62 @@ --- +## What's New in This Fork + +### Token Usage Dashboard (Settings → Usage) + +A full **per-AI token usage breakdown** showing input, output, cache, and message counts across all four AI sources: + +- **Claude Code** — scans `~/.claude/projects/**/*.jsonl` +- **OMP (Oh My Pi)** — scans `~/.omp/agent/sessions/**/*.jsonl` +- **Codex** — scans `~/.codex/sessions/**/*.jsonl` (uses `last_token_usage` delta, not cumulative) +- **Cursor** — extracts usage from Chrome session cookie via the Cursor CSV export API + +#### Features + +- **14-day summary** — total input/output/cache/messages across all sources +- **Per-AI breakdown** — each source with its mascot icon, brand color, and progress bar +- **Daily usage chart** — 14-day bar chart with per-day token counts +- **Last 12 hours sparkline** — hourly output tokens with hour-of-day labels +- **Billion (B) formatting** — large token counts display as 1.1B, 344M, etc. +- **Notch footer rotation** — cycles through each AI source every 60 seconds showing 5h/today totals +- **Footer click → Settings → Usage** — click the notch footer to open the full usage page + +#### Cursor Setup (Settings → Usage → Cursor Usage Setup) + +Cursor doesn't store token counts locally. CodeIsland extracts them via: + +- **Manual cookie extraction** — paste `WorkosCursorSessionToken` from DevTools → Application → Cookies, fetch CSV via API +- **Manual CSV extraction** — download CSV from cursor.com export API, import the file +- **Cookie saved in macOS Keychain** — secure storage, no plaintext files, `kSecAttrAccessibleAfterFirstUnlock` +- **Auto-refresh** — toggle + interval picker (1h/3h/6h/12h/24h), uses saved cookie, no Keychain prompt +- **URL decoding** — handles `%3A%3A` → `::` in cookie values from DevTools +- **Privacy** — all data stays on your Mac, nothing shared, no telemetry + +### Session Management + +- **Right-click rename** on session cards — context menu with Rename / Reset Name / Star +- **Rename freeze** — session list freezes during rename to prevent TextField losing focus +- **Custom names** stored in UserDefaults +- **Star/favorite** sessions — gold highlight, stable sort by startTime +- **`$ Ready` status** — shows in green when session is idle +- **Cursor multi-window fix** — `cursor -r --reuse-window ` CLI for reliable window jumping + +### Extension & Proxy + +- **OMP bridge proxy** and **Codex proxy** — auto-installed via LaunchAgents (RunAtLoad + KeepAlive) +- **Active-only tracking** — sessions removed after 5 min of inactivity +- **Extension timeout fix** — narrowed dangerous patterns to only `rm -rf /`, `rm -rf ~`, `rm -rf $HOME` (safe `rm -rf .build` passes through) +- **20s bridge timeout** with 5s hard outer timeout on socket sends +- **CODEISLAND_DEBUG=1** environment variable for extension logging + +### Architecture + +- **Protocol-based usage scanners** — `UsageScanner` protocol with one struct per source (`ClaudeCodeScanner`, `OMPScanner`, `CodexScanner`, `CursorScanner`), coordinated by `UsageScannerCoordinator` +- **Codex cumulative token fix** — uses `last_token_usage` (delta) not `total_token_usage` (cumulative), cache invalidated on each scan +- **Cursor cookie extraction** — Chrome Safe Storage Keychain + PBKDF2-HMAC-SHA1 + AES-128-CBC decrypt (same technique as `pycookiecheat`/spinnaker-mcp), reimplemented in Swift with `CommonCrypto` + +

CodeIsland Panel Preview

diff --git a/Sources/CodeIsland/AppDelegate.swift b/Sources/CodeIsland/AppDelegate.swift index 15b13d63..12be84fd 100644 --- a/Sources/CodeIsland/AppDelegate.swift +++ b/Sources/CodeIsland/AppDelegate.swift @@ -28,6 +28,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { // hooks get no response and Claude Code denies them. hookServer = HookServer(appState: appState) hookServer?.start() + + // Start keyboard activity monitor (used by SoundManager to suppress + // notification sounds while the user is typing, if enabled in Settings). + KeyboardActivityMonitor.shared.start() RemoteManager.shared.onDisconnect = { [weak appState] hostId in appState?.removeRemoteSessions(hostId: hostId) } @@ -167,7 +171,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { RemoteManager.shared.shutdown() hookServer?.stop() appState.stopCodexAppServerWatcher() - appState.stopSessionDiscovery() + KeyboardActivityMonitor.shared.stop() } // MARK: - Global Shortcuts diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index d3715c7f..ef33c0aa 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -214,6 +214,81 @@ final class AppState { guard let rid = rotatingSessionId else { return nil } return sessions[rid] } + + // MARK: - Favorites (starred sessions) + + /// Favorite session IDs persisted in UserDefaults. Favorited sessions sort to the + /// top of the session list and get a highlighted card. `@Observable` doesn't support + /// `@AppStorage`, so we read/write UserDefaults manually and bump a published counter + /// to trigger SwiftUI updates. + private let favoritesKey = "favoriteSessionIds" + private(set) var favoritesRevision: Int = 0 + + var favoriteSessionIds: Set { + let raw = UserDefaults.standard.string(forKey: favoritesKey) ?? "" + return Set(raw.split(separator: ",").map(String.init).filter { !$0.isEmpty }) + } + + func isFavorite(_ sessionId: String) -> Bool { + favoriteSessionIds.contains(sessionId) + } + + func toggleFavorite(_ sessionId: String) { + var ids = favoriteSessionIds + if ids.contains(sessionId) { + ids.remove(sessionId) + } else { + ids.insert(sessionId) + } + UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: favoritesKey) + favoritesRevision &+= 1 // trigger @Observable update + } + + + // MARK: - Custom session display names + + private let customNamesKey = "customSessionNames" + private(set) var customNamesRevision: Int = 0 + var renamingSessionId: String? + + func customDisplayName(for sessionId: String) -> String? { + guard let raw = UserDefaults.standard.string(forKey: customNamesKey) else { return nil } + let pairs = raw.split(separator: "\n").compactMap { line -> (String, String)? in + let parts = line.split(separator: "\t", maxSplits: 1) + guard parts.count == 2 else { return nil } + return (String(parts[0]), String(parts[1])) + } + return pairs.first { $0.0 == sessionId }?.1 + } + + func setCustomDisplayName(_ name: String?, for sessionId: String) { + let current = UserDefaults.standard.string(forKey: customNamesKey) ?? "" + var pairs = current.split(separator: "\n").compactMap { line -> (String, String)? in + let parts = line.split(separator: "\t", maxSplits: 1) + guard parts.count == 2 else { return nil } + return (String(parts[0]), String(parts[1])) + } + pairs.removeAll { $0.0 == sessionId } + if let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + pairs.append((sessionId, name.trimmingCharacters(in: .whitespacesAndNewlines))) + } + let serialized = pairs.map { "\($0.0)\t\($0.1)" }.joined(separator: "\n") + UserDefaults.standard.set(serialized, forKey: customNamesKey) + customNamesRevision &+= 1 + } + /// Mark a session as "read" — clears its working status to idle so it + /// stops showing as active. Called when the user clicks the card to + /// acknowledge they've seen the response. + func markAsRead(_ sessionId: String) { + guard let session = sessions[sessionId] else { return } + // Only clear if not actively waiting for approval/question + if session.status == .waitingApproval || session.status == .waitingQuestion { return } + sessions[sessionId]?.status = .idle + sessions[sessionId]?.currentTool = nil + sessions[sessionId]?.toolDescription = nil + refreshDerivedState() + scheduleSave() + } @ObservationIgnored nonisolated(unsafe) private var rotationTimer: Timer? @@ -347,9 +422,13 @@ final class AppState { } // 4. Remove idle sessions past timeout (user setting, or 10 min default for no-monitor sessions) + // Favorite (starred) sessions are never removed — they stay visible even + // when idle so the user remembers to go back to them. let userTimeout = SettingsManager.shared.sessionTimeout let defaultStaleMinutes = 10 // for sessions without process monitor + let favorites = favoriteSessionIds for (key, session) in sessions where session.status == .idle { + guard !favorites.contains(key) else { continue } // keep starred sessions let idleMinutes = Int(-session.lastActivity.timeIntervalSinceNow / 60) let hasMonitor = processMonitors[key] != nil if userTimeout > 0 && idleMinutes >= userTimeout { @@ -617,7 +696,18 @@ final class AppState { /// Remove a session, clean up its monitor, and resume any pending continuations. /// Every removal path (cleanup timer, process exit, reducer effect) goes through here /// so leaked continuations / connections are impossible. - private func removeSession(_ sessionId: String) { + private func removeSession(_ sessionId: String, force: Bool = false) { + // Favorite (starred) sessions are never removed unless explicitly forced. + // They stay visible even when the process exits, so the user remembers + // to go back to them. The session will just show as idle/sleeping. + if !force && favoriteSessionIds.contains(sessionId) { + log.notice("Keeping favorite session \(sessionId, privacy: .public) — marking idle instead of removing") + sessions[sessionId]?.status = .idle + sessions[sessionId]?.currentTool = nil + sessions[sessionId]?.toolDescription = nil + stopMonitor(sessionId) + return + } // Resume ALL pending continuations for this session drainPermissions(forSession: sessionId, reason: "removeSession") drainQuestions(forSession: sessionId, reason: "removeSession") @@ -885,6 +975,23 @@ final class AppState { } } + /// Force a usage scan regardless of showUsageStats toggle (for Settings Usage page). + func scanClaudeUsage() { + guard !usageScanInFlight else { return } + if let scannedAt = claudeUsage?.scannedAt, Date().timeIntervalSince(scannedAt) < 10 { return } + usageScanInFlight = true + let cacheCopy = usageFileCache + Task.detached(priority: .utility) { + var cache = cacheCopy + let snapshot = ClaudeUsageScanner.scan(cache: &cache) + await MainActor.run { [weak self] in + self?.claudeUsage = snapshot + self?.usageFileCache = cache + self?.usageScanInFlight = false + } + } + } + /// Last unresolved-branch probe per session — keeps `gitBranch == nil` /// (non-repo cwds, SessionStart snapshot rebuilds) from probing on every event. private var gitBranchCheckedAt: [String: Date] = [:] @@ -937,6 +1044,34 @@ final class AppState { return !isTerminalFrontmost(session) } + /// True if the panel should be deferred because the user is typing. + /// When enabled, approval/question popups wait until 5s after the last + /// keystroke so the user isn't interrupted mid-sentence. + var isDeferringForTyping: Bool { + guard UserDefaults.standard.bool(forKey: SettingsKey.awaitFinishTyping) else { return false } + return KeyboardActivityMonitor.shared.isTyping + } + + /// Retry timer: when a permission/question is deferred because the user + /// is typing, re-check every 1s. Once the user stops typing for 5s, + /// showNextPending() fires and opens the panel. + private var typingDeferRetryTask: Task? + + func scheduleTypingDeferRetry() { + guard isDeferringForTyping else { return } + typingDeferRetryTask?.cancel() + typingDeferRetryTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(500)) + guard let self, !Task.isCancelled else { return } + if !self.isDeferringForTyping { + _ = self.showNextPending() + return + } + } + } + } + private func shouldAutoOpenQuestionSurface(for event: HookEvent) -> Bool { // AskUserQuestion holds the provider/CLI until its continuation resolves, // so there is no parallel terminal prompt for Smart Suppress to defer to. @@ -1225,7 +1360,7 @@ final class AppState { session.remoteHostId == hostId ? key : nil } for id in ids { - removeSession(id) + removeSession(id, force: true) } refreshDerivedState() } @@ -1317,7 +1452,10 @@ final class AppState { activeSessionId = sessionId // If user is already browsing the session list, keep them there and // let inline controls handle approval without stealing focus. - if surface != .sessionList, shouldAutoOpenPendingSurface(for: sessionId) { + if isDeferringForTyping { + // Defer opening the panel until the user stops typing. + scheduleTypingDeferRetry() + } else if surface != .sessionList, shouldAutoOpenPendingSurface(for: sessionId) { surface = .approvalCard(sessionId: sessionId) } SoundManager.shared.handleEvent("PermissionRequest") @@ -1970,6 +2108,14 @@ final class AppState { /// After dequeuing, show next pending item or collapse @discardableResult func showNextPending() -> Bool { + // Defer showing approval/question popups while the user is typing. + // Schedule a retry so the panel opens once the user stops (5s after + // the last keystroke). + if isDeferringForTyping, + !permissionQueue.isEmpty || !questionQueue.isEmpty { + scheduleTypingDeferRetry() + return false + } if let idx = nextVisiblePermissionIndex() { let next = permissionQueue.remove(at: idx) permissionQueue.insert(next, at: 0) diff --git a/Sources/CodeIsland/ConfigInstaller.swift b/Sources/CodeIsland/ConfigInstaller.swift index b049a70d..b1d14f5e 100644 --- a/Sources/CodeIsland/ConfigInstaller.swift +++ b/Sources/CodeIsland/ConfigInstaller.swift @@ -172,6 +172,18 @@ struct ConfigInstaller { private static let openclawDir = NSHomeDirectory() + "/.openclaw" private static let openclawPluginDir = NSHomeDirectory() + "/.openclaw/codeisland-plugin" private static let openclawConfigPath = NSHomeDirectory() + "/.openclaw/openclaw.json" + // Codex desktop-app proxy: the ChatGPT app's app-server doesn't fire hooks, + // so a LaunchAgent polls the state DB and forwards events to the bridge. + private static let codexProxyPath = codeislandDir + "/codex-proxy.py" + private static let codexProxyPlistPath = NSHomeDirectory() + "/Library/LaunchAgents/com.codeisland.codex-proxy.plist" + private static let codexProxyLogPath = codeislandDir + "/codex-proxy.log" + private static let codexProxyLabel = "com.codeisland.codex-proxy" + // OMP proxy: sessions started before extension install don't get native hooks, + // so a LaunchAgent polls JSONL transcripts and forwards events to the bridge. + private static let ompProxyPath = codeislandDir + "/omp-proxy.py" + private static let ompProxyPlistPath = NSHomeDirectory() + "/Library/LaunchAgents/com.codeisland.omp-proxy.plist" + private static let ompProxyLogPath = codeislandDir + "/omp-proxy.log" + private static let ompProxyLabel = "com.codeisland.omp-proxy" // Legacy paths for migration cleanup (#32) @@ -887,6 +899,9 @@ struct ConfigInstaller { if isEnabled(source: "codex"), fm.fileExists(atPath: codexHome()) { enableCodexHooksConfig(fm: fm) + // The ChatGPT desktop app's app-server doesn't fire hooks. + // Install a LaunchAgent proxy that polls the state DB. + installCodexProxy(fm: fm) } // Install OpenCode plugin @@ -902,6 +917,8 @@ struct ConfigInstaller { // Install Oh My Pi / OMP extension if isEnabled(source: "omp") { if !installOmpExtension(fm: fm) { ok = false } + // Install OMP proxy for sessions started before the extension + installOmpProxy(fm: fm) } // Install OpenClaw plugin @@ -931,6 +948,7 @@ struct ConfigInstaller { uninstallPiExtension(fm: fm) } else if cli.source == "omp" { uninstallOmpExtension(fm: fm) + uninstallOmpProxy(fm: fm) } else if cli.source == "openclaw" { uninstallOpenclawPlugin(fm: fm) } else { @@ -939,6 +957,7 @@ struct ConfigInstaller { } uninstallOpencodePlugin(fm: fm) + uninstallCodexProxy(fm: fm) } /// Check if Claude Code hooks are installed @@ -1031,7 +1050,7 @@ struct ConfigInstaller { return installZcodeHooks(fm: fm) } else { installExternalHooks(cli: cli, fm: fm) - if cli.source == "codex" { enableCodexHooksConfig(fm: fm) } + if cli.source == "codex" { enableCodexHooksConfig(fm: fm); installCodexProxy(fm: fm) } return isHooksInstalled(for: cli, fm: fm) } } else { @@ -1126,7 +1145,7 @@ struct ConfigInstaller { } } else { installExternalHooks(cli: cli, fm: fm) - if cli.source == "codex" { enableCodexHooksConfig(fm: fm) } + if cli.source == "codex" { enableCodexHooksConfig(fm: fm); installCodexProxy(fm: fm) } if isHooksInstalled(for: cli, fm: fm) { repaired.append(cli.name) } @@ -2420,6 +2439,177 @@ struct ConfigInstaller { return fm.createFile(atPath: configPath, contents: result.data(using: .utf8)) } + // MARK: - Codex desktop-app proxy (LaunchAgent) + + /// Install the Codex proxy script and register it as a LaunchAgent. + /// The ChatGPT desktop app's app-server doesn't fire hooks, so this proxy + /// polls the Codex state DB and forwards session events to the bridge. + @discardableResult + static func installCodexProxy(fm: FileManager) -> Bool { + // Only install if Codex is present + guard fm.fileExists(atPath: codexHome()) else { return true } + + // Resolve python3 path (prefer pyenv, then system python3) + let pythonPath: String = { + let pyenv = NSHomeDirectory() + "/.pyenv/versions/3.12.0/bin/python3" + if fm.fileExists(atPath: pyenv) { return pyenv } + if fm.fileExists(atPath: "/usr/bin/python3") { return "/usr/bin/python3" } + return "/usr/bin/env python3" + }() + + // Copy the bundled proxy script to ~/.codeisland/codex-proxy.py + if let bundlePath = Bundle.main.path(forResource: "codex-proxy", ofType: "py") { + try? fm.removeItem(atPath: codexProxyPath) + try? fm.copyItem(atPath: bundlePath, toPath: codexProxyPath) + // Ensure executable + try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codexProxyPath) + } else if !fm.fileExists(atPath: codexProxyPath) { + return false + } + + // Create the LaunchAgent plist + let plist = """ + + + + + Label + \(codexProxyLabel) + ProgramArguments + + \(pythonPath) + \(codexProxyPath) + + RunAtLoad + + KeepAlive + + StandardOutPath + \(codexProxyLogPath) + StandardErrorPath + \(codexProxyLogPath) + + + """ + + // Ensure ~/Library/LaunchAgents exists + let launchDir = (codexProxyPlistPath as NSString).deletingLastPathComponent + try? fm.createDirectory(atPath: launchDir, withIntermediateDirectories: true) + + try? plist.write(toFile: codexProxyPlistPath, atomically: true, encoding: .utf8) + + // Load the LaunchAgent (unload first to handle re-install) + let unloadProc = Process() + unloadProc.launchPath = "/bin/launchctl" + unloadProc.arguments = ["unload", codexProxyPlistPath] + try? unloadProc.run() + unloadProc.waitUntilExit() + + let loadProc = Process() + loadProc.launchPath = "/bin/launchctl" + loadProc.arguments = ["load", codexProxyPlistPath] + try? loadProc.run() + loadProc.waitUntilExit() + + return loadProc.terminationStatus == 0 + } + + /// Uninstall the Codex proxy LaunchAgent and script. + static func uninstallCodexProxy(fm: FileManager) { + // Unload the LaunchAgent + let unloadProc = Process() + unloadProc.launchPath = "/bin/launchctl" + unloadProc.arguments = ["unload", codexProxyPlistPath] + try? unloadProc.run() + unloadProc.waitUntilExit() + + try? fm.removeItem(atPath: codexProxyPlistPath) + try? fm.removeItem(atPath: codexProxyPath) + try? fm.removeItem(atPath: codexProxyLogPath) + } + + // MARK: - OMP proxy (LaunchAgent) + + /// Install the OMP proxy script and register it as a LaunchAgent. + /// Sessions started before the CodeIsland extension was installed don't + /// get native hook events. This proxy polls JSONL transcripts to detect + /// activity and forwards events to the bridge. + @discardableResult + static func installOmpProxy(fm: FileManager) -> Bool { + let ompDir = NSHomeDirectory() + "/.omp/agent/sessions" + guard fm.fileExists(atPath: ompDir) else { return true } + + let pythonPath: String = { + let pyenv = NSHomeDirectory() + "/.pyenv/versions/3.12.0/bin/python3" + if fm.fileExists(atPath: pyenv) { return pyenv } + if fm.fileExists(atPath: "/usr/bin/python3") { return "/usr/bin/python3" } + return "/usr/bin/env python3" + }() + + if let bundlePath = Bundle.main.path(forResource: "omp-proxy", ofType: "py") { + try? fm.removeItem(atPath: ompProxyPath) + try? fm.copyItem(atPath: bundlePath, toPath: ompProxyPath) + try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ompProxyPath) + } else if !fm.fileExists(atPath: ompProxyPath) { + return false + } + + let plist = """ + + + + + Label + \(ompProxyLabel) + ProgramArguments + + \(pythonPath) + \(ompProxyPath) + + RunAtLoad + + KeepAlive + + StandardOutPath + \(ompProxyLogPath) + StandardErrorPath + \(ompProxyLogPath) + + + """ + + let launchDir = (ompProxyPlistPath as NSString).deletingLastPathComponent + try? fm.createDirectory(atPath: launchDir, withIntermediateDirectories: true) + try? plist.write(toFile: ompProxyPlistPath, atomically: true, encoding: .utf8) + + let unloadProc = Process() + unloadProc.launchPath = "/bin/launchctl" + unloadProc.arguments = ["unload", ompProxyPlistPath] + try? unloadProc.run() + unloadProc.waitUntilExit() + + let loadProc = Process() + loadProc.launchPath = "/bin/launchctl" + loadProc.arguments = ["load", ompProxyPlistPath] + try? loadProc.run() + loadProc.waitUntilExit() + + return loadProc.terminationStatus == 0 + } + + /// Uninstall the OMP proxy LaunchAgent and script. + static func uninstallOmpProxy(fm: FileManager) { + let unloadProc = Process() + unloadProc.launchPath = "/bin/launchctl" + unloadProc.arguments = ["unload", ompProxyPlistPath] + try? unloadProc.run() + unloadProc.waitUntilExit() + + try? fm.removeItem(atPath: ompProxyPlistPath) + try? fm.removeItem(atPath: ompProxyPath) + try? fm.removeItem(atPath: ompProxyLogPath) + } + // MARK: - Kimi Code CLI (TOML hooks) internal static func installKimiHooks(cli: CLIConfig, fm: FileManager) -> Bool { diff --git a/Sources/CodeIsland/DebugHarness.swift b/Sources/CodeIsland/DebugHarness.swift index 0425cb29..6cc32730 100644 --- a/Sources/CodeIsland/DebugHarness.swift +++ b/Sources/CodeIsland/DebugHarness.swift @@ -227,9 +227,28 @@ enum DebugHarness { last5h: fiveH, today: today, hourlyOutputTokens: [0, 0, 4200, 18_000, 9500, 0, 22_000, 41_000, 12_000, 30_500, 52_000, 17_500], + dailyTotals: previewDailyTotals(today: today), scannedAt: Date() ) } + /// 14 days of synthetic token usage for the preview chart — weekdays busier + /// than weekends, today echoing the supplied totals, older days trailing + /// off. Index 0 is 13 days ago, the last entry is today. + private static func previewDailyTotals(today: ClaudeUsageTotals) -> [ClaudeUsageTotals] { + let baseOutput: [Int] = [8_000, 12_500, 0, 21_000, 33_000, 19_000, 4_500, + 14_000, 27_500, 0, 38_000, 22_000, 11_500] + var days: [ClaudeUsageTotals] = baseOutput.map { out in + var t = ClaudeUsageTotals() + t.inputTokens = Int(Double(out) * 1.75) + t.outputTokens = out + t.cacheCreationTokens = Int(Double(out) * 0.6) + t.cacheReadTokens = Int(Double(out) * 16) + t.messageCount = max(1, out / 2_500) + return t + } + days.append(today) + return days + } private static func applyBusy(to state: AppState) { // Main Claude session with subagents diff --git a/Sources/CodeIsland/KeyboardActivityMonitor.swift b/Sources/CodeIsland/KeyboardActivityMonitor.swift new file mode 100644 index 00000000..11f5b38a --- /dev/null +++ b/Sources/CodeIsland/KeyboardActivityMonitor.swift @@ -0,0 +1,49 @@ +import AppKit + +/// Tracks keyboard activity without recording keystrokes — only whether +/// the user is currently typing. Used to suppress notification sounds while +/// the user is typing, with a 5s debounce after the last keystroke before +/// resuming notifications. +/// +/// Privacy: No keystrokes are captured, stored, or transmitted. The monitor +/// only records the timestamp of the most recent key-down event. +@MainActor +final class KeyboardActivityMonitor { + static let shared = KeyboardActivityMonitor() + + /// Seconds after the last keystroke before notifications resume. + static let debounceInterval: TimeInterval = 5 + + /// Timestamp of the most recent key-down event (main-thread only). + private(set) var lastKeydownAt: Date? + + /// True if the user has typed within the debounce window. + var isTyping: Bool { + guard let last = lastKeydownAt else { return false } + return Date().timeIntervalSince(last) < Self.debounceInterval + } + + private var globalMonitor: Any? + private var started = false + + private init() {} + + func start() { + guard !started else { return } + started = true + // Global monitor fires for key-down events even when the app is not + // focused. We only record the timestamp — no key content is captured. + globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] _ in + self?.lastKeydownAt = Date() + } + } + + func stop() { + if let monitor = globalMonitor { + NSEvent.removeMonitor(monitor) + globalMonitor = nil + } + started = false + lastKeydownAt = nil + } +} diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index a9c2983f..1724308d 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -213,6 +213,7 @@ struct NotchPanelView: View { queueTotal: appState.permissionQueue.count, session: session, sessionId: sid, + eventSource: pending.event.rawJSON["_source"] as? String, appState: appState, onAllow: { appState.approvePermission(always: false) }, onAlwaysAllow: { appState.approvePermission(always: true) }, @@ -562,6 +563,7 @@ private struct CompactRightWing: View { soundEnabled.toggle() } NotchIconButton(icon: "gearshape", tooltip: l10n["settings"]) { + appState.surface = .collapsed SettingsWindowController.shared.show() } NotchIconButton(icon: "power", tint: Color(red: 1.0, green: 0.4, blue: 0.4), tooltip: l10n["quit"]) { @@ -996,6 +998,7 @@ private struct ApprovalBar: View { let queueTotal: Int let session: SessionSnapshot? let sessionId: String + let eventSource: String? let appState: AppState let onAllow: () -> Void let onAlwaysAllow: () -> Void @@ -1020,40 +1023,197 @@ private struct ApprovalBar: View { toolInput?["server_name"] as? String } + /// Resolved source for the no-session fallback: the event's `_source` + /// (normalized) so we can still show *which CLI is asking* even when the + /// session was removed while the card is still visible. + private var resolvedSource: String? { + SessionSnapshot.normalizedSupportedSource(eventSource) + } + + /// Brand color for the CLI identity header — derived from the session's + /// status (matches SessionCard.statusNameColor for visual continuity + /// between the session list and the approval card). + private var headerColor: Color { + guard let session else { + // No session — still an approval context, so use the approval + // accent color for any CLI-identity text we render. + return Color(red: 1.0, green: 0.6, blue: 0.2) + } + if session.status == .idle && session.interrupted { + return Color(red: 1.0, green: 0.45, blue: 0.35) + } + switch session.status { + case .processing, .running: return Color(red: 0.3, green: 0.85, blue: 0.4) + case .waitingApproval, .waitingQuestion: return Color(red: 1.0, green: 0.6, blue: 0.2) + case .idle: return .white + } + } var body: some View { VStack(spacing: 8) { - // Tool name + file context - HStack(spacing: 6) { - Text("!") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) - Text(tool) - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) - if let server = serverName { - Text("(\(server))") - .font(.system(size: 9)) - .foregroundStyle(Color(red: 0.6, green: 0.7, blue: 0.9)) - } - if let name = fileName { - Text(name) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white.opacity(0.6)) - } - if queueTotal > 1 { - Text("\(queuePosition)/\(queueTotal)") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.white.opacity(0.5)) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background(Color.white.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 3)) + // Prominent header: which CLI is asking. Mirrors SessionCard's + // SessionIdentityLine — custom name (if set) or project name, the + // source label, and the short session id in the source brand + // color. The "! Bash" tool line moves to a secondary detail row. + if let session = session { + HStack(spacing: 5) { + if let icon = cliIcon(source: session.source, size: 14) { + Image(nsImage: icon) + .resizable() + .frame(width: 14, height: 14) + } + Text(appState.customDisplayName(for: sessionId) ?? session.projectDisplayName) + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(headerColor) + .lineLimit(1) + .truncationMode(.middle) + Text("·") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.35)) + Text(session.sourceLabel) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(headerColor.opacity(0.85)) + Text("#\(session.displaySessionId(sessionId: sessionId))") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(headerColor.opacity(0.7)) + Spacer(minLength: 4) + if queueTotal > 1 { + Text("\(queuePosition)/\(queueTotal)") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.6)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.white.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + } + .padding(.horizontal, 14) + .contentShape(Rectangle()) + .onTapGesture { handleCardClick() } + + // Secondary row: the tool being approved ("! Bash", "! apply_patch", …). + // Demoted from the header — the CLI identity is now what the + // user sees at a glance; the tool is the context. + HStack(spacing: 6) { + Text("!") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) + Text(tool) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) + if let server = serverName { + Text("(\(server))") + .font(.system(size: 9)) + .foregroundStyle(Color(red: 0.6, green: 0.7, blue: 0.9)) + } + if let name = fileName { + Text("·") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.35)) + Text(name) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.6)) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + } + .padding(.horizontal, 14) + .contentShape(Rectangle()) + .onTapGesture { handleCardClick() } + } else { + // No session (transient/removed) — still show which CLI is + // asking when the event carries `_source`, so the user isn't + // left guessing. Falls back to the tool-only header otherwise. + if let src = resolvedSource { + // Header row: CLI icon + source label (which CLI is asking). + HStack(spacing: 5) { + if let icon = cliIcon(source: src, size: 14) { + Image(nsImage: icon) + .resizable() + .frame(width: 14, height: 14) + } + Text(SessionSnapshot.sourceLabel(for: src)) + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(headerColor) + .lineLimit(1) + Spacer(minLength: 4) + if queueTotal > 1 { + Text("\(queuePosition)/\(queueTotal)") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.6)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.white.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + } + .padding(.horizontal, 14) + .contentShape(Rectangle()) + .onTapGesture { handleCardClick() } + + // Secondary row: the tool being approved. + HStack(spacing: 6) { + Text("!") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) + Text(tool) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) + if let server = serverName { + Text("(\(server))") + .font(.system(size: 9)) + .foregroundStyle(Color(red: 0.6, green: 0.7, blue: 0.9)) + } + if let name = fileName { + Text("·") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.35)) + Text(name) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.6)) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + } + .padding(.horizontal, 14) + .contentShape(Rectangle()) + .onTapGesture { handleCardClick() } + } else { + // No source either — keep the original tool-only header. + HStack(spacing: 6) { + Text("!") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) + Text(tool) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) + if let server = serverName { + Text("(\(server))") + .font(.system(size: 9)) + .foregroundStyle(Color(red: 0.6, green: 0.7, blue: 0.9)) + } + if let name = fileName { + Text(name) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + } + if queueTotal > 1 { + Text("\(queuePosition)/\(queueTotal)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white.opacity(0.5)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + Spacer() + } + .padding(.horizontal, 14) + .contentShape(Rectangle()) + .onTapGesture { handleCardClick() } } - Spacer() } - .padding(.horizontal, 14) - .contentShape(Rectangle()) - .onTapGesture { handleCardClick() } // Tool-specific detail view if toolInput != nil { @@ -1068,10 +1228,10 @@ private struct ApprovalBar: View { // Pixel-style buttons — badge the global shortcut when one is enabled HStack(spacing: 6) { - PixelButton(label: L10n.shared["deny"], fg: .white.opacity(0.95), bg: Color(red: 0.45, green: 0.12, blue: 0.12), border: Color(red: 0.7, green: 0.25, blue: 0.25), hint: Self.shortcutHint(.deny), action: onDeny) - PixelButton(label: L10n.shared["dismiss"], fg: .white.opacity(0.95), bg: Color(red: 0.25, green: 0.25, blue: 0.25), border: Color.white.opacity(0.28), action: onDismiss) - PixelButton(label: L10n.shared["allow_once"], fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), border: Color(red: 0.28, green: 0.62, blue: 0.32), hint: Self.shortcutHint(.approve), action: onAllow) - PixelButton(label: L10n.shared["always"], fg: .white.opacity(0.95), bg: Color(red: 0.14, green: 0.28, blue: 0.52), border: Color(red: 0.28, green: 0.48, blue: 0.82), hint: Self.shortcutHint(.approveAlways), action: onAlwaysAllow) + PixelButton(label: L10n.shared["deny"], fg: .white.opacity(0.95), bg: Color(red: 0.52, green: 0.16, blue: 0.16), border: Color(red: 0.78, green: 0.32, blue: 0.32), hint: Self.shortcutHint(.deny), action: onDeny) + PixelButton(label: L10n.shared["dismiss"], fg: .white.opacity(0.85), bg: Color(red: 0.22, green: 0.22, blue: 0.24), border: Color.white.opacity(0.22), action: onDismiss) + PixelButton(label: L10n.shared["allow_once"], fg: .white.opacity(0.95), bg: Color(red: 0.18, green: 0.42, blue: 0.22), border: Color(red: 0.36, green: 0.68, blue: 0.42), hint: Self.shortcutHint(.approve), action: onAllow) + PixelButton(label: L10n.shared["always"], fg: .white.opacity(0.95), bg: Color(red: 0.18, green: 0.32, blue: 0.58), border: Color(red: 0.36, green: 0.54, blue: 0.88), hint: Self.shortcutHint(.approveAlways), action: onAlwaysAllow) } .padding(.horizontal, 14) } @@ -1683,14 +1843,18 @@ private struct PixelButton: View { } .frame(maxWidth: .infinity) .padding(.vertical, 7) + // Flat fill — no gradient. Hover lightens the bg slightly and + // lifts the button with a soft shadow for a tactile feel. .background( - RoundedRectangle(cornerRadius: 4) - .fill(hovering ? bg.opacity(1.5) : bg) + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(hovering ? bg.opacity(0.85) : bg) ) .overlay( - RoundedRectangle(cornerRadius: 4) - .strokeBorder(hovering ? border : border.opacity(0.4), lineWidth: 1) + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(border.opacity(hovering ? 0.9 : 0.5), lineWidth: 1) ) + .shadow(color: .black.opacity(hovering ? 0.35 : 0.2), radius: hovering ? 3 : 1.5, y: hovering ? 1 : 0.5) + .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } .buttonStyle(.plain) .onHover { h in withAnimation(NotchAnimation.micro) { hovering = h } } @@ -1704,15 +1868,35 @@ private struct SessionListView: View { /// When set, only show this session (auto-expand on completion) var onlySessionId: String? = nil @AppStorage(SettingsKey.sessionGroupingMode) private var groupingMode = SettingsDefaults.sessionGroupingMode + @State private var cachedGroups: [(header: String, source: String?, ids: [String])]? @AppStorage(SettingsKey.maxVisibleSessions) private var maxVisibleSessions = SettingsDefaults.maxVisibleSessions @AppStorage(SettingsKey.showUsageStats) private var showUsageStats = SettingsDefaults.showUsageStats - private var groupedSessions: [(header: String, source: String?, ids: [String])] { + // While renaming, freeze the list so the TextField keeps focus. + if appState.renamingSessionId != nil, let cached = cachedGroups { + return cached + } + return computeGroupedSessions() + } + + private func computeGroupedSessions() -> [(header: String, source: String?, ids: [String])] { if let only = onlySessionId, appState.sessions[only] != nil { return [("", nil, [only])] } - let sorted = appState.sessions.keys.sorted() + let favs = appState.favoriteSessionIds + let sorted = appState.sessions.keys.sorted { a, b in + let fa = favs.contains(a), fb = favs.contains(b) + if fa != fb { return fa } // favorites first + // Sort by startTime (stable — doesn't jump when sessions are + // added/removed by the 3s cleanup/process scan timer). UUID sort + // would insert new sessions at random positions, causing the list + // to reorder every few seconds. + let ta = appState.sessions[a]?.startTime ?? .distantPast + let tb = appState.sessions[b]?.startTime ?? .distantPast + if ta != tb { return ta < tb } + return a < b // final tiebreaker + } switch groupingMode { case "status": @@ -1855,11 +2039,17 @@ private struct SessionListView: View { content } - // Full session list only — the completion card stays focused on - // the finished session. + // Compact one-line usage footer (5h / today + sparkline). if showUsageStats, onlySessionId == nil, let usage = appState.claudeUsage, !(usage.last5h.isEmpty && usage.today.isEmpty) { - UsageFooterLine(usage: usage) + UsageFooterLine(usage: usage, appState: appState) + } + } + .onChange(of: appState.renamingSessionId) { _, newValue in + if newValue != nil { + cachedGroups = computeGroupedSessions() + } else { + cachedGroups = nil } } } @@ -1869,26 +2059,70 @@ private struct SessionListView: View { /// (input + cache writes); cache reads live in the tooltip. private struct UsageFooterLine: View { let usage: ClaudeUsageScanner.Snapshot + var appState: AppState? @ObservedObject private var l10n = L10n.shared + @State private var sourceIndex = 0 + private let rotationTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + + private var sources: [SourceTotals] { + usage.perSource.filter { !$0.total.isEmpty } + } + + private var currentSource: SourceTotals? { + let s = sources + guard !s.isEmpty else { return nil } + return s[min(sourceIndex, s.count - 1)] + } var body: some View { HStack(spacing: 5) { Image(systemName: "gauge.with.needle") .font(.system(size: 9, weight: .semibold)) - Text("Claude") - .fontWeight(.semibold) - Text("5h \(compact(usage.last5h))") - Text("·") - .foregroundStyle(.white.opacity(0.25)) - Text("\(l10n["usage_today"]) \(compact(usage.today))") + if let src = currentSource { + Text(src.name) + .fontWeight(.semibold) + Text("5h \(compact(src.dailyTotals.last ?? ClaudeUsageTotals()))") + Text("·") + .foregroundStyle(.white.opacity(0.25)) + Text("\(l10n["usage_today"]) \(compact(src.dailyTotals.last ?? ClaudeUsageTotals()))") + } else { + Text("Claude") + .fontWeight(.semibold) + Text("5h \(compact(usage.last5h))") + Text("·") + .foregroundStyle(.white.opacity(0.25)) + Text("\(l10n["usage_today"]) \(compact(usage.today))") + } Spacer() - UsageSparkline(buckets: usage.hourlyOutputTokens) + if let src = currentSource { + UsageSourceBarsCompact(dailyTotals: src.dailyTotals, color: sourceColor(src.name)) + } else { + UsageSparkline(buckets: usage.hourlyOutputTokens) + } } .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundStyle(.white.opacity(0.45)) .padding(.horizontal, 14) .padding(.vertical, 5) .help(detail) + .onTapGesture { + appState?.surface = .collapsed + SettingsWindowController.shared.show(page: .usage) + } + .onReceive(rotationTimer) { _ in + let s = sources + if !s.isEmpty { sourceIndex = (sourceIndex + 1) % s.count } + } + } + + private func sourceColor(_ name: String) -> Color { + switch name { + case "Claude Code": return .orange + case "OMP": return .teal + case "Codex": return Color(red: 0.6, green: 0.6, blue: 0.6) + case "Cursor": return Color(red: 0.93, green: 0.93, blue: 0.93) + default: return .teal + } } private func compact(_ t: ClaudeUsageTotals) -> String { @@ -1896,6 +2130,13 @@ private struct UsageFooterLine: View { } private var detail: String { + if let src = currentSource { + func line(_ label: String, _ t: ClaudeUsageTotals) -> String { + "\(label): in \(ClaudeUsageScanner.formatTokens(t.inputTokens)) · out \(ClaudeUsageScanner.formatTokens(t.outputTokens)) · cache \(ClaudeUsageScanner.formatTokens(t.cacheCreationTokens + t.cacheReadTokens))" + } + let today = src.dailyTotals.last ?? ClaudeUsageTotals() + return "\(src.name)\n" + line("5h", today) + "\n" + line(l10n["usage_today"], today) + } func line(_ label: String, _ t: ClaudeUsageTotals) -> String { "\(label): in \(ClaudeUsageScanner.formatTokens(t.inputTokens)) · out \(ClaudeUsageScanner.formatTokens(t.outputTokens)) · cache write \(ClaudeUsageScanner.formatTokens(t.cacheCreationTokens)) · cache read \(ClaudeUsageScanner.formatTokens(t.cacheReadTokens))" } @@ -1903,6 +2144,24 @@ private struct UsageFooterLine: View { } } +/// Compact sparkline for the notch footer — shows per-source daily bars. +private struct UsageSourceBarsCompact: View { + let dailyTotals: [ClaudeUsageTotals] + var color: Color = .teal + + var body: some View { + let peak = max(dailyTotals.map { $0.inputTokens + $0.outputTokens }.max() ?? 0, 1) + HStack(alignment: .bottom, spacing: 1) { + ForEach(Array(dailyTotals.enumerated()), id: \.offset) { _, total in + let value = total.inputTokens + total.outputTokens + RoundedRectangle(cornerRadius: 0.5) + .fill(value == 0 ? Color.gray.opacity(0.15) : color.opacity(0.6)) + .frame(width: 2, height: max(1.5, CGFloat(value) / CGFloat(peak) * 12)) + } + } + } +} + /// Trailing-hours output-token activity, one 2.5pt bar per hour (right = now). private struct UsageSparkline: View { let buckets: [Int] @@ -1964,6 +2223,7 @@ private struct SessionIdentityLine: View { let sessionFontSize: CGFloat let sessionColor: Color let dividerColor: Color + var customName: String? = nil @AppStorage(SettingsKey.showGitBranch) private var showGitBranch = SettingsDefaults.showGitBranch private var displaySessionId: String { session.displaySessionId(sessionId: sessionId) } @@ -1971,7 +2231,7 @@ private struct SessionIdentityLine: View { var body: some View { HStack(spacing: 4) { ProjectNameLink( - name: session.projectDisplayName, + name: customName ?? session.projectDisplayName, cwd: session.cwd, isInteractive: !session.isRemote, fontSize: projectFontSize, @@ -2018,6 +2278,38 @@ private struct SessionIdentityLine: View { } } +private struct RenameSessionField: View { + @Binding var text: String + let color: Color + let fontSize: CGFloat + let onSubmit: () -> Void + let onCancel: () -> Void + @FocusState private var isFocused: Bool + + var body: some View { + HStack(spacing: 6) { + TextField("Session name", text: $text) + .font(.system(size: fontSize, weight: .bold, design: .monospaced)) + .foregroundStyle(color) + .textFieldStyle(.plain) + .focused($isFocused) + .onSubmit(onSubmit) + .onExitCommand(perform: onCancel) + Button(action: onSubmit) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + .buttonStyle(.plain) + Button(action: onCancel) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red.opacity(0.7)) + } + .buttonStyle(.plain) + } + .onAppear { isFocused = true } + } +} + private struct ProjectNameLink: View { let name: String let cwd: String? @@ -2144,6 +2436,8 @@ private struct SessionCard: View { @State private var hovering = false @State private var failureShakeOffset: CGFloat = 0 @State private var jumpValidationTask: Task? + @State private var showRenameField = false + @State private var renameText = "" @State private var showApprovalDetails = false @AppStorage(SettingsKey.contentFontSize) private var contentFontSize = SettingsDefaults.contentFontSize @AppStorage(SettingsKey.aiMessageLines) private var aiMessageLines = SettingsDefaults.aiMessageLines @@ -2199,6 +2493,7 @@ private struct SessionCard: View { } var body: some View { + let _ = appState.customNamesRevision HStack(alignment: .center, spacing: 8) { // Column 1: Character + subagent icons VStack(spacing: 3) { @@ -2227,14 +2522,39 @@ private struct SessionCard: View { VStack(alignment: .leading, spacing: 6) { // Header: project name + optional session label + short ID HStack(alignment: .center, spacing: 8) { - SessionIdentityLine( - session: session, - sessionId: sessionId, - projectFontSize: fontSize + 2, - projectColor: statusNameColor, - sessionFontSize: fontSize, - sessionColor: .white.opacity(0.76), - dividerColor: .white.opacity(0.28) + if showRenameField { + RenameSessionField( + text: $renameText, + color: statusNameColor, + fontSize: fontSize + 2, + onSubmit: { + appState.setCustomDisplayName(renameText, for: sessionId) + showRenameField = false + appState.renamingSessionId = nil + }, + onCancel: { + showRenameField = false + appState.renamingSessionId = nil + } + ) + } else { + SessionIdentityLine( + session: session, + sessionId: sessionId, + projectFontSize: fontSize + 2, + projectColor: statusNameColor, + sessionFontSize: fontSize, + sessionColor: .white.opacity(0.76), + dividerColor: .white.opacity(0.28), + customName: appState.customDisplayName(for: sessionId) + ) + } + // Favorite star — always visible when favorited (gold), + // otherwise shows on hover as a dim outline. + FavoriteStarButton( + isFavorite: appState.isFavorite(sessionId), + hovering: hovering, + toggle: { appState.toggleFavorite(sessionId) } ) Spacer(minLength: 8) @@ -2251,7 +2571,7 @@ private struct SessionCard: View { if session.isYoloMode == true { SessionTag("YOLO", color: Color(red: 1.0, green: 0.35, blue: 0.35)) } - SessionTag(timeAgo(session.startTime)) + SessionTag(timeAgo(session.startTime), color: .white.opacity(0.6), fillColor: .white.opacity(0.15)) TerminalBadge(session: session) } } @@ -2392,21 +2712,12 @@ private struct SessionCard: View { // Suppressed while a Cursor-side question is pending — the // question block above already explains the wait (#265). if session.status != .idle && !showsExternalCursorQuestion { - HStack(spacing: 4) { - Text("$") - .font(.system(size: fontSize, weight: .bold, design: .monospaced)) - .foregroundStyle(Color(red: 0.85, green: 0.47, blue: 0.34)) - if let tool = session.currentTool { - MorphText( - text: session.toolDescription ?? tool, - font: .system(size: fontSize, design: .monospaced), - color: .white.opacity(0.75) - ) - .truncationMode(.tail) - } else { - TypingIndicator(fontSize: fontSize, label: "thinking") - } - } + WorkingIndicator( + fontSize: fontSize, + currentTool: session.currentTool, + toolDescription: session.toolDescription, + lastAssistantMessage: session.lastAssistantMessage + ) } } .padding(.leading, 4) @@ -2417,12 +2728,39 @@ private struct SessionCard: View { .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: 10) - .fill(hovering ? Color.white.opacity(0.10) : Color.white.opacity(0.05)) + .fill(cardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(appState.isFavorite(sessionId) ? Color.yellow.opacity(0.35) : Color.clear, lineWidth: 1) ) .padding(.horizontal, 6) .offset(x: failureShakeOffset) .contentShape(Rectangle()) - .onTapGesture { handleSessionClick() } + .onTapGesture { if !showRenameField { handleSessionClick() } } + .contextMenu { + Button { + renameText = appState.customDisplayName(for: sessionId) ?? session.projectDisplayName + appState.renamingSessionId = sessionId + showRenameField = true + } label: { + Label("Rename", systemImage: "pencil") + } + if appState.customDisplayName(for: sessionId) != nil { + Button { + appState.setCustomDisplayName(nil, for: sessionId) + } label: { + Label("Reset Name", systemImage: "arrow.counterclockwise") + } + } + Divider() + Button { + appState.toggleFavorite(sessionId) + } label: { + Label(appState.isFavorite(sessionId) ? "Unstar" : "Star", + systemImage: appState.isFavorite(sessionId) ? "star.slash" : "star") + } + } .onHover { h in withAnimation(NotchAnimation.micro) { hovering = h } } .onDisappear { jumpValidationTask?.cancel() @@ -2430,9 +2768,20 @@ private struct SessionCard: View { } } + private var cardBackground: Color { + if appState.isFavorite(sessionId) { + return Color.yellow.opacity(hovering ? 0.14 : 0.08) + } + return hovering ? Color.white.opacity(0.10) : Color.white.opacity(0.05) + } + private func handleSessionClick() { TerminalActivator.activate(session: session, sessionId: sessionId) + // Mark as read — transitions the session from active/processing to idle + // so the card stops showing "Ready" after the user clicks it. + appState.markAsRead(sessionId) + guard autoCollapseAfterSessionJump, !session.isRemote else { return } jumpValidationTask?.cancel() @@ -2493,6 +2842,82 @@ private struct SessionCard: View { } } +/// Favorite star button — filled gold when favorited, dim outline on hover +/// otherwise. Slightly larger (12pt) with a hover scale effect and a soft +/// gold glow when active. Extracted from SessionCard.body to keep the body +/// expression within the type-checker's budget. +private struct FavoriteStarButton: View { + let isFavorite: Bool + let hovering: Bool + let toggle: () -> Void + + var body: some View { + Button { + toggle() + } label: { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(isFavorite ? Color.yellow : Color.white.opacity(hovering ? 0.5 : 0)) + .scaleEffect(isFavorite ? 1.0 : (hovering ? 1.1 : 1.0)) + .shadow( + color: isFavorite ? Color.yellow.opacity(hovering ? 0.55 : 0.3) : .clear, + radius: hovering ? 3 : 1.5 + ) + .padding(2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(isFavorite ? "Remove from favorites" : "Add to favorites") + } +} + +/// Live working-status line for a session: a colored status dot + "$" prompt + +/// the current tool name, "Ready", or a thinking shimmer. The dot is white when +/// a tool is running (e.g. Read), green for Ready, blue (pulsing) for thinking. +/// Extracted from SessionCard.body to keep the body expression within the +/// type-checker's budget. +private struct WorkingIndicator: View { + let fontSize: CGFloat + let currentTool: String? + let toolDescription: String? + let lastAssistantMessage: String? + + private var dotColor: Color { + if currentTool != nil { return .white } + if lastAssistantMessage != nil { return Color(red: 0.4, green: 0.85, blue: 0.5) } + return Color(red: 0.4, green: 0.65, blue: 1.0) + } + + private var isThinking: Bool { + currentTool == nil && lastAssistantMessage == nil + } + + var body: some View { + HStack(spacing: 4) { + StatusDot(color: dotColor, pulsing: isThinking) + Text("$") + .font(.system(size: fontSize, weight: .bold, design: .monospaced)) + .foregroundStyle(Color(red: 0.85, green: 0.47, blue: 0.34)) + if let tool = currentTool { + MorphText( + text: toolDescription ?? tool, + font: .system(size: fontSize, design: .monospaced), + color: .white.opacity(0.75) + ) + .truncationMode(.tail) + } else if lastAssistantMessage != nil { + // Session finished a response but hasn't been clicked yet — + // show "Ready" instead of "thinking". + Text("Ready") + .font(.system(size: fontSize, weight: .medium, design: .monospaced)) + .foregroundStyle(Color(red: 0.4, green: 0.85, blue: 0.5)) + } else { + TypingIndicator(fontSize: fontSize, label: "thinking") + } + } + } +} + // MARK: - Claude Logo (official sunburst from simple-icons, viewBox 0 0 24 24) private struct ClaudeLogo: View { @@ -2714,29 +3139,43 @@ private struct TerminalBadge: View { .foregroundStyle(remoteColor) if let term = session.terminalName { Text(term) - .font(.system(size: 9.5, weight: .medium, design: .monospaced)) + .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundStyle(remoteColor) } } .padding(.horizontal, 6) - .padding(.vertical, 3) + .padding(.vertical, 2) .background( - RoundedRectangle(cornerRadius: 5) - .fill(remoteColor.opacity(0.08)) + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(remoteColor.opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(remoteColor.opacity(0.3), lineWidth: 0.5) ) } else { HStack(spacing: 3) { if let icon = termIcon { Image(nsImage: icon) .resizable() - .frame(width: 13, height: 13) + .frame(width: 12, height: 12) } if let term = session.terminalName { Text(term) - .font(.system(size: 9.5, weight: .medium, design: .monospaced)) - .foregroundStyle(.white.opacity(0.5)) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.55)) } } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.ultraThinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + ) } } } @@ -2918,24 +3357,67 @@ private func monogramIcon(for source: String, size: CGFloat) -> NSImage { return image } +/// Small colored status dot for the working indicator: white when a tool is +/// running (e.g. Read), green for Ready, blue for thinking. When `pulsing` is +/// true (thinking), a soft blurred halo breathes around the dot. +private struct StatusDot: View { + let color: Color + var pulsing: Bool = false + + @State private var pulse: Double = 0.5 + + var body: some View { + ZStack { + if pulsing { + Circle() + .fill(color.opacity(0.5)) + .frame(width: 5, height: 5) + .blur(radius: 1.5) + .scaleEffect(1.0 + CGFloat(pulse) * 0.6) + .opacity(0.4 + (1.0 - pulse) * 0.3) + } + Circle() + .fill(color) + .frame(width: 5, height: 5) + .shadow(color: color.opacity(0.7), radius: 2) + } + .onAppear { + guard pulsing else { return } + withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true)) { + pulse = 0.0 + } + } + } +} + private struct SessionTag: View { let text: String var color: Color = .white.opacity(0.7) + // Optional explicit fill for the pill background. When nil, the fill is + // derived from `color` at 12% opacity (the original behavior). Pass an + // explicit Color (e.g. `.white.opacity(0.15)`) for badges that should have + // a neutral, label-independent background like the time-ago badge. + var fillColor: Color? = nil - init(_ text: String, color: Color = .white.opacity(0.7)) { + init(_ text: String, color: Color = .white.opacity(0.7), fillColor: Color? = nil) { self.text = text self.color = color + self.fillColor = fillColor } var body: some View { Text(text) - .font(.system(size: 9.5, weight: .medium, design: .monospaced)) + .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundStyle(color) .padding(.horizontal, 6) - .padding(.vertical, 3) + .padding(.vertical, 2) .background( - RoundedRectangle(cornerRadius: 5) - .fill(color.opacity(0.12)) + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill((fillColor ?? color.opacity(0.12))) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(color.opacity(0.18), lineWidth: 0.5) ) } } diff --git a/Sources/CodeIsland/Resources/codeisland-ext.ts b/Sources/CodeIsland/Resources/codeisland-ext.ts new file mode 100644 index 00000000..73709e43 --- /dev/null +++ b/Sources/CodeIsland/Resources/codeisland-ext.ts @@ -0,0 +1,551 @@ +// CodeIsland pi extension +// version: v2 +// OMP-compatible install + +/** + * @fileoverview CodeIsland Integration Extension for Oh My Pi / OMP. + * + * This is the same socket bridge as codeisland-pi.ts, but imports OMP's + * package scope so `omp` can load it from ~/.omp/agent/extensions. + */ + +import { execFile, execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { connect } from "node:net"; +import { homedir } from "node:os"; +import { getuid } from "node:process"; +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; + +// ── Debug logging ───────────────────────────────────────────────────────────── +/** Set CODEISLAND_DEBUG=1 to emit stderr traces. */ +const DEBUG = process.env.CODEISLAND_DEBUG === "1" || process.env.CODEISLAND_DEBUG === "true"; +function dbg(msg: string, ...args: unknown[]): void { + if (!DEBUG) return; + const ts = new Date().toISOString().slice(11, 23); + const extra = args.length > 0 ? " " + args.map((a) => + typeof a === "string" ? a : JSON.stringify(a) + ).join(" ") : ""; + process.stderr.write(`[codeisland-ext ${ts}] ${msg}${extra}\n`); +} + +// ── Socket / bridge constants ───────────────────────────────────────────────── + +/** Unix socket path CodeIsland listens on (user-scoped). */ +const userId = getuid?.() ?? 0; +const SOCKET_PATH = `/tmp/codeisland-${userId}.sock`; + +/** + * Bridge binary path. Used for blocking permission requests because Node's + * half-close (`sock.end()`) causes NWConnection to close before the response + * arrives on macOS; the bridge uses POSIX `shutdown(SHUT_WR)` which works. + */ +const BRIDGE_PATH = `${homedir()}/.codeisland/codeisland-bridge`; + +/** Environment variable keys forwarded to CodeIsland for terminal detection. */ +const ENV_KEYS = [ + "TERM_PROGRAM", + "ITERM_SESSION_ID", + "TERM_SESSION_ID", + "TMUX", + "TMUX_PANE", + "KITTY_WINDOW_ID", + "CMUX_SURFACE_ID", + "CMUX_WORKSPACE_ID", + "ZELLIJ_PANE_ID", + "ZELLIJ_SESSION_NAME", + "WEZTERM_PANE", + "__CFBundleIdentifier", +] as const; + +// ── Dangerous bash patterns (mirrors permission-gate.ts) ────────────────────── + +const DANGEROUS_PATTERNS: RegExp[] = [ + // Only flag rm -rf of truly dangerous targets: root, home, or bare paths. + // `rm -rf .swiftpm` / `rm -rf .build` / `rm -f file` are safe and common. + /\brm\s+(-[a-z]*r[a-z]*f|--recursive)\s+[/~]|\brm\s+(-[a-z]*r[a-z]*f|--recursive)\s+\$HOME\b/i, + /\bsudo\b/i, + /\b(chmod|chown)\b.*777/i, +]; + +function isDangerous(command: string): boolean { + return DANGEROUS_PATTERNS.some((p) => p.test(command)); +} + +// ── Environment / TTY helpers ───────────────────────────────────────────────── + +/** Collects relevant terminal environment variables. */ +function collectEnv(): Record { + const env: Record = {}; + for (const key of ENV_KEYS) { + if (process.env[key]) env[key] = process.env[key]!; + } + return env; +} + +/** + * Walks the process tree upward to find the controlling TTY. + * Cached at startup — pi's TTY does not change during a session. + */ +function detectTty(): string | null { + try { + let pid = process.pid; + for (let i = 0; i < 8; i++) { + const out = execFileSync("ps", ["-o", "tty=,ppid=", "-p", String(pid)], { + timeout: 1000, + }) + .toString() + .trim(); + const [tty, ppidStr] = out.split(/\s+/); + if (tty && tty !== "??" && tty !== "?") { + return tty.startsWith("/dev/") ? tty : `/dev/${tty}`; + } + const ppid = parseInt(ppidStr ?? "0", 10); + if (!ppid || ppid <= 1) break; + pid = ppid; + } + } catch {} + return null; +} + +// ── Socket communication ────────────────────────────────────────────────────── + +/** + * Sends a JSON payload to the CodeIsland socket (fire-and-forget). + * Returns `false` silently when CodeIsland is not running. + */ +function sendToSocket(payload: object): Promise { + const ev = (payload as Record).hook_event_name ?? "?"; + dbg(`sendToSocket → ${ev} (path=${SOCKET_PATH})`); + const inner = new Promise((resolve) => { + let settled = false; + try { + const sock = connect({ path: SOCKET_PATH }, () => { + dbg(` connect OK → ${ev}`); + sock.write(JSON.stringify(payload)); + sock.end(); + if (!settled) { settled = true; resolve(true); } + }); + sock.on("error", (err) => { + dbg(` error → ${ev}: ${err.message}`); + if (!settled) { settled = true; resolve(false); } + }); + sock.setTimeout(2_000, () => { + dbg(` socket timeout → ${ev} (2s)`); + sock.destroy(); + if (!settled) { settled = true; resolve(false); } + }); + } catch (err) { + dbg(` throw → ${ev}: ${(err as Error).message}`); + resolve(false); + } + }); + // Hard outer guard — never let sendToSocket exceed 5s regardless of + // connect/accept quirks on macOS Unix sockets. + return Promise.race([ + inner, + new Promise((resolve) => + setTimeout(() => { dbg(` HARD TIMEOUT → ${ev} (5s)`); resolve(false); }, 5_000), + ), + ]); +} + +/** + * Sends a JSON payload via the bridge binary and waits for CodeIsland's response. + * Used exclusively for blocking permission/question requests. + * + * @param payload - Blocking request object. + * @param timeoutMs - Maximum wait time in milliseconds (default 30 s). + * @returns Parsed response JSON, or `null` on error / timeout. + */ +function sendAndWaitResponse( + payload: object, + timeoutMs = 30_000, +): Promise | null> { + const ev = (payload as Record).hook_event_name ?? "?"; + dbg(`sendAndWaitResponse → ${ev} (timeout=${timeoutMs}ms, bridge=${BRIDGE_PATH})`); + return new Promise((resolve) => { + if (!existsSync(BRIDGE_PATH)) { + dbg(` bridge not found → ${ev}`); + resolve(null); + return; + } + try { + const child = execFile( + BRIDGE_PATH, + [], + { timeout: timeoutMs, maxBuffer: 1_048_576 }, + (error, stdout) => { + if (error) { + dbg(` bridge error → ${ev}: ${(error as Error).message.slice(0, 120)}`); + resolve(null); + return; + } + try { + const parsed = JSON.parse(stdout); + dbg(` bridge OK → ${ev}`); + resolve(parsed); + } catch { + dbg(` bridge parse fail → ${ev} (stdout len=${stdout.length})`); + resolve(null); + } + }, + ); + child.stdin!.write(JSON.stringify(payload)); + child.stdin!.end(); + } catch (err) { + dbg(` bridge throw → ${ev}: ${(err as Error).message}`); + resolve(null); + } + }); +} + +// ── Event builders ──────────────────────────────────────────────────────────── + +/** + * Builds the base fields required on every CodeIsland event payload. + * + * @param sessionId - Pi session UUID (prefixed with `"pi-"`). + * @param cwd - Current working directory. + * @param extra - Event-specific fields merged into the base. + * @returns Complete event payload ready for `sendToSocket`. + */ +function base( + sessionId: string, + cwd: string, + extra: Record, + tty: string | null, +): Record { + return { + session_id: `pi-${sessionId}`, + _source: "pi", + _ppid: process.pid, + _env: collectEnv(), + _tty: tty, + _server_port: 0, + cwd, + ...extra, + }; +} + +/** Capitalises the first character of a tool name for display. */ +function displayToolName(name: string): string { + return name.charAt(0).toUpperCase() + name.slice(1); +} + +/** Extracts plain text from the last assistant message in an event.messages array. */ +function extractLastAssistantText( + messages: readonly unknown[], +): string { + const assistants = messages.filter( + (m): m is { role: "assistant"; content: unknown } => + !!m && + typeof m === "object" && + (m as { role?: string }).role === "assistant", + ); + const last = assistants.at(-1); + if (!last) return ""; + const content = last.content; + if (!Array.isArray(content)) return ""; + return content + .filter((c): c is { type: "text"; text: string } => c?.type === "text") + .map((c) => c.text) + .join("") + .trim(); +} + +// ── Extension ───────────────────────────────────────────────────────────────── + +export default function codeislandExtension(pi: ExtensionAPI) { + /** TTY path detected once at startup. */ + const tty = detectTty(); + + /** + * Session IDs for which a blocking PermissionRequest is currently in flight. + * Non-lifecycle events for these sessions are suppressed to prevent CodeIsland's + * "answered externally" heuristic from auto-denying while the card is visible. + */ + const pendingPermissionSessions = new Set(); + /** Sessions for which CodeIsland has already received SessionStart. */ + const startedSessions = new Set(); + + async function ensureSessionStarted(sessionId: string, cwd: string): Promise { + const sid = `pi-${sessionId}`; + if (startedSessions.has(sid)) return; + + dbg(`ensureSessionStarted → ${sid} (cwd=${cwd})`); + const sessionName = pi.getSessionName(); + const ok = await sendToSocket( + base(sessionId, cwd, { + hook_event_name: "SessionStart", + ...(sessionName ? { session_title: sessionName } : {}), + }, tty), + ); + dbg(` SessionStart sent → ${sid} (ok=${ok})`); + startedSessions.add(sid); + } + + + /** + * Forwards an `ask` tool call to CodeIsland as an AskUserQuestion and waits + * for the user's on-island answer (#244). + * + * @returns A block result carrying the answers when the user answered in + * CodeIsland, or `null` when the question should fall through to + * OMP's own TUI dialog (skipped, denied, or CodeIsland not running). + */ + async function forwardAskToCodeIsland( + event: { input: Record; toolCallId: string }, + ctx: { cwd: string }, + sessionId: string, + sid: string, + tty: string | null, + ): Promise<{ block: true; reason: string } | null> { + const rawQuestions = Array.isArray(event.input.questions) + ? (event.input.questions as Array>) + : []; + if (rawQuestions.length === 0) return null; + + // Map OMP's ask schema → Claude-style AskUserQuestion input. + const questions = rawQuestions.map((q) => { + const options = Array.isArray(q.options) + ? (q.options as Array>) + .map((o) => ({ + label: typeof o.label === "string" ? o.label : "", + ...(typeof o.description === "string" + ? { description: o.description } + : {}), + })) + .filter((o) => o.label.length > 0) + : []; + return { + question: typeof q.question === "string" ? q.question : "Question", + ...(typeof q.id === "string" && q.id ? { header: q.id } : {}), + multiSelect: q.multi === true, + options, + }; + }); + + // CodeIsland keys answers by question text, deduping repeats with `_2`, + // `_3`… suffixes — reproduce that here so we can translate back to ids. + const usedKeys = new Set(); + const answerKeys = questions.map(({ question }) => { + let key = question; + if (usedKeys.has(key)) { + let suffix = 2; + while (usedKeys.has(`${question}_${suffix}`)) suffix += 1; + key = `${question}_${suffix}`; + } + usedKeys.add(key); + return key; + }); + + pendingPermissionSessions.add(sid); + let response: Record | null = null; + try { + response = await sendAndWaitResponse( + base(sessionId, ctx.cwd, { + hook_event_name: "PermissionRequest", + tool_name: "AskUserQuestion", + tool_input: { questions }, + _pi_tool_call_id: event.toolCallId, + }, tty), + 86_400_000, // waiting on a human — same 24h budget as PermissionRequest hooks + ); + } finally { + pendingPermissionSessions.delete(sid); + } + + const decision = ( + response?.hookSpecificOutput as Record | undefined + )?.decision as Record | undefined; + if (decision?.behavior !== "allow") return null; + + const updatedInput = decision.updatedInput as + | Record + | undefined; + const answers = (updatedInput?.answers ?? {}) as Record; + + const lines = rawQuestions.map((q, i) => { + const id = typeof q.id === "string" && q.id ? q.id : `q${i + 1}`; + const value = answers[answerKeys[i]]; + const text = Array.isArray(value) + ? value.map(String).join(", ") + : typeof value === "string" + ? value + : ""; + return `${id}: ${text || "(no answer)"}`; + }); + + return { + block: true, + reason: + "The user already answered these questions through the CodeIsland desktop app. " + + "Their answers:\n" + + lines.join("\n") + + "\nDo not ask again — proceed using these answers.", + }; + } + + // ── Session lifecycle ────────────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + await ensureSessionStarted(sessionId, ctx.cwd); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + await sendToSocket( + base(sessionId, ctx.cwd, { hook_event_name: "SessionEnd" }, tty), + ); + startedSessions.delete(`pi-${sessionId}`); + }); + + // ── Agent lifecycle ──────────────────────────────────────────────────────── + + pi.on("before_agent_start", async (event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + const sid = `pi-${sessionId}`; + await ensureSessionStarted(sessionId, ctx.cwd); + + if (pendingPermissionSessions.has(sid)) return; + + const prompt = event.prompt ?? ""; + await sendToSocket( + base(sessionId, ctx.cwd, { + hook_event_name: "UserPromptSubmit", + prompt, + }, tty), + ); + }); + + pi.on("agent_end", async (event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + const sid = `pi-${sessionId}`; + await ensureSessionStarted(sessionId, ctx.cwd); + + if (pendingPermissionSessions.has(sid)) return; + + const lastAssistantMessage = extractLastAssistantText(event.messages); + const sessionName = pi.getSessionName(); + + await sendToSocket( + base(sessionId, ctx.cwd, { + hook_event_name: "Stop", + last_assistant_message: lastAssistantMessage || undefined, + ...(sessionName ? { session_title: sessionName } : {}), + }, tty), + ); + }); + + // ── Tool calls ───────────────────────────────────────────────────────────── + + pi.on("tool_call", async (event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + const sid = `pi-${sessionId}`; + dbg(`tool_call → ${event.toolName} (sid=${sid})`); + await ensureSessionStarted(sessionId, ctx.cwd); + const toolName = displayToolName(event.toolName); + + // Build a tool_input object appropriate for the tool type. + const toolInput: Record = { ...event.input }; + if (event.toolName === "bash") { + const command = event.input.command as string | undefined; + if (command) toolInput.patterns = [command]; + } + if (event.toolName === "edit" || event.toolName === "write") { + const path = event.input.path as string | undefined; + if (path) toolInput.file_path = path; + } + + // `ask` tool → mirror the question into CodeIsland's question UI (#244). + // tool_call fires BEFORE the TUI dialog opens and OMP awaits this handler, + // so we can hold the tool, let the user answer on the island (or watch/ + // phone), and feed the answers back by blocking the tool with a result + // message. Skip/deny or an unreachable CodeIsland falls through to OMP's + // own TUI dialog — graceful degradation, never a lost question. + if (event.toolName === "ask") { + const answered = await forwardAskToCodeIsland(event, ctx, sessionId, sid, tty); + if (answered) return answered; + return undefined; + } + + // Dangerous bash → send blocking PermissionRequest via bridge. + if ( + event.toolName === "bash" && + typeof event.input.command === "string" && + isDangerous(event.input.command) + ) { + dbg(` DANGEROUS bash → ${sid}, blocking via bridge: ${event.input.command.slice(0, 80)}`); + pendingPermissionSessions.add(sid); + + const payload = base(sessionId, ctx.cwd, { + hook_event_name: "PermissionRequest", + tool_name: toolName, + tool_input: toolInput, + _pi_tool_call_id: event.toolCallId, + }, tty); + + let response: Record | null = null; + try { + response = await sendAndWaitResponse(payload, 20_000); + } finally { + pendingPermissionSessions.delete(sid); + } + + const behavior = ( + response?.hookSpecificOutput as Record | undefined + )?.decision as Record | undefined; + + if (behavior?.behavior === "deny") { + return { block: true, reason: "Blocked by CodeIsland" }; + } + + // Approved — fall through to normal PreToolUse event below. + } + + dbg(` PreToolUse → ${toolName} (sid=${sid}, pending=${pendingPermissionSessions.has(sid)})`); + // Non-blocking PreToolUse for all other tool calls. + if (!pendingPermissionSessions.has(sid)) { + await sendToSocket( + base(sessionId, ctx.cwd, { + hook_event_name: "PreToolUse", + tool_name: toolName, + tool_input: toolInput, + }, tty), + ); + } + + return undefined; + }); + + pi.on("tool_result", async (_event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + const sid = `pi-${sessionId}`; + await ensureSessionStarted(sessionId, ctx.cwd); + + if (pendingPermissionSessions.has(sid)) return; + + await sendToSocket( + base(sessionId, ctx.cwd, { hook_event_name: "PostToolUse" }, tty), + ); + }); + + // ── Compaction ───────────────────────────────────────────────────────────── + + pi.on("session_before_compact", async (_event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + await ensureSessionStarted(sessionId, ctx.cwd); + await sendToSocket( + base(sessionId, ctx.cwd, { hook_event_name: "PreCompact" }, tty), + ); + }); + + pi.on("session_compact", async (_event, ctx) => { + const sessionId = ctx.sessionManager.getSessionId(); + await ensureSessionStarted(sessionId, ctx.cwd); + await sendToSocket( + base(sessionId, ctx.cwd, { hook_event_name: "PostCompact" }, tty), + ); + }); +} diff --git a/Sources/CodeIsland/Resources/codex-proxy.py b/Sources/CodeIsland/Resources/codex-proxy.py new file mode 100755 index 00000000..07d995de --- /dev/null +++ b/Sources/CodeIsland/Resources/codex-proxy.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +CodeIsland Codex proxy — monitors the Codex state database for thread activity. + +Only tracks sessions that are ACTIVE (updated_at_ms or transcript mtime within +last 5 min). Sends SessionStart when a session becomes active, UserPromptSubmit +while working, and Stop when idle. After 5 min idle, the session is removed. + +The ChatGPT desktop app's app-server doesn't fire hooks, so this proxy polls +the threads table to detect session activity. +""" +import json, os, sqlite3, subprocess, time + +CODEX_HOME = os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex")) +STATE_DB = os.path.join(CODEX_HOME, "state_5.sqlite") +BRIDGE = os.path.expanduser("~/.codeisland/codeisland-bridge") +POLL_INTERVAL = 2 +ACTIVE_THRESHOLD_MS = 300000 # 5 min — only track recently active sessions +STALE_TIMEOUT_MS = 120000 # 2 min idle = send Stop +REMOVE_TIMEOUT_MS = 300000 # 5 min idle = remove from tracking + +def send(event, sid, cwd, **extra): + payload = {"hook_event_name": event, "session_id": sid, "cwd": cwd, + "_source": "codex", "source": "codex"} + payload.update(extra) + try: + subprocess.run([BRIDGE, "--source", "codex"], input=json.dumps(payload), + capture_output=True, text=True, timeout=5) + except Exception: + pass + +def get_active_threads(): + """Get threads with recent activity (updated_at_ms within ACTIVE_THRESHOLD).""" + try: + conn = sqlite3.connect("file:{}?mode=ro".format(STATE_DB), uri=True) + conn.row_factory = sqlite3.Row + now_ms = int(time.time() * 1000) + cutoff = now_ms - ACTIVE_THRESHOLD_MS + rows = conn.execute( + "SELECT id, rollout_path, cwd, updated_at_ms, title, model " + "FROM threads WHERE archived = 0 AND updated_at_ms > ? " + "ORDER BY updated_at_ms DESC LIMIT 50", + (cutoff,) + ).fetchall() + conn.close() + return rows + except Exception: + return [] + +def transcript_mtime_ms(rollout_path): + try: + return int(os.path.getmtime(rollout_path) * 1000) + except Exception: + return 0 + +def main(): + tracked = {} + print("[codex-proxy] Started. Waiting for active sessions...", flush=True) + + while True: + now_ms = int(time.time() * 1000) + rows = get_active_threads() + current = {} + for row in rows: + tid = row["id"] + db_updated = row["updated_at_ms"] + ts = transcript_mtime_ms(row["rollout_path"]) + last_activity = max(db_updated, ts) + current[tid] = { + "cwd": row["cwd"], + "title": row["title"] or "", + "model": row["model"] or "gpt-5.5", + "last_activity": last_activity, + } + + # Detect newly active sessions + for tid, info in current.items(): + if tid not in tracked: + tracked[tid] = { + "cwd": info["cwd"], "last_activity": info["last_activity"], + "active": True, "idle_since": 0 + } + send("SessionStart", tid, info["cwd"], model=info["model"]) + send("UserPromptSubmit", tid, info["cwd"], + model=info["model"], prompt=info["title"]) + print("[codex-proxy] NEW active: {} cwd={}".format( + tid[:12], info["cwd"].split("/")[-1]), flush=True) + else: + if info["last_activity"] > tracked[tid]["last_activity"]: + tracked[tid]["last_activity"] = info["last_activity"] + if not tracked[tid]["active"]: + tracked[tid]["active"] = True + tracked[tid]["idle_since"] = 0 + send("SessionStart", tid, info["cwd"], model=info["model"]) + send("UserPromptSubmit", tid, info["cwd"], + model=info["model"], prompt=info["title"]) + + # Detect idle and stale sessions + for tid, info in list(tracked.items()): + if info["active"] and (now_ms - info["last_activity"]) > STALE_TIMEOUT_MS: + send("Stop", tid, info["cwd"], last_assistant_message="") + info["active"] = False + info["idle_since"] = now_ms + print("[codex-proxy] IDLE: {}".format(tid[:12]), flush=True) + + # Remove sessions idle for too long or no longer active + should_remove = False + if not info["active"] and info["idle_since"] and \ + (now_ms - info["idle_since"]) > REMOVE_TIMEOUT_MS: + should_remove = True + if tid not in current and not info["active"]: + should_remove = True + + if should_remove: + send("SessionEnd", tid, info["cwd"]) + del tracked[tid] + print("[codex-proxy] REMOVED: {}".format(tid[:12]), flush=True) + + time.sleep(POLL_INTERVAL) + +if __name__ == "__main__": + main() diff --git a/Sources/CodeIsland/Resources/omp-proxy.py b/Sources/CodeIsland/Resources/omp-proxy.py new file mode 100755 index 00000000..5b194b4d --- /dev/null +++ b/Sources/CodeIsland/Resources/omp-proxy.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +CodeIsland OMP proxy — monitors OMP session JSONL transcripts for activity. + +Only tracks sessions that are ACTIVE (transcript modified in the last 5 min). +Sends SessionStart when a session becomes active, PostToolUse while working, +and Stop when it goes idle. After 5 min of inactivity the session is removed +from tracking (CodeIsland will clean it up from the panel). + +Sessions that are already idle on startup are NOT tracked — only newly active +ones get reported. +""" +import json, os, glob, time, subprocess + +OMP_SESSIONS_DIR = os.path.join(os.path.expanduser("~/.omp/agent/sessions")) +BRIDGE = os.path.expanduser("~/.codeisland/codeisland-bridge") +POLL_INTERVAL = 2 +ACTIVE_THRESHOLD_S = 300 # transcript modified within last 5 min = active +STALE_TIMEOUT_S = 120 # 2 min of no activity = idle (send Stop) +REMOVE_TIMEOUT_S = 300 # 5 min idle = remove from tracking + +def send(event, sid, cwd, **extra): + payload = {"hook_event_name": event, "session_id": sid, "cwd": cwd, + "_source": "pi", "source": "pi"} + payload.update(extra) + try: + subprocess.run([BRIDGE, "--source", "pi"], input=json.dumps(payload), + capture_output=True, text=True, timeout=5) + except Exception: + pass + +def get_active_sessions(): + """Find OMP sessions with recently modified JSONL transcripts.""" + sessions = {} + if not os.path.isdir(OMP_SESSIONS_DIR): + return sessions + now = time.time() + for d in os.listdir(OMP_SESSIONS_DIR): + dir_path = os.path.join(OMP_SESSIONS_DIR, d) + if not os.path.isdir(dir_path): + continue + jsonls = glob.glob(os.path.join(dir_path, "*.jsonl")) + if not jsonls: + continue + latest = max(jsonls, key=lambda p: os.path.getmtime(p)) + mtime = os.path.getmtime(latest) + # Only track sessions active in the last 5 min + if (now - mtime) > ACTIVE_THRESHOLD_S: + continue + # Extract session ID from filename: timestamp_uuid.jsonl + basename = os.path.basename(latest) + parts = basename.replace(".jsonl", "").split("_", 1) + sid = parts[1] if len(parts) > 1 else basename + # Read cwd from JSONL + cwd = "" + try: + result = subprocess.run(["grep", "-m1", '"cwd"', latest], + capture_output=True, text=True, timeout=2) + if result.stdout: + _d = json.loads(result.stdout.strip()) + cwd = _d.get("cwd", "") + except Exception: + pass + if not cwd: + if d.startswith("-"): + cwd = os.path.expanduser("~") + "/" + d[1:] + else: + cwd = os.path.expanduser("~/" + d) + sessions[sid] = {"cwd": cwd, "mtime": mtime, "path": latest} + return sessions + +def main(): + tracked = {} # {sid: {cwd, mtime, active, idle_since}} + print("[omp-proxy] Started. Waiting for active sessions...", flush=True) + + while True: + now = time.time() + current = get_active_sessions() + + # Detect newly active sessions + for sid, info in current.items(): + if sid not in tracked: + tracked[sid] = { + "cwd": info["cwd"], "mtime": info["mtime"], + "active": True, "idle_since": 0 + } + send("SessionStart", sid, info["cwd"]) + print("[omp-proxy] NEW active: {} cwd={}".format( + sid[:12], info["cwd"].split("/")[-1]), flush=True) + else: + if info["mtime"] > tracked[sid]["mtime"]: + tracked[sid]["mtime"] = info["mtime"] + if not tracked[sid]["active"]: + # Session became active again + tracked[sid]["active"] = True + tracked[sid]["idle_since"] = 0 + send("SessionStart", sid, info["cwd"]) + send("PostToolUse", sid, info["cwd"]) + + # Detect idle and stale sessions + for sid, info in list(tracked.items()): + if info["active"] and (now - info["mtime"]) > STALE_TIMEOUT_S: + send("Stop", sid, info["cwd"], last_assistant_message="") + info["active"] = False + info["idle_since"] = now + print("[omp-proxy] IDLE: {}".format(sid[:12]), flush=True) + + # Remove sessions idle for too long + if not info["active"] and info["idle_since"] and \ + (now - info["idle_since"]) > REMOVE_TIMEOUT_S: + send("SessionEnd", sid, info["cwd"]) + del tracked[sid] + print("[omp-proxy] REMOVED: {}".format(sid[:12]), flush=True) + + # Remove sessions no longer in current active list and already idle + if sid not in current and not info["active"]: + send("SessionEnd", sid, info["cwd"]) + del tracked[sid] + print("[omp-proxy] REMOVED (gone): {}".format(sid[:12]), flush=True) + + time.sleep(POLL_INTERVAL) + +if __name__ == "__main__": + main() diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index d3cfa38c..b530875e 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -61,6 +61,8 @@ enum SettingsKey { static let quietHoursEnabled = "quietHoursEnabled" static let quietHoursStart = "quietHoursStart" static let quietHoursEnd = "quietHoursEnd" + // Defer notification popups and sounds until the user stops typing (off by default) + static let awaitFinishTyping = "awaitFinishTyping" // Session cards static let showGitBranch = "showGitBranch" @@ -164,6 +166,7 @@ struct SettingsDefaults { static let quietHoursEnabled = false static let quietHoursStart = 22 * 60 static let quietHoursEnd = 8 * 60 + static let awaitFinishTyping = false static let showGitBranch = true static let showUsageStats = true @@ -246,6 +249,7 @@ class SettingsManager { SettingsKey.quietHoursEnabled: SettingsDefaults.quietHoursEnabled, SettingsKey.quietHoursStart: SettingsDefaults.quietHoursStart, SettingsKey.quietHoursEnd: SettingsDefaults.quietHoursEnd, + SettingsKey.awaitFinishTyping: SettingsDefaults.awaitFinishTyping, SettingsKey.showGitBranch: SettingsDefaults.showGitBranch, SettingsKey.showUsageStats: SettingsDefaults.showUsageStats, SettingsKey.rotationInterval: SettingsDefaults.rotationInterval, diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index 02dfcb31..f04d1123 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -12,6 +12,7 @@ enum SettingsPage: String, Identifiable, Hashable { case mascots case sound case shortcuts + case usage case remote case hooks case buddy @@ -27,6 +28,7 @@ enum SettingsPage: String, Identifiable, Hashable { case .mascots: return "person.2.fill" case .sound: return "speaker.wave.2.fill" case .shortcuts: return "command.circle.fill" + case .usage: return "chart.bar.fill" case .remote: return "network" case .hooks: return "link.circle.fill" case .buddy: return "dot.radiowaves.left.and.right" @@ -42,6 +44,7 @@ enum SettingsPage: String, Identifiable, Hashable { case .mascots: return .pink case .sound: return .green case .shortcuts: return .indigo + case .usage: return .teal case .remote: return .mint case .hooks: return .purple case .buddy: return .red @@ -56,7 +59,7 @@ private struct SidebarGroup: Hashable { } private let sidebarGroups: [SidebarGroup] = [ - SidebarGroup(title: nil, pages: [.general, .behavior, .appearance, .mascots, .sound, .shortcuts]), + SidebarGroup(title: nil, pages: [.general, .behavior, .appearance, .mascots, .sound, .shortcuts, .usage]), SidebarGroup(title: "CodeIsland", pages: [.remote, .hooks, .buddy, .about]), ] @@ -66,7 +69,15 @@ struct SettingsView: View { @ObservedObject private var l10n = L10n.shared @State private var selectedPage: SettingsPage = .general var appState: AppState? + var initialPage: SettingsPage? = nil + init(appState: AppState?, initialPage: SettingsPage? = nil) { + self.appState = appState + self.initialPage = initialPage + if let page = initialPage { + _selectedPage = State(initialValue: page) + } + } var body: some View { NavigationSplitView { List(selection: $selectedPage) { @@ -94,6 +105,7 @@ struct SettingsView: View { case .mascots: MascotsPage() case .sound: SoundPage() case .shortcuts: ShortcutsPage() + case .usage: UsagePage(appState: appState) case .remote: RemoteHostsPage() case .hooks: HooksPage() case .buddy: BuddyPage() @@ -1179,6 +1191,7 @@ private struct SoundPage: View { @AppStorage(SettingsKey.quietHoursEnabled) private var quietHoursEnabled = SettingsDefaults.quietHoursEnabled @AppStorage(SettingsKey.quietHoursStart) private var quietHoursStart = SettingsDefaults.quietHoursStart @AppStorage(SettingsKey.quietHoursEnd) private var quietHoursEnd = SettingsDefaults.quietHoursEnd + @AppStorage(SettingsKey.awaitFinishTyping) private var awaitFinishTyping = SettingsDefaults.awaitFinishTyping /// DatePicker works in wall-clock Dates; storage is minutes since midnight. private func timeBinding(_ minutes: Binding) -> Binding { @@ -1266,6 +1279,15 @@ private struct SoundPage: View { .datePickerStyle(.field) } } + + Section { + VStack(alignment: .leading, spacing: 2) { + Toggle("Await finish typing", isOn: $awaitFinishTyping) + Text("Defer approval prompts, question cards, and notification sounds until you stop typing. Shows 5s after the last keystroke. No keystrokes are recorded.") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } } } .formStyle(.grouped) @@ -2395,6 +2417,789 @@ private struct ShortcutsPage: View { } } +// MARK: - Usage / Token History Page + +private struct UsagePage: View { + let appState: AppState? + @ObservedObject private var l10n = L10n.shared + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Header + HStack(spacing: 10) { + Image(systemName: "chart.bar.fill") + .font(.title2) + .foregroundStyle(.teal) + Text("Token Usage History") + .font(.title2.bold()) + } + .padding(.bottom, 5) + + + if let usage = appState?.claudeUsage { + // 14-day summary + UsageSummaryCard(dailyTotals: usage.dailyTotals) + + // Per-AI breakdown + if !usage.perSource.isEmpty { + UsagePerSourceCard(sources: usage.perSource) + } + + // 14-day bar chart + UsageHistoryCard(dailyTotals: usage.dailyTotals, scannedAt: usage.scannedAt) + + // Today and 5h breakdown + UsageBreakdownCard(last5h: usage.last5h, today: usage.today) + + // Hourly sparkline (last 12h) with hour labels + if !usage.hourlyOutputTokens.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Last 12 Hours (Output Tokens)") + .font(.headline) + VStack(spacing: 4) { + UsageSparklineLarge(buckets: usage.hourlyOutputTokens) + .frame(maxWidth: .infinity) + + // Hour labels under the chart + HStack(spacing: 0) { + ForEach(0.. Color { + switch name { + case "Claude Code": return .orange // Clawd — orange keyboard + case "OMP": return .teal // Pi — teal terminal + case "Codex": return .gray // Dex — black/white cloud + case "Cursor": return Color(red: 0.93, green: 0.93, blue: 0.93) // Cursor — light face + default: return .teal + } + } + + /// Map a usage source name to the MascotView source identifier. + private func mascotSource(_ name: String) -> String { + switch name { + case "Claude Code": return "claude" + case "OMP": return "omp" + case "Codex": return "codex" + case "Cursor": return "cursor" + default: return "" + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Per-AI Breakdown (14 Days)") + .font(.headline) + + ForEach(sources, id: \.name) { source in + let totalTokens = source.total.inputTokens + source.total.outputTokens + + source.total.cacheCreationTokens + source.total.cacheReadTokens + let allTotal = sources.reduce(0) { $0 + $1.total.inputTokens + $1.total.outputTokens + + $1.total.cacheCreationTokens + $1.total.cacheReadTokens } + let pct = allTotal > 0 ? Double(totalTokens) / Double(allTotal) * 100 : 0 + let color = sourceColor(source.name) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + // Small mascot icon + MascotView(source: mascotSource(source.name), status: .idle, size: 24) + .frame(width: 24, height: 24) + + Text(source.name) + .font(.system(.body, design: .default).bold()) + Spacer() + Text("\(ClaudeUsageScanner.formatTokens(totalTokens)) · \(String(format: "%.0f%%", pct))") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(color) + } + + // Progress bar — uses source color + GeometryReader { geo in + RoundedRectangle(cornerRadius: 3) + .fill(color.opacity(0.2)) + .overlay(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(color.opacity(0.7)) + .frame(width: geo.size.width * CGFloat(pct / 100)) + } + } + .frame(height: 6) + + // Mini stats — offset to align with text after mascot + HStack(spacing: 16) { + Text("In: \(ClaudeUsageScanner.formatTokens(source.total.inputTokens + source.total.cacheCreationTokens))") + Text("Out: \(ClaudeUsageScanner.formatTokens(source.total.outputTokens))") + Text("Cache: \(ClaudeUsageScanner.formatTokens(source.total.cacheReadTokens))") + Text("Msgs: \(source.total.messageCount)") + } + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(.leading, 34) // align with text after 24pt mascot + 10pt spacing + } + .padding(.vertical, 4) + } + + // Mini bar chart per source + if sources.count > 1 { + Divider().padding(.vertical, 4) + ForEach(sources, id: \.name) { source in + let color = sourceColor(source.name) + UsageSourceBars(dailyTotals: source.dailyTotals, color: color) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(RoundedRectangle(cornerRadius: 10).fill(.background.secondary)) + } +} + +private struct UsageSourceBars: View { + let dailyTotals: [ClaudeUsageTotals] + var color: Color = .teal + + var body: some View { + let peak = max(dailyTotals.map { $0.inputTokens + $0.outputTokens }.max() ?? 0, 1) + GeometryReader { geo in + HStack(alignment: .bottom, spacing: 2) { + ForEach(Array(dailyTotals.enumerated()), id: \.offset) { _, total in + let value = total.inputTokens + total.outputTokens + RoundedRectangle(cornerRadius: 1) + .fill(color.opacity(0.5)) + .frame(height: max(2, CGFloat(value) / CGFloat(peak) * geo.size.height)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + .frame(height: 24) + } +} + +private struct UsageMetric: View { + let label: String + let value: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.system(.title3, design: .monospaced).bold()) + .foregroundStyle(color) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private struct UsageHistoryCard: View { + let dailyTotals: [ClaudeUsageTotals] + let scannedAt: Date + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Daily Usage (14 Days)") + .font(.headline) + UsageHistoryBars(dailyTotals: dailyTotals, scannedAt: scannedAt) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(RoundedRectangle(cornerRadius: 10).fill(.background.secondary)) + } +} + +private struct UsageHistoryBars: View { + let dailyTotals: [ClaudeUsageTotals] + let scannedAt: Date + + var body: some View { + let peak = max(dailyTotals.map { $0.inputTokens + $0.outputTokens }.max() ?? 0, 1) + let cal = Calendar.current + let weekdays = ["S", "M", "T", "W", "T", "F", "S"] + + HStack(alignment: .bottom, spacing: 4) { + ForEach(Array(dailyTotals.enumerated()), id: \.offset) { idx, total in + let totalTokens = total.inputTokens + total.outputTokens + let date = cal.date(byAdding: .day, value: -(dailyTotals.count - 1 - idx), to: cal.startOfDay(for: scannedAt)) ?? scannedAt + let weekday = weekdays[cal.component(.weekday, from: date) - 1] + let isToday = idx == dailyTotals.count - 1 + + VStack(spacing: 3) { + Text(totalTokens > 0 ? ClaudeUsageScanner.formatTokens(totalTokens) : "") + .font(.system(size: 7, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + RoundedRectangle(cornerRadius: 2) + .fill(isToday ? Color.teal.opacity(0.8) : Color.teal.opacity(0.35)) + .frame(width: 18, height: max(2, CGFloat(totalTokens) / CGFloat(peak) * 80)) + Text(weekday) + .font(.system(size: 8)) + .foregroundStyle(isToday ? .teal : .secondary) + } + .help("\(cal.component(.month, from: date))/\(cal.component(.day, from: date)): in \(ClaudeUsageScanner.formatTokens(total.inputTokens)) · out \(ClaudeUsageScanner.formatTokens(total.outputTokens))") + } + } + .frame(height: 100, alignment: .bottom) + } +} + +private struct UsageBreakdownCard: View { + let last5h: ClaudeUsageTotals + let today: ClaudeUsageTotals + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Today Breakdown") + .font(.headline) + HStack(spacing: 20) { + VStack(alignment: .leading) { + Text("Last 5 Hours").font(.caption).foregroundStyle(.secondary) + Text("\(ClaudeUsageScanner.formatTokens(last5h.inputTokens + last5h.cacheCreationTokens))↑ \(ClaudeUsageScanner.formatTokens(last5h.outputTokens))↓") + .font(.system(.body, design: .monospaced)) + } + Spacer() + VStack(alignment: .leading) { + Text("Today").font(.caption).foregroundStyle(.secondary) + Text("\(ClaudeUsageScanner.formatTokens(today.inputTokens + today.cacheCreationTokens))↑ \(ClaudeUsageScanner.formatTokens(today.outputTokens))↓") + .font(.system(.body, design: .monospaced)) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(RoundedRectangle(cornerRadius: 10).fill(.background.secondary)) + } +} + +private struct UsageSparklineLarge: View { + let buckets: [Int] + + var body: some View { + let peak = max(buckets.max() ?? 0, 1) + GeometryReader { geo in + let n = max(buckets.count, 1) + let spacing: CGFloat = 3 + let barWidth = max(4, (geo.size.width - CGFloat(n - 1) * spacing) / CGFloat(n)) + HStack(alignment: .bottom, spacing: spacing) { + ForEach(Array(buckets.enumerated()), id: \.offset) { _, value in + RoundedRectangle(cornerRadius: 3) + .fill(value == 0 ? Color.gray.opacity(0.2) : Color.teal.opacity(0.7)) + .frame(width: barWidth, height: max(3, CGFloat(value) / CGFloat(peak) * geo.size.height)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + .frame(height: 120) + } +} + private struct ShortcutRow: View { let action: ShortcutAction let isRecording: Bool diff --git a/Sources/CodeIsland/SettingsWindowController.swift b/Sources/CodeIsland/SettingsWindowController.swift index 53cbd8d0..da546fb6 100644 --- a/Sources/CodeIsland/SettingsWindowController.swift +++ b/Sources/CodeIsland/SettingsWindowController.swift @@ -16,7 +16,7 @@ class SettingsWindowController { } } - func show() { + func show(page: SettingsPage? = nil) { // Switch to regular activation policy so the window can receive focus NSApp.setActivationPolicy(.regular) // Use the actual bundle app icon so Dock matches the packaged asset catalog icon. @@ -28,7 +28,7 @@ class SettingsWindowController { return } - let settingsView = SettingsView(appState: appState) + let settingsView = SettingsView(appState: appState, initialPage: page) let hostingView = NSHostingView(rootView: settingsView) let screen = NSScreen.main ?? NSScreen.screens.first diff --git a/Sources/CodeIsland/SoundManager.swift b/Sources/CodeIsland/SoundManager.swift index c387c26d..bc145a2e 100644 --- a/Sources/CodeIsland/SoundManager.swift +++ b/Sources/CodeIsland/SoundManager.swift @@ -32,6 +32,12 @@ class SoundManager { func handleEvent(_ eventName: String) { guard defaults.bool(forKey: SettingsKey.soundEnabled) else { return } guard !quietHoursActive else { return } + // Defer notification sounds until the user stops typing (off by default). + // Waits 5s after the last keystroke before resuming. + if defaults.bool(forKey: SettingsKey.awaitFinishTyping), + KeyboardActivityMonitor.shared.isTyping { + return + } guard let entry = Self.eventSounds.first(where: { $0.event == eventName }) else { return } guard defaults.bool(forKey: entry.key) else { return } play(entry.sound) diff --git a/Sources/CodeIsland/TerminalActivator.swift b/Sources/CodeIsland/TerminalActivator.swift index e6a7f166..e21986d0 100644 --- a/Sources/CodeIsland/TerminalActivator.swift +++ b/Sources/CodeIsland/TerminalActivator.swift @@ -1,11 +1,15 @@ import AppKit import ApplicationServices import CodeIslandCore +import os.log /// Activates the terminal window/tab running a specific Claude Code session. /// Supports tab-level switching for: Ghostty, iTerm2, Terminal.app, WezTerm, kitty. /// Falls back to app-level activation for: Alacritty, Warp, Hyper, Tabby, Rio. struct TerminalActivator { + /// Visible debug logger for click-to-jump. Watch with: + /// log stream --predicate 'subsystem == "com.codeisland" AND category == "activator"' --level notice + private static let log = Logger(subsystem: "com.codeisland", category: "activator") // Internal (not private) so support tests can assert a terminal is recognized, // matching sourceToNativeAppBundleId's visibility. See TeraxSupportTests. static let knownTerminals: [(name: String, bundleId: String)] = [ @@ -75,8 +79,28 @@ struct TerminalActivator { "com.anthropic.claudefordesktop": "Claude", ] + /// Debounce: drop a duplicate activate() for the same session within this interval. + /// SwiftUI onTapGesture can fire twice per physical click (down+up on some views), + /// and the session card stacks two tap-gesture surfaces. Coalesce them so we don't + /// spawn multiple `cursor -r` / AppleScript activations that fight each other. + private static let activateDebounceInterval: TimeInterval = 0.6 + private static var lastActivateKey: String = "" + private static var lastActivateTime: Date = .distantPast + static func activate(session: SessionSnapshot, sessionId: String? = nil) { - guard !session.isRemote else { return } + let key = sessionId ?? session.cwd ?? "" + let now = Date() + if key == lastActivateKey, now.timeIntervalSince(lastActivateTime) < activateDebounceInterval { + log.notice("activate DEBOUNCE-DROP session=\(key, privacy: .public) (within \(String(format: "%.2f", now.timeIntervalSince(lastActivateTime)), privacy: .public)s)") + return + } + lastActivateKey = key + lastActivateTime = now + guard !session.isRemote else { + log.notice("activate SKIP remote session=\(sessionId ?? "nil", privacy: .public)") + return + } + log.notice("activate BEGIN session=\(sessionId ?? "nil", privacy: .public) source=\(session.source, privacy: .public) cwd=\(session.cwd ?? "", privacy: .public) termBundle=\(session.termBundleId ?? "nil", privacy: .public) tty=\(session.ttyPath ?? "nil", privacy: .public)") // Native app by bundle ID (e.g. Codex APP vs Codex CLI). These are IDE-style // apps (Cursor, Trae, Qoder, Factory, …) that can hold several workspace @@ -86,6 +110,7 @@ struct TerminalActivator { // no cwd or no matching window, so this never regresses single-window apps. if let bundleId = session.termBundleId, nativeAppBundles[bundleId] != nil { + log.notice("activate → IDE window (nativeAppBundle) bundle=\(bundleId, privacy: .public)") activateIDEWindow(bundleId: bundleId, cwd: session.cwd) return } @@ -93,12 +118,14 @@ struct TerminalActivator { // IDE integrated terminal: try window-level matching by CWD, fall back to app-level if session.isIDETerminal, let bundleId = session.termBundleId { + log.notice("activate → IDE window (integrated terminal) bundle=\(bundleId, privacy: .public)") activateIDEWindow(bundleId: bundleId, cwd: session.cwd) return } // IDE sources: just bring the app to front if let appName = appSources[session.source] { + log.notice("activate → IDE source app=\(appName, privacy: .public)") if let app = NSWorkspace.shared.runningApplications.first(where: { $0.localizedName == appName }) { @@ -110,31 +137,31 @@ struct TerminalActivator { return } - // --- Host GUI client detection (e.g. OpenChamber embedding OpenCode as a server) --- - // When an agent is spawned by a GUI client app — not from a terminal and not the - // agent's own desktop app — clicking the session should bring that host client to - // front instead of falling through to the terminal or the agent's native desktop - // app. Walk the CLI process ancestry looking for the nearest running regular GUI - // app that is neither a known terminal nor a known agent-native app; that app is - // the host. Generic by design: any current/future client (OpenChamber, …) works - // without hardcoding bundle IDs. Runs BEFORE the sourceToNativeAppBundleId branch - // below so a coincidentally-running agent desktop app never wins over the real host. if let hostBid = resolveHostClientBundleId(for: session) { + log.notice("activate → host GUI client bundle=\(hostBid, privacy: .public)") activateByBundleId(hostBid) return } - // When the source has a known desktop app that's running, prefer the desktop - // app over the terminal. This handles e.g. OpenCode CLI launched from Ghostty but - // editing in VS Code — without this, the inherited TERM_PROGRAM=ghostty would jump - // to the wrong terminal. Also covers cases where the terminal bundle ID is present - // but the user wants to focus the desktop IDE instead (e.g. opencode → OpenCode). + // Codex desktop app: bring the ChatGPT app to front. The app doesn't + // support deep-link navigation to existing chats (codex://threads/ only + // creates new threads), and the sidebar search field can't be focused + // externally without breaking the active editor. So we just activate + // the app — the user can manually find the chat in the sidebar. + if session.source == "codex" { + log.notice("activate → codex desktop app session=\(sessionId ?? "nil", privacy: .public) cwd=\(session.cwd ?? "nil", privacy: .public)") + activateByBundleId("com.openai.codex") + return + } if let nativeBundleId = sourceToNativeAppBundleId[session.source], NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == nativeBundleId }) { + log.notice("activate → native desktop app bundle=\(nativeBundleId, privacy: .public)") activateByBundleId(nativeBundleId) return } + log.notice("activate → terminal tab matching path termApp=\(session.termApp ?? "nil", privacy: .public)") + // Resolve terminal: bundle ID (most accurate) → TERM_PROGRAM → scan running apps let termApp: String if let bundleId = session.termBundleId, @@ -142,7 +169,6 @@ struct TerminalActivator { termApp = resolved } else { let raw = session.termApp ?? "" - // "tmux" / "screen" etc. are not GUI apps — fall back to scanning if raw.isEmpty || raw.lowercased() == "tmux" || raw.lowercased() == "screen" { termApp = detectRunningTerminal() } else { @@ -150,19 +176,11 @@ struct TerminalActivator { } } let lower = termApp.lowercased() + log.notice("activate resolved termApp=\(termApp, privacy: .public)") - // --- Superset (Electron agent-orchestration terminal): app-level activation --- - // Must come before the kitty/cmux/iTerm branches. Superset spoofs TERM_PROGRAM to - // "kitty" (so claude-code parses CSI-u keyboard input) and strips __CFBundleIdentifier - // from the PTY env, so without this the loose `lower.contains("kitty")` branch would - // launch/raise the REAL kitty app instead. Superset is a single-window Electron app with - // internal xterm.js panes and ships no focus/activate CLI or AppleScript dictionary, so - // pane precision is impossible upstream — we can only bring the Superset window forward - // (same app-level behavior as Warp / Alacritty fallback). The presence of any SUPERSET_* - // env var (captured into supersetWorkspaceId / supersetPaneId) uniquely identifies it. - // __CFBundleIdentifier is stripped so we can't tell canary from stable; default to the - // stable bundle and fall back to whichever variant is actually running. (#213) + // --- Superset --- if session.supersetWorkspaceId != nil || session.supersetPaneId != nil { + log.notice("activate → Superset") let supersetBundles = ["com.superset.desktop", "com.superset.desktop.canary"] let runningBundle = supersetBundles.first(where: { bid in NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bid }) @@ -171,12 +189,9 @@ struct TerminalActivator { return } - // --- Zellij multiplexer: precise pane → tab focus, then activate parent terminal --- - // Must come before tmux/cmux/iTerm/Ghostty branches AND the Terax branch below: - // Zellij runs *inside* one of those terminals, so termApp/termBundleId points to - // the host shell. The presence of zellijPaneId is what disambiguates "running - // inside Zellij" from "plain shell". + // --- Zellij --- if let zellijPane = session.zellijPaneId, !zellijPane.isEmpty { + log.notice("activate → Zellij pane=\(zellijPane, privacy: .public)") activateZellij( paneId: zellijPane, sessionName: session.zellijSessionName, @@ -185,104 +200,75 @@ struct TerminalActivator { return } - // --- Terax (native webview terminal): app-level activation only --- - // Like Superset, Terax (app.crynta.terax) is a single-window app whose tabs are - // drawn inside a webview. It exports no per-pane env id (only TERAX_TERMINAL / - // TERAX_BLOCKS) and ships no URL scheme, AppleScript dictionary, focus CLI, or - // native tab shortcut — and its tabs are absent from the accessibility tree — so - // per-tab precision is impossible upstream. Without this branch Terax has no - // TERM_PROGRAM, so detectRunningTerminal() would misroute the click to whichever - // other terminal happens to be running. Bring its window forward (Space-aware, - // same as Superset) via bundle id. Kept AFTER the Zellij branch so a Zellij - // session hosted in Terax still gets precise pane focus first. + // --- Terax --- if session.termBundleId == "app.crynta.terax" || lower == "terax" { + log.notice("activate → Terax") activateByBundleId("app.crynta.terax") return } - // --- tmux: switch pane first, then fall through to terminal-specific activation --- + // --- tmux --- if let pane = session.tmuxPane, !pane.isEmpty { + log.notice("activate → tmux pane=\(pane, privacy: .public)") activateTmux(pane: pane, tmuxEnv: session.tmuxEnv) } - - // In tmux, use the client TTY (outer terminal) for tab matching, - // since ttyPath is the inner tmux pty which won't match the terminal's tab. - // When tmux is detached (no client TTY), set effectiveTty to nil so terminal-specific - // handlers skip useless TTY matching and fall back to CWD or app-level activation. let inTmux = session.tmuxPane != nil && !(session.tmuxPane ?? "").isEmpty let effectiveTty: String? if inTmux { - effectiveTty = session.tmuxClientTty // nil when detached — intentional + effectiveTty = session.tmuxClientTty } else { effectiveTty = session.ttyPath } - // --- cmux: surface-level precise jump (workspace + surface) --- - // Must be handled before generic tab-switching logic to avoid degrading to bringToFront + // --- cmux --- if lower.contains("cmux") { - activateCmux( - surfaceId: session.cmuxSurfaceId, - workspaceId: session.cmuxWorkspaceId - ) + log.notice("activate → cmux") + activateCmux(surfaceId: session.cmuxSurfaceId, workspaceId: session.cmuxWorkspaceId) return } // --- Tab-level switching (5 terminals) --- - if lower.contains("iterm") { + log.notice("activate → iTerm tty=\(effectiveTty ?? "nil", privacy: .public) itermId=\(session.itermSessionId ?? "nil", privacy: .public)") if let itermId = session.itermSessionId, !itermId.isEmpty { activateITerm(sessionId: itermId) } else { - // No session ID — fall back to tty or cwd matching activateITermByTtyOrCwd(tty: effectiveTty, cwd: session.cwd) } return } - if lower == "ghostty" { - activateGhostty( - cwd: session.cwd, - tty: effectiveTty, - sessionId: sessionId, - source: session.source, - tmuxPane: session.tmuxPane, - tmuxEnv: session.tmuxEnv - ) + log.notice("activate → Ghostty tty=\(effectiveTty ?? "nil", privacy: .public)") + activateGhostty(cwd: session.cwd, tty: effectiveTty, sessionId: sessionId, source: session.source, tmuxPane: session.tmuxPane, tmuxEnv: session.tmuxEnv) return } - - // Match Terminal.app by bundle ID only — Warp sets TERM_PROGRAM=Apple_Terminal if session.termBundleId == "com.apple.Terminal" || (session.termBundleId == nil && lower == "terminal") { + log.notice("activate → Terminal.app tty=\(effectiveTty ?? "nil", privacy: .public) cwd=\(session.cwd ?? "nil", privacy: .public)") activateTerminalApp(ttyPath: effectiveTty, cwd: session.cwd) return } - - // Kaku is a WezTerm fork: same `cli list` JSON shape, different binary + bundle id. - // Match by bundle id (most reliable) or termApp string fallback. if session.termBundleId == "fun.tw93.kaku" || lower == "kaku" { + log.notice("activate → Kaku tty=\(effectiveTty ?? "nil", privacy: .public)") activateKaku(ttyPath: effectiveTty, cwd: session.cwd, paneId: session.weztermPaneId, cliPid: session.cliPid) return } - if lower.contains("wezterm") || lower.contains("wez") { + log.notice("activate → WezTerm tty=\(effectiveTty ?? "nil", privacy: .public)") activateWezTerm(ttyPath: effectiveTty, cwd: session.cwd, paneId: session.weztermPaneId, cliPid: session.cliPid) return } - if lower.contains("kitty") { + log.notice("activate → kitty") activateKitty(windowId: session.kittyWindowId, cwd: session.cwd, source: session.source) return } - - // --- Warp (SQLite pane precision jump + Cmd+N tab switch) --- if lower.contains("warp") { + log.notice("activate → Warp") activateWarp(cwd: session.cwd) return } - // --- Other terminals (Alacritty, Hyper, Tabby, Rio, etc.) --- - // Try window-level matching via System Events (title contains CWD folder name), - // similar to IDE window matching. Falls back to app-level if no match. + log.notice("activate → generic terminal window match bundle=\(session.termBundleId ?? "nil", privacy: .public)") if let bundleId = session.termBundleId, let cwd = session.cwd, !cwd.isEmpty { activateTerminalWindow(bundleId: bundleId, cwd: cwd, fallbackName: termApp) } else { @@ -677,15 +663,16 @@ struct TerminalActivator { guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.apple.Terminal" }) else { + log.notice("Terminal.app not running — launching") bringToFront("Terminal") return } if app.isHidden { app.unhide() } app.activate() + log.notice("Terminal.app activate tty=\(ttyPath ?? "nil", privacy: .public) cwd=\(cwd ?? "nil", privacy: .public)") let ttyEscaped = ttyPath.map(escapeAppleScript) ?? "" let dirEscaped = cwd.map { escapeAppleScript(($0 as NSString).lastPathComponent) } ?? "" - // Try tty → tab auto-name → user custom title → deminiaturize any window as a last resort. // Terminal.app auto-generates `name of t` containing the running command + cwd; `custom title` // only exists when the user set it explicitly, so matching against `name` works for the @@ -701,6 +688,7 @@ struct TerminalActivator { set targetTty to "\(ttyEscaped)" set targetDir to "\(dirEscaped)" set found to false + set strategy to 0 -- Strategy 1: precise tty match if targetTty is not "" then @@ -712,6 +700,7 @@ struct TerminalActivator { set selected tab of w to t set index of w to 1 set found to true + set strategy to 1 exit repeat end if end try @@ -730,6 +719,7 @@ struct TerminalActivator { set selected tab of w to t set index of w to 1 set found to true + set strategy to 2 exit repeat end if end try @@ -748,6 +738,7 @@ struct TerminalActivator { set selected tab of w to t set index of w to 1 set found to true + set strategy to 3 exit repeat end if end try @@ -758,6 +749,7 @@ struct TerminalActivator { -- Fallback: unminimize the first miniaturized window so the user always sees something. if not found then + set strategy to 4 repeat with w in windows try if miniaturized of w then @@ -770,6 +762,7 @@ struct TerminalActivator { end if activate + return strategy as text end tell -- Final fallback via System Events: when Terminal.app's `windows` collection doesn't @@ -790,7 +783,8 @@ struct TerminalActivator { end tell end try """ - runAppleScript(script) + let result = runAppleScriptReturningString(script) + log.notice("Terminal.app result strategy=\(result ?? "nil", privacy: .public) (1=tty,2=name,3=custom,4=unminimize)") } // MARK: - WezTerm (CLI: wezterm cli list + activate-tab) @@ -1029,33 +1023,75 @@ struct TerminalActivator { // MARK: - IDE window-level activation (JetBrains, VS Code, Zed, etc.) + + /// Returns the path to the bundled CLI for an Electron IDE (Cursor / VS Code), + /// if present. Used to focus the correct workspace window via `--reuse-window` + /// since System Events can't enumerate all Electron windows reliably. + private static func idEditorCLIPath(bundleId: String) -> String? { + guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId), + let appBundle = Bundle(url: appURL) else { return nil } + let resources = appBundle.bundlePath + "/Contents/Resources/app/bin" + // Cursor ships `cursor`; VS Code ships `code`. Prefer the first executable found. + let candidates = ["cursor", "code"] + for name in candidates { + let path = resources + "/" + name + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return nil + } + /// Activate the specific IDE window whose title contains the project folder name. /// Falls back to app-level activation if no CWD or no matching window found. private static func activateIDEWindow(bundleId: String, cwd: String?) { guard let cwd = cwd, !cwd.isEmpty else { + log.notice("IDE no cwd — app-level activation bundle=\(bundleId, privacy: .public)") activateByBundleId(bundleId) return } + if let cliPath = idEditorCLIPath(bundleId: bundleId) { + log.notice("IDE CLI reuse-window cli=\(cliPath, privacy: .public) cwd=\(cwd, privacy: .public)") + let proc = Process() + proc.executableURL = URL(fileURLWithPath: cliPath) + proc.arguments = ["-r", "--reuse-window", cwd] + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + do { + try proc.run() + // Give the CLI a moment to select the workspace window, then + // activate the app by bundle ID so macOS switches to the Space + // that window lives on. `--reuse-window` alone doesn't switch + // Spaces when the target window is on another desktop, so + // activateByBundleId (which uses NSWorkspace.openApplication) + // handles the Space transition while the CLI pinned the window. + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.3) { + log.notice("IDE CLI reuse-window sent — activating bundle for Space switch") + activateByBundleId(bundleId) + } + log.notice("IDE CLI reuse-window launched + Space-switch scheduled") + return + } catch { + log.error("IDE CLI reuse-window FAILED to launch: \(error.localizedDescription, privacy: .public) — falling back to AX") + } + } else { + log.notice("IDE CLI not found for bundle=\(bundleId, privacy: .public) — using AX fallback") + } + let folderName = (cwd as NSString).lastPathComponent guard !folderName.isEmpty else { activateByBundleId(bundleId) return } - guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleId }) else { activateByBundleId(bundleId) return } - if app.isHidden { app.unhide() } app.activate() - - // Use System Events to iterate windows and AXRaise the best match. - // Priority: exact folder name at word boundary > shortest title containing folder name. - // This avoids jumping to the wrong window when multiple projects share a folder name - // (e.g., /work/app vs /backup/app). + log.notice("IDE AX fallback folder=\(folderName, privacy: .public) appName=\(app.localizedName ?? "?", privacy: .public)") let appName = app.localizedName ?? "Application" let escapedFolder = escapeAppleScript(folderName) let script = """ @@ -1071,7 +1107,7 @@ struct TerminalActivator { set wLen to count of wName if wLen < bestLen then set bestWindow to w - set bestLen to wLen + set bestLen = wLen end if end if end try @@ -1085,6 +1121,19 @@ struct TerminalActivator { runAppleScript(script) } + private static func activateByBundleId(_ bundleId: String) { + if let app = NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == bundleId + }) { + if app.isHidden { app.unhide() } + app.activate() + } + // Also use openApplication for reliable Space switching (Electron apps like VSCode) + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) { + NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration()) + } + } + // MARK: - Generic terminal window matching (Alacritty, Warp, Hyper, etc.) /// For terminals without tab-switching APIs, try to raise the window whose title @@ -1093,6 +1142,7 @@ struct TerminalActivator { guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleId }) else { + log.notice("generic terminal not running — bringToFront=\(fallbackName, privacy: .public)") bringToFront(fallbackName) return } @@ -1103,12 +1153,14 @@ struct TerminalActivator { guard !folderName.isEmpty else { return } let appName = app.localizedName ?? fallbackName + let escapedFolder = escapeAppleScript(folderName) + log.notice("generic terminal window match folder=\(folderName, privacy: .public) app=\(appName, privacy: .public)") let script = """ tell application "System Events" tell process "\(escapeAppleScript(appName))" repeat with w in windows try - if name of w contains "\(escapeAppleScript(folderName))" then + if name of w contains "\(escapedFolder)" then perform action "AXRaise" of w return end if @@ -1120,21 +1172,6 @@ struct TerminalActivator { runAppleScript(script) } - // MARK: - Activate by bundle ID - - private static func activateByBundleId(_ bundleId: String) { - if let app = NSWorkspace.shared.runningApplications.first(where: { - $0.bundleIdentifier == bundleId - }) { - if app.isHidden { app.unhide() } - app.activate() - } - // Also use openApplication for reliable Space switching (Electron apps like VSCode) - if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) { - NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration()) - } - } - /// Bring an app forward without calling `NSRunningApplication.activate()`. /// `activate()` triggers Ghostty's quick-terminal pop-up when Ghostty has no visible /// window — that's why activateGhostty avoids it too. Using only unhide + @@ -1208,6 +1245,25 @@ struct TerminalActivator { } } + /// Runs an AppleScript on a background queue and returns its stdout as a string. + /// Used to read a return value from the script (e.g. which tab-match strategy hit). + private static func runAppleScriptReturningString(_ source: String) -> String? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + proc.arguments = ["-e", source] + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = FileHandle.nullDevice + do { + try proc.run() + proc.waitUntilExit() + } catch { + return nil + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + } + private static func runOsaScript(_ source: String) { DispatchQueue.global(qos: .userInitiated).async { runOsaScriptSync(source) diff --git a/Sources/CodeIslandBridge/main.swift b/Sources/CodeIslandBridge/main.swift index a5f9e4e4..f278c2c1 100644 --- a/Sources/CodeIslandBridge/main.swift +++ b/Sources/CodeIslandBridge/main.swift @@ -351,7 +351,22 @@ if sourceTag == nil && effectiveSource != nil { json["_via_plugin"] = true } -let resolvedTrackedPID = CLIProcessResolver.resolvedTrackedPID( +// Resolve the CLI PID to track. By default we walk the process ancestry so +// the hook's transient shell parent (e.g. `sh -c`) is replaced by the +// long-lived CLI binary. An explicit caller-supplied `_ppid` (an Int in the +// JSON payload) is honoured when the `CODEISLAND_KEEP_PPID` env var is set — +// this lets an external registrar attribute events to a CLI process that +// isn't this bridge's actual ancestor (e.g. registering pre-existing omp +// sessions that started before their extension was installed). Normal +// hooks never set `_ppid`, so this never changes their behavior. +let callerPpid: Int32? = { + guard ProcessInfo.processInfo.environment["CODEISLAND_KEEP_PPID"] != nil else { return nil } + // Accept any numeric shape JSONSerialization may produce (Int or NSNumber). + if let v = json["_ppid"] as? Int { return Int32(truncatingIfNeeded: v) } + if let v = json["_ppid"] as? NSNumber { return Int32(truncatingIfNeeded: v.intValue) } + return nil +}() +let resolvedTrackedPID = callerPpid ?? CLIProcessResolver.resolvedTrackedPID( immediateParentPID: Int32(immediateParentPID), source: effectiveSource, ancestry: coreAncestry @@ -411,10 +426,14 @@ alarm(isBlocking ? 8 : 4) // --- Deep terminal environment collection --- // Terminal app identification (only include when present) -if let termApp = env["TERM_PROGRAM"], !termApp.isEmpty { +// Terminal app identification (only include when present). Respect a +// caller-supplied value already in the JSON (e.g. an external registrar +// attributing events to a Cursor workspace, not this bridge's own terminal) +// before falling back to this bridge process's environment. +if json["_term_app"] == nil, let termApp = env["TERM_PROGRAM"], !termApp.isEmpty { json["_term_app"] = termApp } -if let termBundle = env["__CFBundleIdentifier"], !termBundle.isEmpty { +if json["_term_bundle"] == nil, let termBundle = env["__CFBundleIdentifier"], !termBundle.isEmpty { json["_term_bundle"] = termBundle } diff --git a/Sources/CodeIslandCore/ClaudeUsageScanner.swift b/Sources/CodeIslandCore/ClaudeUsageScanner.swift index c251bb22..281a9f48 100644 --- a/Sources/CodeIslandCore/ClaudeUsageScanner.swift +++ b/Sources/CodeIslandCore/ClaudeUsageScanner.swift @@ -1,158 +1,52 @@ import Foundation -public struct ClaudeUsageTotals: Equatable, Sendable { - public var inputTokens = 0 - public var outputTokens = 0 - public var cacheCreationTokens = 0 - public var cacheReadTokens = 0 - public var messageCount = 0 - - public var isEmpty: Bool { messageCount == 0 } - - public init() {} - - mutating func add(_ other: ClaudeUsageTotals) { - inputTokens += other.inputTokens - outputTokens += other.outputTokens - cacheCreationTokens += other.cacheCreationTokens - cacheReadTokens += other.cacheReadTokens - messageCount += other.messageCount - } -} - -/// Token-usage aggregation over the local Claude Code transcripts -/// (~/.claude/projects/**/*.jsonl) — local-first, no provider API calls. -/// Every assistant line carries `message.usage`; `message.id` repeats across -/// tool-use continuation lines of the same API response, so totals dedupe on it. +/// Compatibility shim — delegates to the new per-source scanner architecture. +/// All parsing logic now lives in `UsageScanners/` — one class per source: +/// • `ClaudeCodeScanner` — `~/.claude/projects/**/*.jsonl` +/// • `OMPScanner` — `~/.omp/agent/sessions/**/*.jsonl` +/// • `CodexScanner` — `~/.codex/sessions/**/*.jsonl` +/// • `CursorScanner` — CSV export API (tokscale approach) +/// `UsageScannerCoordinator` runs them all and merges results. public enum ClaudeUsageScanner { - /// Sparkline resolution: one bucket per hour, oldest first. - public static let sparklineHours = 12 + public static let sparklineHours = UsageScannerCoordinator.sparklineHours + public static let historyDays = UsageScannerCoordinator.historyDays - public struct Snapshot: Equatable, Sendable { - public let last5h: ClaudeUsageTotals - public let today: ClaudeUsageTotals - /// Output tokens per hour for the trailing `sparklineHours` hours, - /// index 0 oldest, last index = the current hour. - public let hourlyOutputTokens: [Int] - public let scannedAt: Date + public typealias Snapshot = UsageSnapshot + public typealias FileCache = UsageFileCache + public typealias SourceTotals = CodeIslandCore.SourceTotals - public init(last5h: ClaudeUsageTotals, today: ClaudeUsageTotals, hourlyOutputTokens: [Int], scannedAt: Date) { - self.last5h = last5h - self.today = today - self.hourlyOutputTokens = hourlyOutputTokens - self.scannedAt = scannedAt - } - } - - /// Per-file incremental parse state. Transcripts are append-only, so each - /// rescan reads only the bytes past `consumedBytes` — a day-long multi-MB - /// transcript is never re-read in full. Value semantics: the caller owns a - /// copy, hands it to the background scan, and stores the returned state. - public struct FileCache: Sendable { - struct CachedMessage: Sendable, Equatable { - let timestamp: Date - let usage: ClaudeUsageTotals - } - struct FileEntry: Sendable { - var consumedBytes: UInt64 = 0 - var entries: [CachedMessage] = [] - // Dedupe is per file: an assistant message's continuation lines - // repeat its id within the same transcript; ids never straddle files. - var seenIds: Set = [] - } - var files: [String: FileEntry] = [:] - public init() {} - } - - /// One-shot convenience (tests, callers without persistent state). public static func scan( claudeHome: String = ClaudeConfigPaths.configDir(), + ompHome: String = NSHomeDirectory() + "/.omp", + codexHome: String = NSHomeDirectory() + "/.codex", + cursorStorage: String = NSHomeDirectory() + "/Library/Application Support/Cursor/User/globalStorage", now: Date = Date() ) -> Snapshot { - var cache = FileCache() - return scan(claudeHome: claudeHome, now: now, cache: &cache) + UsageScannerCoordinator.scan( + claudeHome: claudeHome, ompHome: ompHome, codexHome: codexHome, + cursorStorage: cursorStorage, now: now) } public static func scan( claudeHome: String = ClaudeConfigPaths.configDir(), + ompHome: String = NSHomeDirectory() + "/.omp", + codexHome: String = NSHomeDirectory() + "/.codex", + cursorStorage: String = NSHomeDirectory() + "/Library/Application Support/Cursor/User/globalStorage", now: Date = Date(), cache: inout FileCache ) -> Snapshot { - let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) - let midnight = Calendar.current.startOfDay(for: now) - let sparklineStart = now.addingTimeInterval(-Double(sparklineHours) * 3600) - let cutoff = min(fiveHoursAgo, midnight, sparklineStart) - - var last5h = ClaudeUsageTotals() - var today = ClaudeUsageTotals() - var hourly = [Int](repeating: 0, count: sparklineHours) - var activeFiles = Set() - - let fm = FileManager.default - let projectsDir = claudeHome + "/projects" - for project in (try? fm.contentsOfDirectory(atPath: projectsDir)) ?? [] { - let projectPath = projectsDir + "/" + project - for file in (try? fm.contentsOfDirectory(atPath: projectPath)) ?? [] { - guard file.hasSuffix(".jsonl") else { continue } - let path = projectPath + "/" + file - // mtime gate: untouched-since-cutoff transcripts can't contain - // in-window lines, so the scan stays cheap on big histories. - guard let attrs = try? fm.attributesOfItem(atPath: path), - let mtime = attrs[.modificationDate] as? Date, - mtime >= cutoff else { continue } - activeFiles.insert(path) - let size = (attrs[.size] as? NSNumber)?.uint64Value ?? 0 - - var entry = cache.files[path] ?? FileCache.FileEntry() - if size < entry.consumedBytes { - // Truncated or replaced — start over. - entry = FileCache.FileEntry() - } - if size > entry.consumedBytes { - consumeNewLines(path: path, into: &entry) - } - entry.entries.removeAll { $0.timestamp < cutoff } - cache.files[path] = entry - - for message in entry.entries where message.timestamp <= now { - if message.timestamp >= fiveHoursAgo { last5h.add(message.usage) } - if message.timestamp >= midnight { today.add(message.usage) } - let hoursAgo = Int(now.timeIntervalSince(message.timestamp) / 3600) - if hoursAgo >= 0 && hoursAgo < sparklineHours { - hourly[sparklineHours - 1 - hoursAgo] += message.usage.outputTokens - } - } - } - } - // Files that fell out of the mtime window carry no in-window entries. - cache.files = cache.files.filter { activeFiles.contains($0.key) } - return Snapshot(last5h: last5h, today: today, hourlyOutputTokens: hourly, scannedAt: now) + UsageScannerCoordinator.scan( + claudeHome: claudeHome, ompHome: ompHome, codexHome: codexHome, + cursorStorage: cursorStorage, now: now, cache: &cache) } - /// Read bytes past `entry.consumedBytes` and parse the COMPLETE lines only — - /// a partial trailing line (writer mid-append) is left for the next scan. - private static func consumeNewLines(path: String, into entry: inout FileCache.FileEntry) { - guard let handle = FileHandle(forReadingAtPath: path) else { return } - defer { handle.closeFile() } - handle.seek(toFileOffset: entry.consumedBytes) - let data = handle.readDataToEndOfFile() - guard let lastNewline = data.lastIndex(of: UInt8(ascii: "\n")) else { return } - let consumable = data[data.startIndex...lastNewline] - entry.consumedBytes += UInt64(consumable.count) - guard let text = String(data: consumable, encoding: .utf8) else { return } - - for line in text.split(separator: "\n") { - guard let parsed = parseAssistantUsage(String(line)), - !entry.seenIds.contains(parsed.messageId) else { continue } - entry.seenIds.insert(parsed.messageId) - entry.entries.append(.init(timestamp: parsed.timestamp, usage: parsed.usage)) - } + public static func formatTokens(_ count: Int) -> String { + CodeIslandCore.formatTokens(count) } - /// Parse one transcript line into (timestamp, message id, usage) — nil for - /// non-assistant lines and lines without usage. + /// Parse one Claude Code transcript line into (timestamp, message id, usage). + /// Exposed for tests; mirrors `ClaudeCodeScanner.parseLine`. static func parseAssistantUsage(_ line: String) -> (timestamp: Date, messageId: String, usage: ClaudeUsageTotals)? { - // Cheap pre-filter before full JSON decoding: assistant lines only. guard line.contains("\"assistant\""), line.contains("\"usage\"") else { return nil } guard let data = line.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -163,7 +57,6 @@ public enum ClaudeUsageScanner { let messageId = message["id"] as? String, let usage = message["usage"] as? [String: Any] else { return nil } - var totals = ClaudeUsageTotals() totals.inputTokens = usage["input_tokens"] as? Int ?? 0 totals.outputTokens = usage["output_tokens"] as? Int ?? 0 @@ -172,29 +65,4 @@ public enum ClaudeUsageScanner { totals.messageCount = 1 return (timestamp, messageId, totals) } - - private static let fractionalFormatter: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }() - private static let plainFormatter = ISO8601DateFormatter() - - static func parseISO8601(_ raw: String) -> Date? { - fractionalFormatter.date(from: raw) ?? plainFormatter.date(from: raw) - } - - /// Compact human token count: 950, 32.5K, 1.4M. Unit selection uses the - /// rounded value so 999,950 rolls over to "1M" instead of "1000K". - public static func formatTokens(_ count: Int) -> String { - if count < 1000 { return "\(count)" } - func fmt(_ value: Double, _ unit: String) -> String { - String(format: "%.1f\(unit)", value).replacingOccurrences(of: ".0\(unit)", with: unit) - } - let thousands = Double(count) / 1000 - if thousands < 999.95 { return fmt(thousands, "K") } - let millions = Double(count) / 1_000_000 - if millions < 999.95 { return fmt(millions, "M") } - return fmt(Double(count) / 1_000_000_000, "B") - } } diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift index 789b2f23..41054174 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/CodeIslandCore/SessionSnapshot.swift @@ -510,7 +510,12 @@ public struct SessionSnapshot: Sendable { } /// Source label for display - public var sourceLabel: String { + public var sourceLabel: String { Self.sourceLabel(for: source) } + + /// Static source-label lookup so callers with only a source string (e.g. + /// the approval card when the session was removed) can still render the + /// CLI identity without a live `SessionSnapshot`. + public static func sourceLabel(for source: String) -> String { switch source { case "claude": return "Claude" case "codex": return "Codex" @@ -540,7 +545,7 @@ public struct SessionSnapshot: Sendable { case "cline": return "Cline" case "zcode": return "ZCode" default: - if let customName = Self.loadCustomSourceNames()[source] { + if let customName = loadCustomSourceNames()[source] { return customName } return source.capitalized @@ -953,7 +958,7 @@ public func reduceEvent( sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: msg)) } else if sessions[sessionId]?.lastAssistantMessage == nil, sessions[sessionId]?.recentMessages.last?.isUser == true { - sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: "[回复完成]")) + sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: "[Reply complete]")) } // Cline tasks are single-round — treat completion/cancellation as session end, // and latch a flag so out-of-order in-flight tool events don't revive it. @@ -999,7 +1004,7 @@ public func reduceEvent( } else if sessions[sessionId]?.lastAssistantMessage == nil, sessions[sessionId]?.recentMessages.last?.isUser == true { // No reply content from hook (e.g. CodeBuddy) -- add placeholder - sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: "[回复完成]")) + sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: "[Reply complete]")) } // Try to capture user prompt from Stop event if not already set if sessions[sessionId]?.lastUserPrompt == nil { diff --git a/Sources/CodeIslandCore/UsageScanners/ClaudeCodeScanner.swift b/Sources/CodeIslandCore/UsageScanners/ClaudeCodeScanner.swift new file mode 100644 index 00000000..9c867065 --- /dev/null +++ b/Sources/CodeIslandCore/UsageScanners/ClaudeCodeScanner.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Scans `~/.claude/projects/**/*.jsonl` for assistant message usage. +/// Claude Code transcripts have `type: "assistant"` lines carrying +/// `message.usage` with `input_tokens`/`output_tokens`/`cache_*_input_tokens`. +public struct ClaudeCodeScanner: UsageScanner { + public let sourceName = "Claude Code" + private let root: String + + public init(claudeHome: String = NSHomeDirectory() + "/.claude") { + self.root = claudeHome + "/projects" + } + + public func scan( + cache: inout UsageFileCache, + cutoff: Date, + fm: FileManager + ) -> Set { + var activeFiles = Set() + enumerateProjects( + root: root, cache: &cache, activeFiles: &activeFiles, + cutoff: cutoff, fm: fm) + return activeFiles + } + + private func enumerateProjects( + root: String, + cache: inout UsageFileCache, + activeFiles: inout Set, + cutoff: Date, + fm: FileManager + ) { + guard let projectDirs = try? fm.contentsOfDirectory(atPath: root) else { return } + for project in projectDirs { + let projectPath = root + "/" + project + guard let files = try? fm.contentsOfDirectory(atPath: projectPath) else { continue } + for file in files where file.hasSuffix(".jsonl") { + let path = projectPath + "/" + file + guard let attrs = try? fm.attributesOfItem(atPath: path), + let mtime = attrs[.modificationDate] as? Date, + mtime >= cutoff else { continue } + activeFiles.insert(path) + let size = (attrs[.size] as? NSNumber)?.uint64Value ?? 0 + var entry = cache.files[path] ?? UsageFileCache.FileEntry() + entry.source = sourceName + if size < entry.consumedBytes { + entry = UsageFileCache.FileEntry() + entry.source = sourceName + } + if size > entry.consumedBytes { + consumeNewLines(path: path, parser: parseLine, into: &entry) + } + entry.entries.removeAll { $0.timestamp < cutoff } + cache.files[path] = entry + } + } + } + + /// Parse one Claude Code transcript line. + /// Only assistant lines with `message.usage` are relevant. + private func parseLine(_ line: String) -> ParsedUsageLine? { + guard line.contains("\"assistant\""), line.contains("\"usage\"") else { return nil } + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + obj["type"] as? String == "assistant", + let timestampRaw = obj["timestamp"] as? String, + let timestamp = parseISO8601(timestampRaw), + let message = obj["message"] as? [String: Any], + let messageId = message["id"] as? String, + let usage = message["usage"] as? [String: Any] + else { return nil } + var totals = ClaudeUsageTotals() + totals.inputTokens = usage["input_tokens"] as? Int ?? 0 + totals.outputTokens = usage["output_tokens"] as? Int ?? 0 + totals.cacheCreationTokens = usage["cache_creation_input_tokens"] as? Int ?? 0 + totals.cacheReadTokens = usage["cache_read_input_tokens"] as? Int ?? 0 + totals.messageCount = 1 + return ParsedUsageLine(timestamp: timestamp, messageId: messageId, usage: totals) + } +} diff --git a/Sources/CodeIslandCore/UsageScanners/CodexScanner.swift b/Sources/CodeIslandCore/UsageScanners/CodexScanner.swift new file mode 100644 index 00000000..e94caecc --- /dev/null +++ b/Sources/CodeIslandCore/UsageScanners/CodexScanner.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Scans `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` recursively. +/// Codex emits `type: "event_msg"` with `payload.type: "token_count"` +/// containing both `total_token_usage` (cumulative) and `last_token_usage` +/// (delta for this response). We use `last_token_usage` to avoid double-counting. +public struct CodexScanner: UsageScanner { + public let sourceName = "Codex" + private let root: String + + public init(codexHome: String = NSHomeDirectory() + "/.codex") { + self.root = codexHome + "/sessions" + } + + public func scan( + cache: inout UsageFileCache, + cutoff: Date, + fm: FileManager + ) -> Set { + // Force re-parse every scan: old cache entries may have been built + // with total_token_usage (cumulative) instead of last_token_usage (delta). + cache.files = cache.files.filter { $0.value.source != sourceName } + + var activeFiles = Set() + var filesFound = 0 + var filesParsed = 0 + + guard let enumerator = fm.enumerator(atPath: root) else { + usageLogger.notice("Codex: no enumerator for \(self.root, privacy: .public)") + return activeFiles + } + while let subpath = enumerator.nextObject() as? String { + let full = root + "/" + subpath + guard subpath.hasSuffix(".jsonl") else { continue } + guard let attrs = try? fm.attributesOfItem(atPath: full), + let mtime = attrs[.modificationDate] as? Date, + mtime >= cutoff else { continue } + filesFound += 1 + activeFiles.insert(full) + let size = (attrs[.size] as? NSNumber)?.uint64Value ?? 0 + var entry = cache.files[full] ?? UsageFileCache.FileEntry() + entry.source = sourceName + if size < entry.consumedBytes { + entry = UsageFileCache.FileEntry() + entry.source = sourceName + } + if size > entry.consumedBytes { + consumeNewLines(path: full, parser: parseLine, into: &entry) + filesParsed += 1 + } + entry.entries.removeAll { $0.timestamp < cutoff } + cache.files[full] = entry + } + let cachedCount = cache.files.values.filter { $0.source == sourceName }.count + usageLogger.notice("Codex: found \(filesFound) files, parsed \(filesParsed) new, \(cachedCount) cached entries") + return activeFiles + } + + /// Parse one Codex transcript line. + private func parseLine(_ line: String) -> ParsedUsageLine? { + // Cheap pre-filter: `token_count` events contain both + // `total_token_usage` and `last_token_usage`. + guard line.contains("\"token_count\""), line.contains("token_usage") else { return nil } + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + obj["type"] as? String == "event_msg", + let timestampRaw = obj["timestamp"] as? String, + let timestamp = parseISO8601(timestampRaw), + let payload = obj["payload"] as? [String: Any], + payload["type"] as? String == "token_count", + let info = payload["info"] as? [String: Any], + let usage = info["last_token_usage"] as? [String: Any] + else { return nil } + var totals = ClaudeUsageTotals() + totals.inputTokens = usage["input_tokens"] as? Int ?? 0 + totals.outputTokens = (usage["output_tokens"] as? Int ?? 0) + + (usage["reasoning_output_tokens"] as? Int ?? 0) + totals.cacheReadTokens = usage["cached_input_tokens"] as? Int ?? 0 + totals.cacheCreationTokens = usage["cache_write_input_tokens"] as? Int ?? 0 + totals.messageCount = 1 + // Dedupe by timestamp — each token_count event has a unique timestamp. + let messageId = "codex-tc-\(timestampRaw)" + return ParsedUsageLine(timestamp: timestamp, messageId: messageId, usage: totals) + } +} diff --git a/Sources/CodeIslandCore/UsageScanners/CursorScanner.swift b/Sources/CodeIslandCore/UsageScanners/CursorScanner.swift new file mode 100644 index 00000000..1084ead8 --- /dev/null +++ b/Sources/CodeIslandCore/UsageScanners/CursorScanner.swift @@ -0,0 +1,578 @@ +import Foundation +import SQLite3 +import CommonCrypto +import Security + +/// Scans Cursor usage via the CSV export API (tokscale approach). +/// Reads `cursorAuth/accessToken` from `globalStorage/state.vscdb`, sends it +/// as `WorkosCursorSessionToken` cookie to +/// `cursor.com/api/dashboard/export-usage-events-csv?strategy=tokens`, and +/// caches the CSV at `~/.codeisland/cursor-usage.csv`. +/// Falls back to the cached CSV if the token is expired. +public struct CursorScanner: UsageScanner { + public let sourceName = "Cursor" + private let stateDBPath: String + public let csvCachePath: String + private let tokscaleCachePath: String + private let chromeCookiesPath: String + + public init(cursorStorage: String = NSHomeDirectory() + "/Library/Application Support/Cursor/User/globalStorage") { + self.stateDBPath = cursorStorage + "/state.vscdb" + self.csvCachePath = NSHomeDirectory() + "/.codeisland/cursor-usage.csv" + self.tokscaleCachePath = NSHomeDirectory() + "/.config/tokscale/cursor-cache/usage.csv" + self.chromeCookiesPath = NSHomeDirectory() + "/Library/Application Support/Google/Chrome/Default/Cookies" + // Migrate: remove old plaintext cookie file if it exists + let oldCookiePath = NSHomeDirectory() + "/.codeisland/cursor-cookie.txt" + if FileManager.default.fileExists(atPath: oldCookiePath) { + try? FileManager.default.removeItem(atPath: oldCookiePath) + } + } + + public func scan( + cache: inout UsageFileCache, + cutoff: Date, + fm: FileManager + ) -> Set { + var activeFiles = Set() + let cacheKey = "cursor-csv" + // Use cached CSV only. Chrome Keychain extraction is an explicit + // user action (see CursorScanner.refreshCSV) to avoid prompting + // for Keychain access on every app launch. + if let cached = try? String(contentsOfFile: csvCachePath, encoding: .utf8), + !cached.hasPrefix("<") { + usageLogger.notice("Cursor: using cached CSV") + var entry = cache.files[cacheKey] ?? UsageFileCache.FileEntry() + entry.source = sourceName + entry.entries = parseCursorUsageCSV(text: cached) + entry.entries.removeAll { $0.timestamp < cutoff } + cache.files[cacheKey] = entry + activeFiles.insert(cacheKey) + usageLogger.notice("Cursor: \(entry.entries.count) entries in window") + return activeFiles + } + if let tokscale = try? String(contentsOfFile: tokscaleCachePath, encoding: .utf8), + !tokscale.hasPrefix("<") { + usageLogger.notice("Cursor: using tokscale cache") + var entry = cache.files[cacheKey] ?? UsageFileCache.FileEntry() + entry.source = sourceName + entry.entries = parseCursorUsageCSV(text: tokscale) + entry.entries.removeAll { $0.timestamp < cutoff } + cache.files[cacheKey] = entry + activeFiles.insert(cacheKey) + usageLogger.notice("Cursor: \(entry.entries.count) entries in window") + return activeFiles + } + usageLogger.notice("Cursor: no cached CSV — run refresh from Settings") + return activeFiles + } + + /// On-demand CSV refresh: extracts the Chrome cookie, fetches the CSV, + /// and caches it. Call this from a Settings button, not the automatic scan. + /// Returns true on success. + public func refreshCSV() -> Bool { + var sessionToken: String? + var failReason = "unknown" + + // Chrome Keychain extraction removed — endpoint security blocks it. + + // Fallback: IDE session token (may not work for CSV API — different auth) + if sessionToken == nil, let ideToken = readCursorSessionToken(dbPath: stateDBPath) { + usageLogger.notice("Cursor: trying IDE session token (len=\(ideToken.count))") + sessionToken = ideToken + failReason = "ide-token" + } + + guard let token = sessionToken else { + usageLogger.error("Cursor: no session token found — \(failReason)") + return false + } + guard let csv = fetchCursorUsageCSV(token: token), !csv.hasPrefix("<") else { + usageLogger.error("Cursor: CSV fetch failed — token may be wrong type or expired") + return false + } + try? csv.write(toFile: csvCachePath, atomically: true, encoding: .utf8) + usageLogger.notice("Cursor: CSV refreshed and cached (\(csv.count) bytes)") + return true + } + + /// Extract the WorkosCursorSessionToken from Chrome. USER-INITIATED ONLY — + /// endpoint security blocks Keychain access to Chrome's items, so this must + /// never be called automatically (at startup or during scan). Only the + /// "Extract from Chrome" button calls this, so the user explicitly opts in. + /// Dispatches to the next main-runloop tick so the Keychain dialog can + /// appear outside the SwiftUI action closure. + public func extractChromeToken(completion: @escaping (String?) -> Void) { + DispatchQueue.main.async { [self] in + if let chromeToken = readChromeCursorSessionToken() { + usageLogger.notice("Cursor: extracted WorkosCursorSessionToken from Chrome (len=\(chromeToken.count))") + completion(chromeToken) + } else { + usageLogger.notice("Cursor: Chrome cookie extraction failed — Keychain access may have been denied") + completion(nil) + } + } + } + public func extractChromeTokenSync() -> String? { nil } + + /// Fetch the CSV from the Cursor API and cache it. Can run on any thread. + public func fetchAndCacheCSV(token: String) -> Bool { + guard let csv = fetchCursorUsageCSV(token: token), !csv.hasPrefix("<") else { + usageLogger.error("Cursor: CSV fetch failed — token may be wrong type or expired") + return false + } + try? csv.write(toFile: csvCachePath, atomically: true, encoding: .utf8) + usageLogger.notice("Cursor: CSV refreshed and cached (\(csv.count) bytes)") + return true + } + + /// Fetch CSV using a manually-provided session token (cookie value). + public func refreshCSVWithCookie(_ cookie: String) -> Bool { + var token = cookie.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { return false } + // URL-decode: DevTools shows %3A%3A for :: — the API needs the decoded value + token = token.removingPercentEncoding ?? token + usageLogger.notice("Cursor: fetching CSV with manual cookie (len=\(token.count), decoded=\(token != cookie))") + guard let csv = fetchCursorUsageCSV(token: token), !csv.hasPrefix("<") else { + usageLogger.notice("Cursor: CSV fetch failed with manual cookie") + return false + } + try? csv.write(toFile: csvCachePath, atomically: true, encoding: .utf8) + usageLogger.notice("Cursor: CSV fetched with manual cookie (\(csv.count) bytes)") + return true + } + + private static let keychainService = "com.codeisland.cursor-cookie" + private static let keychainAccount = "WorkosCursorSessionToken" + + /// Track the time the CursorScanner was first created. Keychain access + /// is delayed 10s after this to avoid endpoint security's startup behavioral + /// analysis — endpoint security kills the app if SecItem* calls happen too soon after + /// launch. After 10s the app is "settled" and Keychain access is allowed. + private static let firstLoadTime = Date() + + /// Log a timestamped Keychain operation for debugging endpoint security blocks. + private func logKeychainOp(_ op: String, status: OSStatus = 0) { + let elapsed = Int(Date().timeIntervalSince(Self.firstLoadTime)) + usageLogger.notice("Cursor: KEYCHAIN [+\(elapsed, privacy: .public)s] \(op, privacy: .public) status=\(status, privacy: .public)") + } + + /// Guard: only allow Keychain access 10s after the scanner was first + /// created (≈ app launch). This avoids triggering endpoint security's + /// behavioral threat detection at startup. + private var keychainReady: Bool { + let elapsed = Date().timeIntervalSince(Self.firstLoadTime) + if elapsed < 10 { + usageLogger.notice("Cursor: keychain not ready (elapsed=\(Int(elapsed), privacy: .public)s < 10s) — deferring") + return false + } + return true + } + + /// Save a cookie value to macOS Keychain. Delayed 10s after launch to + /// avoid endpoint security behavioral threat detection at startup. + public func saveCookie(_ cookie: String) { + let trimmed = cookie.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard keychainReady else { return } + let data = Data(trimmed.utf8) + + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount, + ] + let delStatus = SecItemDelete(deleteQuery as CFDictionary) + logKeychainOp("SecItemDelete", status: delStatus) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + logKeychainOp("SecItemAdd", status: addStatus) + if addStatus == errSecSuccess { + usageLogger.notice("Cursor: cookie saved to Keychain") + } else { + usageLogger.error("Cursor: failed to save cookie to Keychain (status=\(addStatus))") + } + } + + /// Load the saved cookie from macOS Keychain. Delayed 10s after launch. + public var savedCookie: String? { + guard keychainReady else { return nil } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + logKeychainOp("SecItemCopyMatching(savedCookie)", status: status) + guard status == errSecSuccess, + let data = result as? Data, + let cookie = String(data: data, encoding: .utf8) else { return nil } + return cookie + } + + /// Check if a saved cookie exists in Keychain. + public var hasSavedCookie: Bool { + savedCookie != nil + } + + /// Delete the saved cookie from Keychain. + public func clearSavedCookie() { + guard keychainReady else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount, + ] + let status = SecItemDelete(query as CFDictionary) + logKeychainOp("SecItemDelete(clear)", status: status) + usageLogger.notice("Cursor: saved cookie cleared from Keychain") + } + + /// Refresh CSV using the saved cookie from Keychain. + public func refreshFromSavedCookie() -> Bool { + guard let cookie = savedCookie else { return false } + usageLogger.notice("Cursor: refreshing from saved Keychain cookie") + return refreshCSVWithCookie(cookie) + } + + /// Save a manually-provided CSV string (e.g. pasted from the browser console). + public func saveManualCSV(_ csv: String) -> Bool { + guard !csv.hasPrefix("<"), csv.contains("Date") else { return false } + try? csv.write(toFile: csvCachePath, atomically: true, encoding: .utf8) + usageLogger.notice("Cursor: manual CSV saved (\(csv.count) bytes)") + return true + } + + /// Check if a cached CSV exists. + public var hasCachedCSV: Bool { + guard let cached = try? String(contentsOfFile: csvCachePath, encoding: .utf8) else { return false } + return !cached.hasPrefix("<") && cached.contains("Date") + } + + + + // MARK: - Token reading + + private func readCursorSessionToken(dbPath: String) -> String? { + var db: OpaquePointer? + guard sqlite3_open(dbPath, &db) == SQLITE_OK else { + sqlite3_close(db) + return nil + } + defer { sqlite3_close(db) } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken' LIMIT 1", -1, &stmt, nil) == SQLITE_OK else { + return nil + } + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + guard let cString = sqlite3_column_text(stmt, 0) else { return nil } + return String(cString: cString) + } + + // MARK: - CSV fetch + + private func fetchCursorUsageCSV(token: String) -> String? { + let url = URL(string: "https://cursor.com/api/dashboard/export-usage-events-csv?strategy=tokens")! + var request = URLRequest(url: url, timeoutInterval: 60) + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue("https://www.cursor.com/settings", forHTTPHeaderField: "Referer") + request.setValue("WorkosCursorSessionToken=\(token)", forHTTPHeaderField: "Cookie") + let semaphore = DispatchSemaphore(value: 0) + var result: String? + URLSession.shared.dataTask(with: request) { data, response, _ in + if let response = response as? HTTPURLResponse { + let status = response.statusCode + if let data, let text = String(data: data, encoding: .utf8) { + if status == 200 && !text.hasPrefix("<") { + result = text + } else { + usageLogger.notice("Cursor: HTTP \(status), \(text.count) bytes, starts: \(String(text.prefix(80)), privacy: .public)") + } + } else if status != 200 { + usageLogger.notice("Cursor: HTTP \(status), no body") + } + } else { + usageLogger.notice("Cursor: no response") + } + semaphore.signal() + }.resume() + _ = semaphore.wait(timeout: .now() + 60) + return result + } + + // MARK: - CSV parsing + + private func parseCursorUsageCSV(text: String) -> [UsageFileCache.CachedMessage] { + let lines = text.split(separator: "\n", omittingEmptySubsequences: true).map(String.init) + guard let header = lines.first else { return [] } + let headerFields = parseCSVLine(header) + guard headerFields.contains(where: { $0.contains("Date") }) else { + usageLogger.notice("Cursor: parsed 0 CSV rows (no Date header)") + return [] + } + let hasKind = headerFields.contains(where: { $0.contains("Kind") }) + var rows = 0 + var entries: [UsageFileCache.CachedMessage] = [] + for line in lines.dropFirst() { + let fields = parseCSVLine(line) + guard fields.count >= (hasKind ? 5 : 4) else { continue } + let modelIdx = hasKind ? (fields.count >= 7 ? 4 : 2) : 1 + let inputWithCacheIdx = modelIdx + 1 + let inputWithoutCacheIdx = modelIdx + 2 + let cacheReadIdx = modelIdx + 3 + let outputIdx = modelIdx + 4 + guard fields.indices.contains(0), + let date = parseCursorDate(fields[0]) else { continue } + var totals = ClaudeUsageTotals() + if fields.indices.contains(inputWithoutCacheIdx) { + totals.inputTokens = Int(fields[inputWithoutCacheIdx]) ?? 0 + } + if fields.indices.contains(inputWithCacheIdx), + fields.indices.contains(inputWithoutCacheIdx) { + let withCache = Int(fields[inputWithCacheIdx]) ?? 0 + let withoutCache = Int(fields[inputWithoutCacheIdx]) ?? 0 + totals.cacheCreationTokens = max(0, withCache - withoutCache) + } + if fields.indices.contains(cacheReadIdx) { + totals.cacheReadTokens = Int(fields[cacheReadIdx]) ?? 0 + } + if fields.indices.contains(outputIdx) { + totals.outputTokens = Int(fields[outputIdx]) ?? 0 + } + totals.messageCount = 1 + entries.append(.init(timestamp: date, usage: totals)) + rows += 1 + } + usageLogger.notice("Cursor: parsed \(rows) CSV rows") + return entries + } + + private func parseCursorDate(_ raw: String) -> Date? { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone.current + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + if let d = f.date(from: raw) { return d } + return parseISO8601(raw) + } + + private func parseCSVLine(_ line: String) -> [String] { + var fields: [String] = [] + var current = "" + var inQuotes = false + for char in line { + if char == "\"" { + inQuotes.toggle() + } else if char == "," && !inQuotes { + fields.append(current) + current = "" + } else { + current.append(char) + } + } + fields.append(current) + return fields + } + + // MARK: - gRPC fallback + + /// Fetch current period usage via gRPC-web to api2.cursor.sh. + /// Returns a single synthetic entry for today with the billing cycle totals. + /// The CSV export API is the preferred source, but it requires a browser + /// session cookie; the gRPC call works with the IDE access token. + private func fetchCurrentPeriodUsage(token: String, cutoff: Date) -> [UsageFileCache.CachedMessage]? { + let url = URL(string: "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage")! + var request = URLRequest(url: url, timeoutInterval: 10) + request.httpMethod = "POST" + request.setValue("application/grpc-web+proto", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("0.50.0", forHTTPHeaderField: "x-cursor-client-version") + request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + // Empty protobuf message (gRPC-web frame: 0x00 flag + 4-byte length=0) + request.httpBody = Data([0x00, 0x00, 0x00, 0x00, 0x00]) + + let semaphore = DispatchSemaphore(value: 0) + var responseData: Data? + URLSession.shared.dataTask(with: request) { data, response, _ in + if let response = response as? HTTPURLResponse, + response.statusCode == 200, + let data { + responseData = data + } + semaphore.signal() + }.resume() + _ = semaphore.wait(timeout: .now() + 10) + + guard let data = responseData, data.count > 10 else { return nil } + + // Parse gRPC-web frame: byte 0 = flag, bytes 1-4 = big-endian length + let length = Int(data[1]) << 24 | Int(data[2]) << 16 | Int(data[3]) << 8 | Int(data[4]) + guard data.count >= 5 + length, length > 0 else { return nil } + let proto = data[5..<(5 + length)] + + // Parse GetCurrentPeriodUsageResponse: + // field 1 = billing_cycle_start (int64 millis) + // field 2 = billing_cycle_end (int64 millis) + // We don't get per-event token counts from this — only billing info. + // Return nil to indicate no token data available. + // The CSV export is the only source of per-event token counts. + usageLogger.notice("Cursor: gRPC response \(data.count) bytes, proto \(length) bytes") + return nil + } + + // MARK: - Chrome cookie extraction + + /// Extract the `WorkosCursorSessionToken` cookie from Chrome's cookie DB. + /// Uses the same technique as `pycookiecheat` / spinnaker-mcp: + /// 1. Read the AES key from macOS Keychain (service "Chrome Safe Storage") + /// 2. Derive a 16-byte key via PBKDF2 (password=key, salt="saltysalt", 1003 rounds) + /// 3. Decrypt the cookie value from Chrome's SQLite Cookies DB (AES-128-CBC, IV=16*" ") + /// 4. Strip the v10 prefix and PKCS7 padding + /// Requires Chrome to have been logged into cursor.com. + private func readChromeCursorSessionToken() -> String? { + guard FileManager.default.fileExists(atPath: chromeCookiesPath) else { return nil } + + // Step 1: Read the Chrome Safe Storage key from Keychain. + guard let chromeKey = readChromeSafeStorageKey() else { + usageLogger.notice("Cursor: could not read Chrome Safe Storage key from Keychain") + return nil + } + + // Step 2: Derive AES key via PBKDF2. + let derivedKey = deriveChromeAESKey(password: chromeKey) + + // Step 3: Read the encrypted cookie value from Chrome's SQLite DB. + guard let encryptedValue = readEncryptedCookieValue() else { + usageLogger.notice("Cursor: no WorkosCursorSessionToken in Chrome cookies") + return nil + } + + // Step 4: Decrypt the cookie value. + // Chrome cookie encryption format: "v10" prefix + AES-128-CBC ciphertext, IV = 16 space bytes. + guard encryptedValue.count > 3, + encryptedValue.prefix(3) == Data("v10".utf8) else { return nil } + let ciphertext = encryptedValue.subdata(in: 3.. String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "Chrome Safe Storage", + kSecAttrAccount as String: "Chrome", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecSuccess, + let data = result as? Data, + let key = String(data: data, encoding: .utf8) { + usageLogger.notice("Cursor: Chrome Safe Storage key read via SecItemCopyMatching") + return key + } + usageLogger.error("Cursor: SecItemCopyMatching failed (status=\(status)) — app may be unsigned") + return nil + } + + /// Derive the 16-byte AES key using PBKDF2-HMAC-SHA1 (Chrome's scheme). + private func deriveChromeAESKey(password: String) -> Data { + let passwordData = password.data(using: .utf8) ?? Data() + let salt = Data("saltysalt".utf8) + let derivedKey = self.pbkdf2(password: passwordData, salt: salt, iterations: 1003, keyLength: 16) + return derivedKey + } + + /// PBKDF2-HMAC-SHA1 key derivation. + private func pbkdf2(password: Data, salt: Data, iterations: Int, keyLength: Int) -> Data { + var derivedKey = Data(count: keyLength) + derivedKey.withUnsafeMutableBytes { derivedKeyBytes in + password.withUnsafeBytes { passwordBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBytes.baseAddress, password.count, + saltBytes.baseAddress, salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + UInt32(iterations), + derivedKeyBytes.baseAddress, keyLength + ) + } + } + } + return derivedKey + } + + /// AES-128-CBC decryption. + private func aesDecrypt(ciphertext: Data, key: Data, iv: Data) -> Data? { + let bufferSize = ciphertext.count + kCCBlockSizeAES128 + var buffer = Data(count: bufferSize) + var bytesDecrypted = 0 + let status = buffer.withUnsafeMutableBytes { bufferBytes in + ciphertext.withUnsafeBytes { cipherBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES128), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, key.count, + ivBytes.baseAddress, + cipherBytes.baseAddress, ciphertext.count, + bufferBytes.baseAddress, bufferSize, + &bytesDecrypted + ) + } + } + } + } + guard status == kCCSuccess else { return nil } + return buffer.prefix(bytesDecrypted) + } + + /// Read the encrypted WorkosCursorSessionToken cookie value from Chrome's Cookies SQLite DB. + private func readEncryptedCookieValue() -> Data? { + var db: OpaquePointer? + guard sqlite3_open(chromeCookiesPath, &db) == SQLITE_OK else { + sqlite3_close(db) + return nil + } + defer { sqlite3_close(db) } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT encrypted_value FROM cookies WHERE name = 'WorkosCursorSessionToken' AND host_key LIKE '%cursor.com%' LIMIT 1", -1, &stmt, nil) == SQLITE_OK else { + return nil + } + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + let blob = sqlite3_column_blob(stmt, 0) + let length = sqlite3_column_bytes(stmt, 0) + guard let blob, length > 0 else { return nil } + return Data(bytes: blob, count: Int(length)) + } +} diff --git a/Sources/CodeIslandCore/UsageScanners/OMPScanner.swift b/Sources/CodeIslandCore/UsageScanners/OMPScanner.swift new file mode 100644 index 00000000..3705acc2 --- /dev/null +++ b/Sources/CodeIslandCore/UsageScanners/OMPScanner.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Scans `~/.omp/agent/sessions/**/*.jsonl` for OMP message usage. +/// OMP transcripts have `type: "message"` lines carrying `message.usage` +/// with `input`/`output`/`cacheRead`/`cacheWrite`. +public struct OMPScanner: UsageScanner { + public let sourceName = "OMP" + private let root: String + + public init(ompHome: String = NSHomeDirectory() + "/.omp") { + self.root = ompHome + "/agent/sessions" + } + + public func scan( + cache: inout UsageFileCache, + cutoff: Date, + fm: FileManager + ) -> Set { + var activeFiles = Set() + enumerateProjects( + root: root, cache: &cache, activeFiles: &activeFiles, + cutoff: cutoff, fm: fm) + return activeFiles + } + + private func enumerateProjects( + root: String, + cache: inout UsageFileCache, + activeFiles: inout Set, + cutoff: Date, + fm: FileManager + ) { + guard let projectDirs = try? fm.contentsOfDirectory(atPath: root) else { return } + for project in projectDirs { + let projectPath = root + "/" + project + guard let files = try? fm.contentsOfDirectory(atPath: projectPath) else { continue } + for file in files where file.hasSuffix(".jsonl") { + let path = projectPath + "/" + file + guard let attrs = try? fm.attributesOfItem(atPath: path), + let mtime = attrs[.modificationDate] as? Date, + mtime >= cutoff else { continue } + activeFiles.insert(path) + let size = (attrs[.size] as? NSNumber)?.uint64Value ?? 0 + var entry = cache.files[path] ?? UsageFileCache.FileEntry() + entry.source = sourceName + if size < entry.consumedBytes { + entry = UsageFileCache.FileEntry() + entry.source = sourceName + } + if size > entry.consumedBytes { + consumeNewLines(path: path, parser: parseLine, into: &entry) + } + entry.entries.removeAll { $0.timestamp < cutoff } + cache.files[path] = entry + } + } + } + + /// Parse one OMP transcript line. + private func parseLine(_ line: String) -> ParsedUsageLine? { + guard line.contains("\"message\""), line.contains("\"usage\"") else { return nil } + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + obj["type"] as? String == "message", + let timestampRaw = obj["timestamp"] as? String, + let timestamp = parseISO8601(timestampRaw), + let messageId = obj["id"] as? String, + let message = obj["message"] as? [String: Any], + let usage = message["usage"] as? [String: Any] + else { return nil } + var totals = ClaudeUsageTotals() + totals.inputTokens = usage["input"] as? Int ?? 0 + totals.outputTokens = usage["output"] as? Int ?? 0 + // OMP's `cacheWrite` maps to Claude's cache-creation bucket. + totals.cacheCreationTokens = usage["cacheWrite"] as? Int ?? 0 + totals.cacheReadTokens = usage["cacheRead"] as? Int ?? 0 + totals.messageCount = 1 + return ParsedUsageLine(timestamp: timestamp, messageId: messageId, usage: totals) + } +} diff --git a/Sources/CodeIslandCore/UsageScanners/UsageScanner.swift b/Sources/CodeIslandCore/UsageScanners/UsageScanner.swift new file mode 100644 index 00000000..b00c84bb --- /dev/null +++ b/Sources/CodeIslandCore/UsageScanners/UsageScanner.swift @@ -0,0 +1,154 @@ +import Foundation +import os + +/// Shared logger for all usage scanners. +let usageLogger = Logger(subsystem: "com.codeisland", category: "usage-scanner") + +/// Aggregated token counts for one message or one time bucket. +public struct ClaudeUsageTotals: Equatable, Sendable { + public var inputTokens = 0 + public var outputTokens = 0 + public var cacheCreationTokens = 0 + public var cacheReadTokens = 0 + public var messageCount = 0 + + public var isEmpty: Bool { messageCount == 0 } + + public init() {} + + mutating func add(_ other: ClaudeUsageTotals) { + inputTokens += other.inputTokens + outputTokens += other.outputTokens + cacheCreationTokens += other.cacheCreationTokens + cacheReadTokens += other.cacheReadTokens + messageCount += other.messageCount + } +} + +/// Per-source totals for the history window. +public struct SourceTotals: Equatable, Sendable { + public let name: String + public let total: ClaudeUsageTotals + public let dailyTotals: [ClaudeUsageTotals] + public init(name: String, total: ClaudeUsageTotals, dailyTotals: [ClaudeUsageTotals]) { + self.name = name + self.total = total + self.dailyTotals = dailyTotals + } +} + +/// Immutable result of a full scan — all sources merged. +public struct UsageSnapshot: Equatable, Sendable { + public let last5h: ClaudeUsageTotals + public let today: ClaudeUsageTotals + public let hourlyOutputTokens: [Int] + public let dailyTotals: [ClaudeUsageTotals] + public let perSource: [SourceTotals] + public let scannedAt: Date + + public init( + last5h: ClaudeUsageTotals, + today: ClaudeUsageTotals, + hourlyOutputTokens: [Int], + dailyTotals: [ClaudeUsageTotals] = [], + perSource: [SourceTotals] = [], + scannedAt: Date + ) { + self.last5h = last5h + self.today = today + self.hourlyOutputTokens = hourlyOutputTokens + self.dailyTotals = dailyTotals + self.perSource = perSource + self.scannedAt = scannedAt + } +} + +/// Per-file incremental parse state. Transcripts are append-only, so each +/// rescan reads only the bytes past `consumedBytes`. +public struct UsageFileCache: Sendable { + public struct CachedMessage: Sendable, Equatable { + public let timestamp: Date + public let usage: ClaudeUsageTotals + } + + public struct FileEntry: Sendable { + public var consumedBytes: UInt64 = 0 + public var entries: [CachedMessage] = [] + public var seenIds: Set = [] + public var source: String = "" + } + + public var files: [String: FileEntry] = [:] + public init() {} +} + +/// One parsed line from a transcript — the common output of all line parsers. +public struct ParsedUsageLine: Sendable { + public let timestamp: Date + public let messageId: String + public let usage: ClaudeUsageTotals +} + +/// A single AI source scanner. Each implementation owns its parsing, +/// file enumeration, and cache management. The coordinator runs all +/// scanners and merges their results into a `UsageSnapshot`. +public protocol UsageScanner: Sendable { + /// Human-readable source name, e.g. "Claude Code", "Codex". + var sourceName: String { get } + /// Walk the source's files, parse new lines, update the cache, and + /// return the set of active file paths (for cache pruning). + func scan( + cache: inout UsageFileCache, + cutoff: Date, + fm: FileManager + ) -> Set +} + +// MARK: - Shared helpers + +/// Read new bytes from an append-only transcript, parse each line with +/// `parser`, dedupe by message id, and append to `entry.entries`. +func consumeNewLines( + path: String, + parser: (String) -> ParsedUsageLine?, + into entry: inout UsageFileCache.FileEntry +) { + guard let handle = FileHandle(forReadingAtPath: path) else { return } + defer { handle.closeFile() } + handle.seek(toFileOffset: entry.consumedBytes) + let data = handle.readDataToEndOfFile() + guard let lastNewline = data.lastIndex(of: UInt8(ascii: "\n")) else { return } + let consumable = data[data.startIndex...lastNewline] + entry.consumedBytes += UInt64(consumable.count) + guard let text = String(data: consumable, encoding: .utf8) else { return } + + for line in text.split(separator: "\n") { + guard let parsed = parser(String(line)), + !entry.seenIds.contains(parsed.messageId) else { continue } + entry.seenIds.insert(parsed.messageId) + entry.entries.append(.init(timestamp: parsed.timestamp, usage: parsed.usage)) + } +} + +let fractionalISOFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f +}() + +let plainISOFormatter = ISO8601DateFormatter() + +func parseISO8601(_ raw: String) -> Date? { + fractionalISOFormatter.date(from: raw) ?? plainISOFormatter.date(from: raw) +} + +/// Compact human token count: 950, 32.5K, 1.4M. +public func formatTokens(_ count: Int) -> String { + let abs = Swift.abs(count) + if abs < 1000 { return "\(count)" } + let k = Double(abs) / 1000 + if k < 1000 { return String(format: "%.1fK", k) } + let m = k / 1000 + if m < 1000 { return String(format: "%.1fM", m) } + return String(format: "%.1fB", m / 1000) +} diff --git a/Sources/CodeIslandCore/UsageScanners/UsageScannerCoordinator.swift b/Sources/CodeIslandCore/UsageScanners/UsageScannerCoordinator.swift new file mode 100644 index 00000000..4ceea141 --- /dev/null +++ b/Sources/CodeIslandCore/UsageScanners/UsageScannerCoordinator.swift @@ -0,0 +1,141 @@ +import Foundation + +/// Coordinates all usage scanners and merges their results into a single +/// `UsageSnapshot`. Each scanner is an independent `UsageScanner` that owns +/// its parsing, file enumeration, and cache management. +public enum UsageScannerCoordinator { + public static let sparklineHours = 12 + public static let historyDays = 14 + + /// One-shot convenience (tests, callers without persistent state). + public static func scan( + claudeHome: String = NSHomeDirectory() + "/.claude", + ompHome: String = NSHomeDirectory() + "/.omp", + codexHome: String = NSHomeDirectory() + "/.codex", + cursorStorage: String = NSHomeDirectory() + "/Library/Application Support/Cursor/User/globalStorage", + now: Date = Date() + ) -> UsageSnapshot { + var cache = UsageFileCache() + return scan( + claudeHome: claudeHome, ompHome: ompHome, codexHome: codexHome, + cursorStorage: cursorStorage, now: now, cache: &cache) + } + + /// Persistent-state scan: callers keep a `UsageFileCache` across scans so + /// only new transcript bytes are parsed. + public static func scan( + claudeHome: String = NSHomeDirectory() + "/.claude", + ompHome: String = NSHomeDirectory() + "/.omp", + codexHome: String = NSHomeDirectory() + "/.codex", + cursorStorage: String = NSHomeDirectory() + "/Library/Application Support/Cursor/User/globalStorage", + now: Date = Date(), + cache: inout UsageFileCache + ) -> UsageSnapshot { + let calendar = Calendar.current + let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) + let midnight = calendar.startOfDay(for: now) + let sparklineStart = now.addingTimeInterval(-Double(sparklineHours) * 3600) + let historyStart = midnight.addingTimeInterval(-Double(historyDays - 1) * 86_400) + let cutoff = min(fiveHoursAgo, midnight, sparklineStart, historyStart) + + let fm = FileManager.default + let scanners: [UsageScanner] = [ + ClaudeCodeScanner(claudeHome: claudeHome), + OMPScanner(ompHome: ompHome), + CodexScanner(codexHome: codexHome), + CursorScanner(cursorStorage: cursorStorage), + ] + + // Run all scanners concurrently — each only touches its own files + // (different directories), so cache.files keys never collide. + // Copy cache to a local value so escaping closures can capture it. + var baseCache = cache + let scannerCount = scanners.count + let results = (0..(nil) } + let group = DispatchGroup() + for i in 0..() + for r in results { + if let r = r.value { + allActiveFiles.formUnion(r.activeFiles) + baseCache.files.merge(r.files) { _, new in new } + } + } + cache = baseCache + + // Prune cache entries that fell out of the mtime window. + cache.files = cache.files.filter { allActiveFiles.contains($0.key) } + + // Tally all cached entries into aggregates. + var last5h = ClaudeUsageTotals() + var today = ClaudeUsageTotals() + var hourly = [Int](repeating: 0, count: sparklineHours) + var daily = [ClaudeUsageTotals](repeating: ClaudeUsageTotals(), count: historyDays) + var perSourceTotals: [String: ClaudeUsageTotals] = [:] + var perSourceDaily: [String: [ClaudeUsageTotals]] = [:] + + for (_, entry) in cache.files { + let src = entry.source.isEmpty ? "Unknown" : entry.source + for message in entry.entries where message.timestamp >= cutoff { + guard message.timestamp <= now else { continue } + if message.timestamp >= fiveHoursAgo { last5h.add(message.usage) } + if message.timestamp >= midnight { today.add(message.usage) } + let hoursAgo = Int(now.timeIntervalSince(message.timestamp) / 3600) + if hoursAgo >= 0 && hoursAgo < sparklineHours { + hourly[sparklineHours - 1 - hoursAgo] += message.usage.outputTokens + } + let messageDay = calendar.startOfDay(for: message.timestamp) + let daysAgo = Int(midnight.timeIntervalSince(messageDay) / 86_400) + if daysAgo >= 0 && daysAgo < historyDays { + daily[historyDays - 1 - daysAgo].add(message.usage) + } + perSourceTotals[src, default: ClaudeUsageTotals()].add(message.usage) + if daysAgo >= 0 && daysAgo < historyDays { + var sd = perSourceDaily[src] ?? [ClaudeUsageTotals](repeating: ClaudeUsageTotals(), count: historyDays) + sd[historyDays - 1 - daysAgo].add(message.usage) + perSourceDaily[src] = sd + } + } + } + + let allSourceNames = scanners.map { $0.sourceName } + let perSource = allSourceNames.map { name in + SourceTotals( + name: name, + total: perSourceTotals[name] ?? ClaudeUsageTotals(), + dailyTotals: perSourceDaily[name] ?? [ClaudeUsageTotals](repeating: ClaudeUsageTotals(), count: historyDays) + ) + }.filter { !$0.total.isEmpty } + + for s in perSource { + usageLogger.notice("perSource [\(s.name, privacy: .public)]: in=\(s.total.inputTokens) out=\(s.total.outputTokens) cache=\(s.total.cacheReadTokens) msgs=\(s.total.messageCount)") + } + + return UsageSnapshot( + last5h: last5h, today: today, + hourlyOutputTokens: hourly, dailyTotals: daily, + perSource: perSource, scannedAt: now) + } +} + +/// Result of one scanner's run. +private struct ScanResult { + let activeFiles: Set + let files: [String: UsageFileCache.FileEntry] +} + +/// Thread-safe box for storing a scan result from a background queue. +private final class UnsafeBox { + var value: T + init(_ value: T) { self.value = value } +} diff --git a/Tests/CodeIslandCoreTests/ClaudeUsageScannerTests.swift b/Tests/CodeIslandCoreTests/ClaudeUsageScannerTests.swift index 4ce4800e..1ed2ee59 100644 --- a/Tests/CodeIslandCoreTests/ClaudeUsageScannerTests.swift +++ b/Tests/CodeIslandCoreTests/ClaudeUsageScannerTests.swift @@ -3,11 +3,15 @@ import XCTest final class ClaudeUsageScannerTests: XCTestCase { private var home: String! + private var ompHome: String! override func setUpWithError() throws { home = NSTemporaryDirectory() + "usage-tests-" + UUID().uuidString try FileManager.default.createDirectory( atPath: home + "/projects/p1", withIntermediateDirectories: true) + // OMP scanning is on by default; point it at a nonexistent dir so the + // fixtures stay isolated from the user's real ~/.omp transcripts. + ompHome = home + "/no-omp" } override func tearDown() { @@ -61,7 +65,7 @@ final class ClaudeUsageScannerTests: XCTestCase { try lines.joined(separator: "\n") .write(toFile: home + "/projects/p1/s.jsonl", atomically: true, encoding: .utf8) - let snap = ClaudeUsageScanner.scan(claudeHome: home, now: now) + let snap = ClaudeUsageScanner.scan(claudeHome: home, ompHome: ompHome, now: now) XCTAssertEqual(snap.last5h.inputTokens, 100) XCTAssertEqual(snap.last5h.outputTokens, 10) @@ -85,7 +89,7 @@ final class ClaudeUsageScannerTests: XCTestCase { .write(toFile: path, atomically: true, encoding: .utf8) var cache = ClaudeUsageScanner.FileCache() - let first = ClaudeUsageScanner.scan(claudeHome: home, now: now, cache: &cache) + let first = ClaudeUsageScanner.scan(claudeHome: home, ompHome: ompHome, now: now, cache: &cache) XCTAssertEqual(first.last5h.inputTokens, 100) let consumedAfterFirst = try XCTUnwrap(cache.files[path]?.consumedBytes) XCTAssertGreaterThan(consumedAfterFirst, 0) @@ -96,7 +100,7 @@ final class ClaudeUsageScannerTests: XCTestCase { handle.write(Data((assistantLine(id: "b", at: now.addingTimeInterval(-1800), input: 7, output: 3) + "\n").utf8)) handle.closeFile() - let second = ClaudeUsageScanner.scan(claudeHome: home, now: now, cache: &cache) + let second = ClaudeUsageScanner.scan(claudeHome: home, ompHome: ompHome, now: now, cache: &cache) XCTAssertEqual(second.last5h.inputTokens, 107) XCTAssertEqual(second.last5h.messageCount, 2) XCTAssertGreaterThan(try XCTUnwrap(cache.files[path]?.consumedBytes), consumedAfterFirst) @@ -110,7 +114,7 @@ final class ClaudeUsageScannerTests: XCTestCase { try (full + partial).write(toFile: path, atomically: true, encoding: .utf8) var cache = ClaudeUsageScanner.FileCache() - let snap = ClaudeUsageScanner.scan(claudeHome: home, now: now, cache: &cache) + let snap = ClaudeUsageScanner.scan(claudeHome: home, ompHome: ompHome, now: now, cache: &cache) XCTAssertEqual(snap.last5h.messageCount, 1) // Offset stops at the last complete line so the partial line is retried. XCTAssertEqual(cache.files[path]?.consumedBytes, UInt64(full.utf8.count)) @@ -124,19 +128,19 @@ final class ClaudeUsageScannerTests: XCTestCase { .write(toFile: path, atomically: true, encoding: .utf8) var cache = ClaudeUsageScanner.FileCache() - _ = ClaudeUsageScanner.scan(claudeHome: home, now: now, cache: &cache) + _ = ClaudeUsageScanner.scan(claudeHome: home, ompHome: ompHome, now: now, cache: &cache) // Replace with a shorter file (e.g. transcript rewritten). try (assistantLine(id: "c", at: now.addingTimeInterval(-600), input: 1, output: 2) + "\n") .write(toFile: path, atomically: true, encoding: .utf8) - let snap = ClaudeUsageScanner.scan(claudeHome: home, now: now, cache: &cache) + let snap = ClaudeUsageScanner.scan(claudeHome: home, ompHome: ompHome, now: now, cache: &cache) XCTAssertEqual(snap.last5h.inputTokens, 1) XCTAssertEqual(snap.last5h.messageCount, 1) } func testScanEmptyHome() { - let snap = ClaudeUsageScanner.scan(claudeHome: home + "/nonexistent", now: noon) + let snap = ClaudeUsageScanner.scan(claudeHome: home + "/nonexistent", ompHome: ompHome, now: noon) XCTAssertTrue(snap.last5h.isEmpty) XCTAssertTrue(snap.today.isEmpty) } diff --git a/bin/run-quit-launch.sh b/bin/run-quit-launch.sh new file mode 100755 index 00000000..870b8008 --- /dev/null +++ b/bin/run-quit-launch.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Shared quit-then-launch helper used by `make run` / `make restart`. +# +# Usage: bin/run-quit-launch.sh +# APP_PATH may be a signed .app bundle or a bare executable. +# +# Mirrors the quit_app() / launch_app() logic in +# scripts/dev-hot-restart.sh so behavior stays consistent across both +# entry points. + +set -euo pipefail + +APP_PATH="${1:?usage: run-quit-launch.sh }" +APP_NAME="CodeIsland" + +log() { printf '[run] %s\n' "$*"; } +fail() { printf '[run] ERROR: %s\n' "$*" >&2; exit 1; } + +# --- Quit any running instance -------------------------------------------- + +quit_app() { + local existing_pids + existing_pids="$(pgrep -x "$APP_NAME" || true)" + [[ -z "$existing_pids" ]] && return 0 + + log "Stopping existing $APP_NAME process(es): $existing_pids" + local pid + for pid in $existing_pids; do + kill -TERM "$pid" >/dev/null 2>&1 || true + done + + local deadline all_gone + deadline=$((SECONDS + 2)) + while ((SECONDS < deadline)); do + all_gone=1 + for pid in $existing_pids; do + if kill -0 "$pid" >/dev/null 2>&1; then all_gone=0; break; fi + done + ((all_gone == 1)) && return 0 + sleep 0.1 + done + + log "SIGTERM did not stop app within 2s; escalating to SIGKILL" + for pid in $existing_pids; do + kill -KILL "$pid" >/dev/null 2>&1 || true + done + + deadline=$((SECONDS + 2)) + while ((SECONDS < deadline)); do + all_gone=1 + for pid in $existing_pids; do + if kill -0 "$pid" >/dev/null 2>&1; then all_gone=0; break; fi + done + ((all_gone == 1)) && return 0 + sleep 0.1 + done + + fail "Existing $APP_NAME process(es) still alive after SIGKILL; aborting restart" +} + +# --- Launch ---------------------------------------------------------------- + +launch_app() { + case "$APP_PATH" in + *.app/Contents/MacOS/*) + log "Launching bundle: $APP_PATH" + open -a "$APP_PATH" + ;; + *.app) + log "Launching bundle: $APP_PATH" + open "$APP_PATH" + ;; + *) + log "Launching bare executable: $APP_PATH" + log "NOTE: Buddy Bluetooth requires a signed .app bundle with Bluetooth entitlements" + "$APP_PATH" & + ;; + esac +} + +# --- Run ------------------------------------------------------------------- + +quit_app +launch_app