Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
fa21124
Fix Cursor multi-window jump, add debug logging, favorites, and stabl…
maatheusgois-dd Jul 17, 2026
5608311
feat: auto-install Codex desktop proxy for ChatGPT app capture
maatheusgois-dd Jul 18, 2026
3309c0e
feat: auto-install OMP proxy and fix Codex session status
maatheusgois-dd Jul 18, 2026
fba8ace
fix: proxies only track active sessions, idle then disappear
maatheusgois-dd Jul 18, 2026
a37b1f8
feat: add 14-day token history chart and beautify session card UI
maatheusgois-dd Jul 18, 2026
9963875
feat: add token usage history page in settings
maatheusgois-dd Jul 18, 2026
1e0a3c6
feat: collapse notch panel when opening settings
maatheusgois-dd Jul 18, 2026
6084bc1
fix: Usage page scans regardless of showUsageStats toggle
maatheusgois-dd Jul 18, 2026
e74cf4f
fix: make Usage page cards full-width
maatheusgois-dd Jul 18, 2026
47dbe13
feat: remove usage footer from notch panel
maatheusgois-dd Jul 18, 2026
a2e360a
fix: keep small usage footer, remove only big 14-day chart
maatheusgois-dd Jul 18, 2026
b55d398
diag: instrument CodeIsland omp extension with debug logs
maatheusgois-dd Jul 18, 2026
e3464ce
fix: narrow dangerous rm pattern to prevent 30s timeout on rm -rf .sw…
maatheusgois-dd Jul 19, 2026
c0f347e
feat: add per-AI token usage breakdown in settings
maatheusgois-dd Jul 19, 2026
24de849
feat: add Codex+Cursor to per-AI token usage + right-click rename
maatheusgois-dd Jul 19, 2026
bb2ea4d
feat: Cursor token usage via CSV export API (tokscale approach)
maatheusgois-dd Jul 19, 2026
e28aab6
fix: Codex cumulative token bug + footer click opens Usage settings
maatheusgois-dd Jul 19, 2026
45eda94
Refactor usage scanners to protocol-based architecture
maatheusgois-dd Jul 19, 2026
f82985f
Add Cursor gRPC fallback + tokscale cache path
maatheusgois-dd Jul 19, 2026
cf5a0cd
Add Chrome cookie extraction for Cursor usage (like spinnaker-mcp)
maatheusgois-dd Jul 19, 2026
803edb8
Add Cursor setup card in Settings — on-demand Chrome extraction
maatheusgois-dd Jul 19, 2026
a8a52b4
Fix Cursor UI: full-width disclosure button, correct dashboard URL, f…
maatheusgois-dd Jul 19, 2026
3e5800e
Reorder Usage page: summary first, Cursor setup last; add mascot icon…
maatheusgois-dd Jul 19, 2026
8906463
Cursor setup: mascot icon, remove CSV button, await JS, hour labels, …
maatheusgois-dd Jul 19, 2026
1eab2b2
Cursor setup: file importer, cookie JS, mascot sleep/awake, Remove CS…
maatheusgois-dd Jul 19, 2026
62506fb
Notch footer rotates between AI sources every 60s; no telemetry in pr…
maatheusgois-dd Jul 19, 2026
9941bef
Cursor setup: collapse on success, restyle disclosure buttons, HttpOn…
maatheusgois-dd Jul 19, 2026
ab987a2
Cursor: save cookie for reuse + auto-refresh with selectable interval
maatheusgois-dd Jul 19, 2026
1627d6f
Move Cursor cookie from plaintext file to macOS Keychain
maatheusgois-dd Jul 19, 2026
7b715a5
Fix Extract from Chrome: better error message and logging
maatheusgois-dd Jul 19, 2026
52195d7
Fix Chrome Keychain prompt not appearing: dispatch to main thread
maatheusgois-dd Jul 19, 2026
bc3654f
Make WorkosCursorSessionToken clickable to copy to clipboard
maatheusgois-dd Jul 19, 2026
0b787e4
Fix Chrome Extract: run Keychain access on main thread (no deadlock)
maatheusgois-dd Jul 19, 2026
10e76a9
Fix cookie save: run SecItemAdd on main thread, show auto-refresh sec…
maatheusgois-dd Jul 19, 2026
09f9d28
Improve Cursor CSV fetch logging to diagnose cookie errors
maatheusgois-dd Jul 19, 2026
b8c160a
Show dual status (CSV + cookie) in Cursor Setup header
maatheusgois-dd Jul 19, 2026
e3db6c1
Fix loading button size: fixed frame, show spinner with text
maatheusgois-dd Jul 19, 2026
a204868
Fix cookie URL decode + auto-refresh persistence + simplify manual CSV
maatheusgois-dd Jul 19, 2026
ebdf5e4
Fix CSV fetch timeout: 10s was too short for 3.9MB CSV download
maatheusgois-dd Jul 19, 2026
96d5677
Better error for Extract from Chrome on unsigned apps
maatheusgois-dd Jul 19, 2026
96b4812
Reorder Cursor Setup: cookie extraction first, then CSV extraction
maatheusgois-dd Jul 19, 2026
d31f67f
Show green status on disclosure buttons when data is loaded
maatheusgois-dd Jul 19, 2026
3a2d453
Restructure: each disclosure button followed by its own content
maatheusgois-dd Jul 19, 2026
74e8e38
Add billion (B) formatting + left-align disclosure content
maatheusgois-dd Jul 19, 2026
a886522
Update README with fork changes: token usage, Cursor setup, session m…
maatheusgois-dd Jul 19, 2026
9f92ca4
Fix Chrome Keychain access for unsigned apps — use legacy SecKeychain…
maatheusgois-dd Jul 19, 2026
dba7771
Fix Chrome Keychain dialog not appearing — dispatch async + legacy API
maatheusgois-dd Jul 19, 2026
81e4d88
Revert legacy SecKeychain API (endpoint security credential access), …
maatheusgois-dd Jul 19, 2026
4d93841
Move Cursor cookie from Keychain to plaintext file — endpoint securit…
maatheusgois-dd Jul 19, 2026
b66c687
Store Cursor cookie with File Protection Complete (safest endpoint se…
maatheusgois-dd Jul 19, 2026
6e37769
Switch back to Keychain with 10s startup delay + detailed logging
maatheusgois-dd Jul 19, 2026
b6b784d
Remove internal tool references from code comments
maatheusgois-dd Jul 19, 2026
d4c23ed
Keep starred sessions visible + suppress sounds while typing
maatheusgois-dd Jul 19, 2026
5882bd9
Rename to 'Await finish typing' — defer panel popups AND sounds
maatheusgois-dd Jul 19, 2026
f30f7c4
Localize hardcoded zh-CN reply placeholder in core (#1)
maatheusgois-dd Jul 19, 2026
2828009
Show which CLI is asking on the approval card (#2)
maatheusgois-dd Jul 19, 2026
a723347
Polish approval card UI + add Makefile (#3)
maatheusgois-dd Jul 19, 2026
c6910e1
Merge upstream/main: sync fork with 57 upstream improvements
maatheusgois-dd Jul 23, 2026
52f4f14
Show which CLI is asking on approval card even without a session
maatheusgois-dd Jul 23, 2026
a840e59
Add parseAssistantUsage to ClaudeUsageScanner for upstream test compat
maatheusgois-dd Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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=<path to launch> SIGN_ID=<codesign identity>'
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cwd>` 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`


<p align="center">
<img src="docs/images/notch-panel.png" width="700" alt="CodeIsland Panel Preview">
</p>
Expand Down
6 changes: 5 additions & 1 deletion Sources/CodeIsland/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -167,7 +171,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
RemoteManager.shared.shutdown()
hookServer?.stop()
appState.stopCodexAppServerWatcher()
appState.stopSessionDiscovery()
KeyboardActivityMonitor.shared.stop()
}

// MARK: - Global Shortcuts
Expand Down
152 changes: 149 additions & 3 deletions Sources/CodeIsland/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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?

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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] = [:]
Expand Down Expand Up @@ -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<Void, Never>?

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.
Expand Down Expand Up @@ -1225,7 +1360,7 @@ final class AppState {
session.remoteHostId == hostId ? key : nil
}
for id in ids {
removeSession(id)
removeSession(id, force: true)
}
refreshDerivedState()
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Loading