Skip to content

Commit a30462a

Browse files
wxtskyclaude
andcommitted
fix: resolve 10 verified findings from deep review of today's changes
- ZCode installer no longer flips a user's explicit hooks.enabled=false (master switch stays theirs; our hooks remain dormant until re-enabled) - Git branch resolution moved off the reducer to a detached task fired after reduce: remote sessions can no longer probe the local filesystem (remoteHostId is authoritative post-reduce), network-mounted cwds can't beachball the main actor, and SessionStart snapshot rebuilds re-resolve via a throttled unresolved-branch retry - Terax activation moved below the Zellij branch so zellij-in-Terax keeps precise pane focus - Dot-leaf peeling now matches an explicit agent-metadata whitelist: .dotfiles/.config keep their own names and are no longer 'unhelpful' - Linked-worktree detection requires /.git/worktrees/ — submodules under a directory named worktrees no longer get the ⧉ badge - formatTokens unit selection uses the rounded value (999,950 → 1M, adds B) - Usage scanner is incremental: per-file consumed-byte offsets, complete lines only, truncation resets — day-long transcripts are read once - Cursor projects-path decode memoized (NSCache) out of the render path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c62ace8 commit a30462a

11 files changed

Lines changed: 344 additions & 103 deletions

Sources/CodeIsland/AppState.swift

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ final class AppState {
121121
/// Refreshed lazily on panel expansion (no resident timer, no API calls).
122122
var claudeUsage: ClaudeUsageScanner.Snapshot?
123123
private var usageScanInFlight = false
124+
/// Incremental parse state — round-trips through each detached scan so
125+
/// growing transcripts are only read past their last consumed offset.
126+
private var usageFileCache = ClaudeUsageScanner.FileCache()
124127

125128
/// Glance completion mode: an agent finished while the pill was collapsed —
126129
/// light the dot instead of expanding. Cleared when the user expands the
@@ -836,15 +839,44 @@ final class AppState {
836839
guard !usageScanInFlight else { return }
837840
if let scannedAt = claudeUsage?.scannedAt, Date().timeIntervalSince(scannedAt) < 120 { return }
838841
usageScanInFlight = true
842+
let cacheCopy = usageFileCache
839843
Task.detached(priority: .utility) {
840-
let snapshot = ClaudeUsageScanner.scan()
844+
var cache = cacheCopy
845+
let snapshot = ClaudeUsageScanner.scan(cache: &cache)
841846
await MainActor.run { [weak self] in
842847
self?.claudeUsage = snapshot
848+
self?.usageFileCache = cache
843849
self?.usageScanInFlight = false
844850
}
845851
}
846852
}
847853

854+
/// Last unresolved-branch probe per session — keeps `gitBranch == nil`
855+
/// (non-repo cwds, SessionStart snapshot rebuilds) from probing on every event.
856+
private var gitBranchCheckedAt: [String: Date] = [:]
857+
858+
/// Branch resolution runs detached: .git probing on a dead network mount
859+
/// must never beachball the main actor. Triggers on cwd changes, at Stop
860+
/// (the turn may have switched branches), and while unresolved (throttled).
861+
private func maybeRefreshGitBranch(for sessionId: String, cwdBefore: String?, normalizedEventName: String) {
862+
guard let session = sessions[sessionId],
863+
session.remoteHostId == nil,
864+
let cwd = session.cwd else { return }
865+
let unresolvedDue = session.gitBranch == nil
866+
&& Date().timeIntervalSince(gitBranchCheckedAt[sessionId] ?? .distantPast) > 60
867+
guard cwd != cwdBefore || normalizedEventName == "Stop" || unresolvedDue else { return }
868+
gitBranchCheckedAt[sessionId] = Date()
869+
Task.detached(priority: .utility) {
870+
let info = GitBranchReader.read(cwd: cwd)
871+
await MainActor.run { [weak self] in
872+
guard let self, var s = self.sessions[sessionId], s.cwd == cwd else { return }
873+
s.gitBranch = info?.branch
874+
s.gitIsWorktree = info?.isWorktree ?? false
875+
self.sessions[sessionId] = s
876+
}
877+
}
878+
}
879+
848880
private func flashGlanceCompletionIndicator() {
849881
guard !surface.isExpanded else { return } // user is already looking
850882
glanceCompletionActive = true
@@ -1055,6 +1087,7 @@ final class AppState {
10551087
let normalizedEventName = EventNormalizer.normalize(event.eventName)
10561088
let prevStatus = sessions[sessionId]?.status
10571089
let wasWaiting = prevStatus == .waitingApproval || prevStatus == .waitingQuestion
1090+
let cwdBeforeReduce = sessions[sessionId]?.cwd
10581091

10591092
// Cache PreToolUse payloads so downstream events sharing tool_use_id can be
10601093
// correlated, and drain queue entries whose agent already moved on.
@@ -1067,6 +1100,10 @@ final class AppState {
10671100

10681101
let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: maxHistory)
10691102

1103+
// After reduce: remoteHostId is authoritative (extractMetadata just ran),
1104+
// so a remote session can never probe the local filesystem here.
1105+
maybeRefreshGitBranch(for: sessionId, cwdBefore: cwdBeforeReduce, normalizedEventName: normalizedEventName)
1106+
10701107
// Backfill model after metadata extraction. Hooks are inconsistent across providers,
10711108
// so retry with a cooldown instead of giving up permanently on the first miss.
10721109
if sessions[sessionId]?.isRemote != true {
@@ -2164,11 +2201,6 @@ final class AppState {
21642201
snapshot.zellijSessionName = p.zellijSessionName
21652202
snapshot.weztermPaneId = p.weztermPaneId
21662203
snapshot.lastActivity = p.lastActivity
2167-
// Branch is re-read, not persisted — it may have changed between runs.
2168-
if let cwd = p.cwd, let info = GitBranchReader.read(cwd: cwd) {
2169-
snapshot.gitBranch = info.branch
2170-
snapshot.gitIsWorktree = info.isWorktree
2171-
}
21722204
// Restore persisted cliPid only if the process is still alive — avoids
21732205
// stale sessions reappearing briefly after the app or IDE restarts (#46).
21742206
if let pid = p.cliPid, pid > 0 {
@@ -2184,6 +2216,8 @@ final class AppState {
21842216
}
21852217
sessions[p.sessionId] = snapshot
21862218
refreshProviderTitle(for: p.sessionId)
2219+
// Branch is re-read, not persisted — it may have changed between runs.
2220+
maybeRefreshGitBranch(for: p.sessionId, cwdBefore: nil, normalizedEventName: "SessionStart")
21872221
// Reattach exit monitoring without changing the restored idle/running snapshot.
21882222
tryMonitorSession(p.sessionId)
21892223
}

Sources/CodeIsland/ConfigInstaller.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2132,7 +2132,12 @@ struct ConfigInstaller {
21322132
events[event] = entries
21332133
}
21342134

2135-
hooksRoot["enabled"] = true
2135+
// `enabled` is the user's master switch over ALL their hooks — never
2136+
// flip an explicit false (that would silently re-arm every third-party
2137+
// hook command they turned off). Our hooks stay dormant in that case.
2138+
if hooksRoot["enabled"] == nil {
2139+
hooksRoot["enabled"] = true
2140+
}
21362141
hooksRoot["events"] = events
21372142

21382143
return JSONMinimalEditor.setTopLevelValue(in: baseText, key: "hooks", value: hooksRoot) ?? contents

Sources/CodeIsland/TerminalActivator.swift

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,20 @@ struct TerminalActivator {
171171
return
172172
}
173173

174+
// --- Zellij multiplexer: precise pane → tab focus, then activate parent terminal ---
175+
// Must come before tmux/cmux/iTerm/Ghostty branches AND the Terax branch below:
176+
// Zellij runs *inside* one of those terminals, so termApp/termBundleId points to
177+
// the host shell. The presence of zellijPaneId is what disambiguates "running
178+
// inside Zellij" from "plain shell".
179+
if let zellijPane = session.zellijPaneId, !zellijPane.isEmpty {
180+
activateZellij(
181+
paneId: zellijPane,
182+
sessionName: session.zellijSessionName,
183+
preferredParentBundleId: session.termBundleId
184+
)
185+
return
186+
}
187+
174188
// --- Terax (native webview terminal): app-level activation only ---
175189
// Like Superset, Terax (app.crynta.terax) is a single-window app whose tabs are
176190
// drawn inside a webview. It exports no per-pane env id (only TERAX_TERMINAL /
@@ -179,25 +193,13 @@ struct TerminalActivator {
179193
// per-tab precision is impossible upstream. Without this branch Terax has no
180194
// TERM_PROGRAM, so detectRunningTerminal() would misroute the click to whichever
181195
// other terminal happens to be running. Bring its window forward (Space-aware,
182-
// same as Superset) via bundle id.
196+
// same as Superset) via bundle id. Kept AFTER the Zellij branch so a Zellij
197+
// session hosted in Terax still gets precise pane focus first.
183198
if session.termBundleId == "app.crynta.terax" || lower == "terax" {
184199
activateByBundleId("app.crynta.terax")
185200
return
186201
}
187202

188-
// --- Zellij multiplexer: precise pane → tab focus, then activate parent terminal ---
189-
// Must come before tmux/cmux/iTerm/Ghostty branches: Zellij runs *inside* one of
190-
// those terminals, so termApp/termBundleId points to the host shell. The presence
191-
// of zellijPaneId is what disambiguates "running inside Zellij" from "plain shell".
192-
if let zellijPane = session.zellijPaneId, !zellijPane.isEmpty {
193-
activateZellij(
194-
paneId: zellijPane,
195-
sessionName: session.zellijSessionName,
196-
preferredParentBundleId: session.termBundleId
197-
)
198-
return
199-
}
200-
201203
// --- tmux: switch pane first, then fall through to terminal-specific activation ---
202204
if let pane = session.tmuxPane, !pane.isEmpty {
203205
activateTmux(pane: pane, tmuxEnv: session.tmuxEnv)

Sources/CodeIslandCore/ClaudeUsageScanner.swift

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,39 @@ public enum ClaudeUsageScanner {
4444
}
4545
}
4646

47+
/// Per-file incremental parse state. Transcripts are append-only, so each
48+
/// rescan reads only the bytes past `consumedBytes` — a day-long multi-MB
49+
/// transcript is never re-read in full. Value semantics: the caller owns a
50+
/// copy, hands it to the background scan, and stores the returned state.
51+
public struct FileCache: Sendable {
52+
struct CachedMessage: Sendable, Equatable {
53+
let timestamp: Date
54+
let usage: ClaudeUsageTotals
55+
}
56+
struct FileEntry: Sendable {
57+
var consumedBytes: UInt64 = 0
58+
var entries: [CachedMessage] = []
59+
// Dedupe is per file: an assistant message's continuation lines
60+
// repeat its id within the same transcript; ids never straddle files.
61+
var seenIds: Set<String> = []
62+
}
63+
var files: [String: FileEntry] = [:]
64+
public init() {}
65+
}
66+
67+
/// One-shot convenience (tests, callers without persistent state).
4768
public static func scan(
4869
claudeHome: String = NSHomeDirectory() + "/.claude",
4970
now: Date = Date()
71+
) -> Snapshot {
72+
var cache = FileCache()
73+
return scan(claudeHome: claudeHome, now: now, cache: &cache)
74+
}
75+
76+
public static func scan(
77+
claudeHome: String = NSHomeDirectory() + "/.claude",
78+
now: Date = Date(),
79+
cache: inout FileCache
5080
) -> Snapshot {
5181
let fiveHoursAgo = now.addingTimeInterval(-5 * 3600)
5282
let midnight = Calendar.current.startOfDay(for: now)
@@ -56,7 +86,7 @@ public enum ClaudeUsageScanner {
5686
var last5h = ClaudeUsageTotals()
5787
var today = ClaudeUsageTotals()
5888
var hourly = [Int](repeating: 0, count: sparklineHours)
59-
var seenMessageIds = Set<String>()
89+
var activeFiles = Set<String>()
6090

6191
let fm = FileManager.default
6292
let projectsDir = claudeHome + "/projects"
@@ -70,25 +100,55 @@ public enum ClaudeUsageScanner {
70100
guard let attrs = try? fm.attributesOfItem(atPath: path),
71101
let mtime = attrs[.modificationDate] as? Date,
72102
mtime >= cutoff else { continue }
73-
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
74-
75-
for line in contents.split(separator: "\n") {
76-
guard let parsed = parseAssistantUsage(String(line)),
77-
parsed.timestamp >= cutoff, parsed.timestamp <= now,
78-
!seenMessageIds.contains(parsed.messageId) else { continue }
79-
seenMessageIds.insert(parsed.messageId)
80-
if parsed.timestamp >= fiveHoursAgo { last5h.add(parsed.usage) }
81-
if parsed.timestamp >= midnight { today.add(parsed.usage) }
82-
let hoursAgo = Int(now.timeIntervalSince(parsed.timestamp) / 3600)
103+
activeFiles.insert(path)
104+
let size = (attrs[.size] as? NSNumber)?.uint64Value ?? 0
105+
106+
var entry = cache.files[path] ?? FileCache.FileEntry()
107+
if size < entry.consumedBytes {
108+
// Truncated or replaced — start over.
109+
entry = FileCache.FileEntry()
110+
}
111+
if size > entry.consumedBytes {
112+
consumeNewLines(path: path, into: &entry)
113+
}
114+
entry.entries.removeAll { $0.timestamp < cutoff }
115+
cache.files[path] = entry
116+
117+
for message in entry.entries where message.timestamp <= now {
118+
if message.timestamp >= fiveHoursAgo { last5h.add(message.usage) }
119+
if message.timestamp >= midnight { today.add(message.usage) }
120+
let hoursAgo = Int(now.timeIntervalSince(message.timestamp) / 3600)
83121
if hoursAgo >= 0 && hoursAgo < sparklineHours {
84-
hourly[sparklineHours - 1 - hoursAgo] += parsed.usage.outputTokens
122+
hourly[sparklineHours - 1 - hoursAgo] += message.usage.outputTokens
85123
}
86124
}
87125
}
88126
}
127+
// Files that fell out of the mtime window carry no in-window entries.
128+
cache.files = cache.files.filter { activeFiles.contains($0.key) }
89129
return Snapshot(last5h: last5h, today: today, hourlyOutputTokens: hourly, scannedAt: now)
90130
}
91131

132+
/// Read bytes past `entry.consumedBytes` and parse the COMPLETE lines only —
133+
/// a partial trailing line (writer mid-append) is left for the next scan.
134+
private static func consumeNewLines(path: String, into entry: inout FileCache.FileEntry) {
135+
guard let handle = FileHandle(forReadingAtPath: path) else { return }
136+
defer { handle.closeFile() }
137+
handle.seek(toFileOffset: entry.consumedBytes)
138+
let data = handle.readDataToEndOfFile()
139+
guard let lastNewline = data.lastIndex(of: UInt8(ascii: "\n")) else { return }
140+
let consumable = data[data.startIndex...lastNewline]
141+
entry.consumedBytes += UInt64(consumable.count)
142+
guard let text = String(data: consumable, encoding: .utf8) else { return }
143+
144+
for line in text.split(separator: "\n") {
145+
guard let parsed = parseAssistantUsage(String(line)),
146+
!entry.seenIds.contains(parsed.messageId) else { continue }
147+
entry.seenIds.insert(parsed.messageId)
148+
entry.entries.append(.init(timestamp: parsed.timestamp, usage: parsed.usage))
149+
}
150+
}
151+
92152
/// Parse one transcript line into (timestamp, message id, usage) — nil for
93153
/// non-assistant lines and lines without usage.
94154
static func parseAssistantUsage(_ line: String) -> (timestamp: Date, messageId: String, usage: ClaudeUsageTotals)? {
@@ -124,15 +184,17 @@ public enum ClaudeUsageScanner {
124184
fractionalFormatter.date(from: raw) ?? plainFormatter.date(from: raw)
125185
}
126186

127-
/// Compact human token count: 950, 32.5K, 1.4M.
187+
/// Compact human token count: 950, 32.5K, 1.4M. Unit selection uses the
188+
/// rounded value so 999,950 rolls over to "1M" instead of "1000K".
128189
public static func formatTokens(_ count: Int) -> String {
129-
switch count {
130-
case ..<1000:
131-
return "\(count)"
132-
case ..<1_000_000:
133-
return String(format: "%.1fK", Double(count) / 1000).replacingOccurrences(of: ".0K", with: "K")
134-
default:
135-
return String(format: "%.1fM", Double(count) / 1_000_000).replacingOccurrences(of: ".0M", with: "M")
190+
if count < 1000 { return "\(count)" }
191+
func fmt(_ value: Double, _ unit: String) -> String {
192+
String(format: "%.1f\(unit)", value).replacingOccurrences(of: ".0\(unit)", with: unit)
136193
}
194+
let thousands = Double(count) / 1000
195+
if thousands < 999.95 { return fmt(thousands, "K") }
196+
let millions = Double(count) / 1_000_000
197+
if millions < 999.95 { return fmt(millions, "M") }
198+
return fmt(Double(count) / 1_000_000_000, "B")
137199
}
138200
}

Sources/CodeIslandCore/GitBranchReader.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ public enum GitBranchReader {
3131
}
3232
guard let pointer = try? String(contentsOfFile: gitPath, encoding: .utf8),
3333
let gitDir = parseGitdirPointer(pointer, relativeTo: dir) else { return nil }
34-
// Submodules are also gitdir-pointer checkouts (".../modules/...")
35-
// but are not linked worktrees.
36-
return headInfo(gitDir: gitDir, isWorktree: gitDir.contains("/worktrees/"))
34+
// Linked worktrees resolve to <repo>/.git/worktrees/<name>.
35+
// Submodules are also gitdir-pointer checkouts but resolve to
36+
// .git/modules/… — and a repo merely *located under* a
37+
// directory named "worktrees" must not match either.
38+
return headInfo(gitDir: gitDir, isWorktree: gitDir.contains("/.git/worktrees/"))
3739
}
3840
let parent = (dir as NSString).deletingLastPathComponent
3941
if parent == dir || parent.isEmpty { return nil }

0 commit comments

Comments
 (0)