diff --git a/NotchIA.xcodeproj/project.pbxproj b/NotchIA.xcodeproj/project.pbxproj index f5c23f4..aa78d2e 100644 --- a/NotchIA.xcodeproj/project.pbxproj +++ b/NotchIA.xcodeproj/project.pbxproj @@ -1289,7 +1289,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20904; + CURRENT_PROJECT_VERSION = 20905; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1297,7 +1297,7 @@ INFOPLIST_KEY_CFBundleDisplayName = NotchIAXPCHelper; INFOPLIST_KEY_NSHumanReadableCopyright = ""; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.4; + MARKETING_VERSION = 2.9.5; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia.NotchIAXPCHelper; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -1315,7 +1315,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20904; + CURRENT_PROJECT_VERSION = 20905; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1323,7 +1323,7 @@ INFOPLIST_KEY_CFBundleDisplayName = NotchIAXPCHelper; INFOPLIST_KEY_NSHumanReadableCopyright = ""; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.4; + MARKETING_VERSION = 2.9.5; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia.NotchIAXPCHelper; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -1474,7 +1474,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20904; + CURRENT_PROJECT_VERSION = 20905; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"NotchIA/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -1502,7 +1502,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.4; + MARKETING_VERSION = 2.9.5; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1530,7 +1530,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 20904; + CURRENT_PROJECT_VERSION = 20905; DEAD_CODE_STRIPPING = YES; DEPLOYMENT_POSTPROCESSING = YES; DEVELOPMENT_ASSET_PATHS = "\"NotchIA/Preview Content\""; @@ -1558,7 +1558,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.4; + MARKETING_VERSION = 2.9.5; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/NotchIA/ContentView.swift b/NotchIA/ContentView.swift index bee17a6..0500e1b 100644 --- a/NotchIA/ContentView.swift +++ b/NotchIA/ContentView.swift @@ -235,6 +235,10 @@ struct ContentView: View { } private var hasCompactCodeAssistantSession: Bool { + // Pro lockdown — mirror `shouldShowCodeAssistantCompactActivity`. The + // AI live-activity is a Pro feature; without this guard the closed-notch + // chin-width calc and idle-view branch could surface it for free users. + guard LicenseManager.shared.state.isPro else { return false } // Only show the compact AI view when an assistant is actually doing // something (thinking, running tools, waiting for permission). A // running-but-idle CLI process should not keep the notch occupied. diff --git a/NotchIA/Localizable.xcstrings b/NotchIA/Localizable.xcstrings index 8b821dc..21dfb0b 100644 --- a/NotchIA/Localizable.xcstrings +++ b/NotchIA/Localizable.xcstrings @@ -876,6 +876,7 @@ } }, "%@ — outil : %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -8098,6 +8099,35 @@ } } }, + "Blanc" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weiß" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "White" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blanco" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blanc" + } + } + } + }, "Boost your productivity with Clipboard Manager" : { "extractionState" : "stale", "localizations" : { @@ -8228,7 +8258,6 @@ } }, "Calendar" : { - "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -10522,6 +10551,7 @@ } }, "Claude attend ta permission" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -10818,6 +10848,35 @@ } } }, + "Clipboard" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zwischenablage" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clipboard" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portapapeles" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presse-papiers" + } + } + } + }, "Cliquer pour choisir un fichier — ou y déposer un PDF / texte / code" : { "localizations" : { "de" : { @@ -26010,7 +26069,6 @@ } }, "Media" : { - "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/NotchIA/NotchIAViewCoordinator.swift b/NotchIA/NotchIAViewCoordinator.swift index 8b500b2..d6c2e17 100644 --- a/NotchIA/NotchIAViewCoordinator.swift +++ b/NotchIA/NotchIAViewCoordinator.swift @@ -76,6 +76,10 @@ class NotchIAViewCoordinator: ObservableObject { openLastTabByDefault = false if ShelfStateViewModel.shared.isEmpty || !Defaults[.openShelfByDefault] { currentView = .claudeCode + // .claudeCode is Pro-gated and may be hidden/disabled — + // fall back to the first visible tab so we never land on a + // hidden or locked view. + sanitizeCurrentViewIfNeeded() } } } @@ -251,7 +255,13 @@ class NotchIAViewCoordinator: ObservableObject { icon: String = "" ) { sneakPeekDuration = duration - if type != .music && type != .pomodoro && type != .screenRecording { + // The hudReplacement gate is only for the volume/brightness/backlight + // HUD-replacement peeks. Feature widgets (music, pomodoro, screen + // recording, clipboard, download) are user/engine-driven and must show + // regardless — otherwise the clipboard/download peek was silently + // dropped for everyone who hasn't enabled HUD replacement (the default). + if type != .music && type != .pomodoro && type != .screenRecording + && type != .clipboard && type != .download { // close() if !Defaults[.hudReplacement] { return diff --git a/NotchIA/enums/generic.swift b/NotchIA/enums/generic.swift index fbb6d6f..0bec343 100644 --- a/NotchIA/enums/generic.swift +++ b/NotchIA/enums/generic.swift @@ -38,19 +38,19 @@ extension NotchViews { var label: String { switch self { case .media: - return "Media" + return String(localized: "Media") case .calendar: - return "Calendar" + return String(localized: "Calendar") case .shelf: - return "Shelf" + return String(localized: "Shelf") case .claudeCode: return "Claude" case .digest: - return "Digest" + return String(localized: "Digest") case .pomodoro: - return "Concentration" + return String(localized: "Concentration") case .clipboard: - return "Clipboard" + return String(localized: "Clipboard") } } @@ -150,7 +150,7 @@ enum SliderColorEnum: String, CaseIterable, Defaults.Serializable { var title: String { switch self { case .white: - return "Blanc" + return String(localized: "Blanc") case .albumArt: return String(localized: "Suivre la pochette") case .accent: diff --git a/NotchIA/managers/ClaudeCodeManager.swift b/NotchIA/managers/ClaudeCodeManager.swift index 7c56576..3ab2677 100644 --- a/NotchIA/managers/ClaudeCodeManager.swift +++ b/NotchIA/managers/ClaudeCodeManager.swift @@ -162,6 +162,33 @@ final class ClaudeCodeManager: ObservableObject { /// interrupt an in-flight prompt response. private let idleCheckDelay: TimeInterval = 600.0 + // MARK: - Per-prompt token de-duplication + + /// Message ids whose `usage` has already been folded into the current + /// prompt cycle's `promptTokensTotal` for the SELECTED session. Claude + /// Code writes a single assistant response as several JSONL lines + /// (thinking + text + tool_use) that all repeat an IDENTICAL `usage` + /// block under the same message id; without this set each turn's tokens + /// were counted 2-3×. Reset whenever a fresh real user prompt starts a + /// new cycle. Seeded by `reconstructSelectedCycle`. + private var countedMsgIds: Set = [] + /// Same de-dup set, keyed by session id, for the multi-session parser. + private var countedMsgIdsBySession: [String: Set] = [:] + + /// Last `end_turn` message id we emitted a completion notification for, + /// per session. The same logical `end_turn` is written on multiple JSONL + /// lines (thinking + text), sometimes 15-25s apart, so a time-window + /// dedup can't catch it — we dedup by the message id instead. Seeded by + /// the cycle reconstruction so a gap-replayed end_turn never re-notifies. + private var lastNotifiedEndTurnId: [String: String] = [:] + + /// Consecutive session-scan ticks where the SELECTED session's process + /// was not enumerated. `proc_pidinfo`/sysctl is documented to flake ~33% + /// of the time under hardened runtime, and a single transient miss used + /// to nil the selection → tear down the reader → wipe a running chrono. + /// We now require 2+ consecutive misses before clearing. + private var selectedSessionMissCount: Int = 0 + // MARK: - Initialization private init() { @@ -271,25 +298,49 @@ final class ClaudeCodeManager: ObservableObject { mostActive.id != selectedSession?.id, let mostActiveTime = sessionStates[mostActive.id]?.lastMessageTime, now.timeIntervalSince(mostActiveTime) < 10 { + // Never steal focus (and the running chrono) from a session + // that is mid-cycle. `lastMessageTime` goes stale during a long + // tool run (build/Bash/Read writes no JSONL for >30s) yet the + // agent is still working — without this guard the auto-switch + // would yank selection away and wipe its chrono. + let selectedHasOpenCycle: Bool = selectedSession.flatMap { sel in + sessionStates[sel.id].map { + $0.activityStartedAt != nil || $0.isThinking || !$0.activeTools.isEmpty + } + } ?? false + let currentLastActivity = selectedSession.flatMap { sessionStates[$0.id]?.lastMessageTime } let currentIdleFor = currentLastActivity.map { now.timeIntervalSince($0) } ?? .infinity - if currentIdleFor > 30 { + if currentIdleFor > 30 && !selectedHasOpenCycle { log.info("Auto-switching from idle selection to more active session") selectSession(mostActive) } } } - // Clear selection if selected session no longer exists - if let selected = selectedSession, - !sessions.contains(where: { $0.id == selected.id }) { - selectedSession = nil - state = ClaudeCodeState() - stopWatchingSessionFile() + // Clear selection if the selected session's process is gone — but only + // after 2+ CONSECUTIVE scan misses. `proc_pidinfo`/sysctl flakes ~33% + // of the time under hardened runtime; a single transient miss used to + // nil the selection, tear down the reader and wipe a running chrono + // mid-cycle ("le chrono se coupe alors que l'agent n'a pas fini"). + if let selected = selectedSession { + if sessions.contains(where: { $0.id == selected.id }) { + selectedSessionMissCount = 0 + } else { + selectedSessionMissCount += 1 + if selectedSessionMissCount >= 2 { + selectedSessionMissCount = 0 + selectedSession = nil + state = ClaudeCodeState() + stopWatchingSessionFile() + } + } + } else { + selectedSessionMissCount = 0 } // MARK: Multi-Session Watching - Watch ALL sessions for permission detection @@ -608,11 +659,11 @@ final class ClaudeCodeManager: ObservableObject { isLoadingHistory = true let watchedSessionId = session.id Task { [weak self] in - let lines: [String] = await Task.detached(priority: .utility) { + let replay = await Task.detached(priority: .utility) { () -> (lines: [String], offset: UInt64) in var collected: [String] = [] let reader = JSONLSessionReader() - reader.loadRecent(file: jsonlFile) { collected.append($0) } - return collected + let offset = reader.loadRecent(file: jsonlFile) { collected.append($0) } + return (collected, offset) }.value guard let self else { return } // La sélection a pu changer pendant le replay (timer de scan 3s @@ -620,18 +671,27 @@ final class ClaudeCodeManager: ObservableObject { // pas écraser l'état de la nouvelle session (fix race condition). guard self.selectedSession?.id == watchedSessionId else { return } - for line in lines { self.parseJSONLLine(line) } + for line in replay.lines { self.parseJSONLLine(line) } self.isLoadingHistory = false self.state.activeTools.removeAll() - self.state.isThinking = false - self.state.activityStartedAt = nil self.pendingToolChecks.removeAll() + // Reconstruct the in-flight prompt cycle (chrono start + per-prompt + // token total) from the replayed transcript instead of blindly + // wiping activityStartedAt. This is what makes the chrono survive a + // mid-work attach AND an idempotent re-arm — the dominant causes of + // "le chrono ne s'active pas / se coupe alors que l'agent n'a pas + // fini". Mirrors CodexManager's hasOpenTurn model. + self.reconstructSelectedCycle(from: replay.lines, sessionId: watchedSessionId) self.state.lastUpdateTime = Date() self.loadRateLimitsSnapshot() let reader = JSONLSessionReader() + // Resume the live tail EXACTLY where the replay snapshot ended + // (no gap, no overlap) so a prompt fired during (re)selection is + // never lost. let started = reader.start( file: jsonlFile, + fromOffset: replay.offset, onLine: { [weak self] line in self?.parseJSONLLine(line) }, @@ -676,9 +736,17 @@ final class ClaudeCodeManager: ObservableObject { // MARK: - Multi-Session Watching (Permission Detection for All Sessions) - /// Start watching a specific session for permission detection + /// Start watching a specific session for permission detection. + /// + /// Mirrors `startWatchingSessionFile`: replay the tail off-main, then arm + /// the live watcher AT THE REPLAY'S END OFFSET (gap-free) inside the same + /// continuation, and reconstruct the in-flight cycle from the transcript so + /// the per-session chrono/tokens are correct on a mid-work attach. The + /// `isLoadingHistoryBySession` flag doubles as a re-entry guard so a 3s + /// scan tick can't spin up a second watcher while the replay is in flight. private func startWatchingSession(_ session: ClaudeSession) { guard sessionReaders[session.id] == nil, + isLoadingHistoryBySession[session.id] != true, let projectKey = session.projectKey else { return } @@ -702,28 +770,50 @@ final class ClaudeCodeManager: ObservableObject { sessionState.isConnected = true sessionStates[session.id] = sessionState - // Seed UI from recent history first (50KB tail). - loadRecentHistoryForSession(from: jsonlFile, sessionId: session.id) - - // Capture session.id locally so the closure doesn't retain ClaudeSession. let capturedId = session.id - let reader = JSONLSessionReader() - let started = reader.start( - file: jsonlFile, - onLine: { [weak self] line in - self?.parseJSONLLineForSession(line, sessionId: capturedId) - }, - onChunkComplete: { [weak self] in - guard let self else { return } - self.sessionStates[capturedId]?.lastUpdateTime = Date() - self.resetIdleTimer() + isLoadingHistoryBySession[capturedId] = true + + Task { [weak self] in + let replay = await Task.detached(priority: .utility) { () -> (lines: [String], offset: UInt64) in + var collected: [String] = [] + let reader = JSONLSessionReader() + let offset = reader.loadRecent(file: jsonlFile) { collected.append($0) } + return (collected, offset) + }.value + guard let self else { return } + // Session may have been dropped while replaying. + guard self.sessionStates[capturedId] != nil else { + self.isLoadingHistoryBySession.removeValue(forKey: capturedId) + return } - ) - guard started else { - sessionStates.removeValue(forKey: session.id) - return + + for line in replay.lines { + self.parseJSONLLineForSession(line, sessionId: capturedId) + } + self.isLoadingHistoryBySession[capturedId] = false + self.sessionStates[capturedId]?.activeTools.removeAll() + self.pendingToolChecksBySession[capturedId]?.removeAll() + self.reconstructSessionCycle(from: replay.lines, sessionId: capturedId) + + let reader = JSONLSessionReader() + let started = reader.start( + file: jsonlFile, + fromOffset: replay.offset, + onLine: { [weak self] line in + self?.parseJSONLLineForSession(line, sessionId: capturedId) + }, + onChunkComplete: { [weak self] in + guard let self else { return } + self.sessionStates[capturedId]?.lastUpdateTime = Date() + self.resetIdleTimer() + } + ) + guard started else { + self.sessionStates.removeValue(forKey: capturedId) + return + } + self.sessionReaders[capturedId] = reader } - sessionReaders[session.id] = reader } /// Stop watching a specific session @@ -741,32 +831,6 @@ final class ClaudeCodeManager: ObservableObject { log.debug("[Multi] Stopped watching session: \(sessionId)") } - /// Load recent history for a specific session via the shared reader - /// helper (50KB tail, last 50 complete lines). - private func loadRecentHistoryForSession(from file: URL, sessionId: String) { - // Disable permission tracking during history loading - isLoadingHistoryBySession[sessionId] = true - // Read off-main to avoid jank when a JSONL tail is slow to seek (e.g. - // session living on an external SSD or iCloud-evicted file). Parsing - // stays on main so `parseJSONLLineForSession` keeps its actor isolation. - Task { [weak self] in - let lines: [String] = await Task.detached(priority: .utility) { - var collected: [String] = [] - let reader = JSONLSessionReader() - reader.loadRecent(file: file) { collected.append($0) } - return collected - }.value - guard let self else { return } - for line in lines { - self.parseJSONLLineForSession(line, sessionId: sessionId) - } - self.isLoadingHistoryBySession[sessionId] = false - self.sessionStates[sessionId]?.activeTools.removeAll() - self.sessionStates[sessionId]?.isThinking = false - self.pendingToolChecksBySession[sessionId]?.removeAll() - } - } - /// Reset the idle detection timer private func resetIdleTimer() { idleCheckTimer?.invalidate() @@ -842,13 +906,19 @@ final class ClaudeCodeManager: ObservableObject { lastActivityTime = now } - private func captureCompletionIfNeeded(for state: inout ClaudeCodeState, at now: Date) { - guard let startedAt = state.activityStartedAt else { return } + /// Freezes the chrono for the selected session's state. Returns `true` + /// only when a real cycle was in flight (activityStartedAt was set) and a + /// duration was actually captured — callers use this to gate the + /// completion notification so stray end_turns never notify. + @discardableResult + private func captureCompletionIfNeeded(for state: inout ClaudeCodeState, at now: Date) -> Bool { + guard let startedAt = state.activityStartedAt else { return false } state.lastCompletedAt = now state.lastCompletedDuration = max(0, now.timeIntervalSince(startedAt)) state.activityStartedAt = nil lastActivityTime = now + return true } private func isRecentlyCompleted(_ state: ClaudeCodeState) -> Bool { @@ -856,6 +926,119 @@ final class ClaudeCodeManager: ObservableObject { return Date().timeIntervalSince(lastCompletedAt) < completionDisplayDuration } + // MARK: - Cycle Reconstruction (idempotent, transcript-derived) + + /// The in-flight prompt-cycle state recomputed purely from a transcript + /// window. Idempotent: re-running it over the same lines yields the same + /// result, which is what lets a re-arm (auto-switch, transient scan miss) + /// rebuild the chrono instead of wiping it. + private struct ReconstructedCycle { + var activityStartedAt: Date? + var isThinking: Bool + var promptTokensTotal: Int + var countedIds: Set + var lastEndTurnId: String? + } + + /// Walk a replayed JSONL window and derive whether a prompt cycle is open + /// (a real user prompt with no closing `end_turn` after it, or a tail that + /// is mid-work), the chrono start (the prompt's transcript timestamp), and + /// the de-duplicated per-prompt token total. Pure & `nonisolated` — only + /// touches the passed-in lines. + private nonisolated static func reconstructCycle(from lines: [String]) -> ReconstructedCycle { + var cycleStart: Date? + var lastEndTurn: Date? + var firstTs: Date? + var tokens = 0 + var seen: Set = [] + var lastEndTurnId: String? + // Did the most recent role-bearing line indicate active work (anything + // that is not a terminal end_turn)? Drives the fallback for very long + // cycles whose opening prompt scrolled above the replay window. + var tailActive = false + + for line in lines { + guard let data = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = json["message"] as? [String: Any] else { continue } + let ts = parseTimestamp(json["timestamp"]) + if firstTs == nil { firstTs = ts } + + if let role = message["role"] as? String { + if role == "user" { + if messageIsRealUserPrompt(message) { + cycleStart = ts ?? cycleStart + tokens = 0 + seen.removeAll() + } + tailActive = true + } else if role == "assistant" { + tailActive = true + } + } + + if let usage = message["usage"] as? [String: Any] { + let mid = message["id"] as? String + if mid == nil || !seen.contains(mid!) { + if let mid { seen.insert(mid) } + tokens += (usage["input_tokens"] as? Int ?? 0) + + (usage["output_tokens"] as? Int ?? 0) + + (usage["cache_creation_input_tokens"] as? Int ?? 0) + } + } + + if (message["stop_reason"] as? String) == "end_turn" { + lastEndTurn = ts ?? lastEndTurn + lastEndTurnId = message["id"] as? String ?? lastEndTurnId + tailActive = false + } + } + + let openByPrompt = cycleStart != nil && (lastEndTurn == nil || cycleStart! > lastEndTurn!) + let openByTail = cycleStart == nil && tailActive + let hasOpenTurn = openByPrompt || openByTail + + return ReconstructedCycle( + activityStartedAt: hasOpenTurn ? (cycleStart ?? firstTs) : nil, + isThinking: hasOpenTurn, + promptTokensTotal: tokens, + countedIds: seen, + lastEndTurnId: lastEndTurnId + ) + } + + /// Apply a reconstructed cycle to the SELECTED session's `state` after a + /// replay, instead of unconditionally clearing activityStartedAt. + private func reconstructSelectedCycle(from lines: [String], sessionId: String) { + let cycle = Self.reconstructCycle(from: lines) + state.activityStartedAt = cycle.activityStartedAt + state.isThinking = cycle.isThinking + state.promptTokensTotal = cycle.promptTokensTotal + countedMsgIds = cycle.countedIds + if cycle.activityStartedAt != nil { + state.lastCompletedAt = nil + state.lastCompletedDuration = 0 + } + // Seed the completion-notif dedup so a gap-replayed end_turn (already + // shown in history) cannot fire a duplicate "Claude a terminé". + if let id = cycle.lastEndTurnId { + lastNotifiedEndTurnId[sessionId] = id + } + } + + /// Apply a reconstructed cycle to a background session's per-session state. + private func reconstructSessionCycle(from lines: [String], sessionId: String) { + let cycle = Self.reconstructCycle(from: lines) + sessionStates[sessionId]?.activityStartedAt = cycle.activityStartedAt + sessionStates[sessionId]?.isThinking = cycle.isThinking + sessionStates[sessionId]?.promptTokensTotal = cycle.promptTokensTotal + countedMsgIdsBySession[sessionId] = cycle.countedIds + if cycle.activityStartedAt != nil { + sessionStates[sessionId]?.lastCompletedAt = nil + sessionStates[sessionId]?.lastCompletedDuration = 0 + } + } + /// Parse a JSONL line for a specific session (focused on permission detection) private func parseJSONLLineForSession(_ line: String, sessionId: String) { guard let data = line.data(using: .utf8), @@ -865,12 +1048,12 @@ final class ClaudeCodeManager: ObservableObject { // Parse message content for tool detection if let message = json["message"] as? [String: Any] { - parseMessageForSession(message, sessionId: sessionId) + parseMessageForSession(message, sessionId: sessionId, timestamp: Self.parseTimestamp(json["timestamp"])) } } /// Parse message content for a specific session - private func parseMessageForSession(_ message: [String: Any], sessionId: String) { + private func parseMessageForSession(_ message: [String: Any], sessionId: String, timestamp: Date? = nil) { let isLoadingHistory = isLoadingHistoryBySession[sessionId] == true // Extract model @@ -895,10 +1078,11 @@ final class ClaudeCodeManager: ObservableObject { if Self.messageIsRealUserPrompt(message) { sessionStates[sessionId]?.latestThinking = "" sessionStates[sessionId]?.isThinking = true - sessionStates[sessionId]?.activityStartedAt = Date() + sessionStates[sessionId]?.activityStartedAt = timestamp ?? Date() sessionStates[sessionId]?.lastCompletedAt = nil sessionStates[sessionId]?.lastCompletedDuration = 0 sessionStates[sessionId]?.promptTokensTotal = 0 + countedMsgIdsBySession[sessionId] = [] lastActivityTime = Date() } else { // Continuation (tool_result) — voir parseMessage : on @@ -911,20 +1095,23 @@ final class ClaudeCodeManager: ObservableObject { } } - // Per-prompt token accumulator for this session. + // Per-prompt token accumulator for this session. Same dedup-by-id + + // exclude-cache_read correction as the selected-session parser. if !isLoadingHistory, let usage = message["usage"] as? [String: Any] { - let turnTokens = (usage["input_tokens"] as? Int ?? 0) - + (usage["output_tokens"] as? Int ?? 0) - + (usage["cache_creation_input_tokens"] as? Int ?? 0) - + (usage["cache_read_input_tokens"] as? Int ?? 0) - sessionStates[sessionId]?.promptTokensTotal += turnTokens + let mid = message["id"] as? String + if mid == nil || countedMsgIdsBySession[sessionId]?.contains(mid!) != true { + if let mid { countedMsgIdsBySession[sessionId, default: []].insert(mid) } + sessionStates[sessionId]?.promptTokensTotal += (usage["input_tokens"] as? Int ?? 0) + + (usage["output_tokens"] as? Int ?? 0) + + (usage["cache_creation_input_tokens"] as? Int ?? 0) + } } // End-of-turn — final response received, prompt cycle is over. if let stopReason = message["stop_reason"] as? String, stopReason == "end_turn", !isLoadingHistory { - captureCompletionIfNeeded(for: sessionId, at: Date()) + captureCompletionIfNeeded(for: sessionId, at: timestamp ?? Date()) sessionStates[sessionId]?.isThinking = false } @@ -1031,10 +1218,10 @@ final class ClaudeCodeManager: ObservableObject { if elapsed >= permissionCheckDelay { // This tool has been pending too long - likely needs permission if let tool = sessionStates[sessionId]?.activeTools.first(where: { $0.id == toolId }) { - if sessionStates[sessionId]?.needsPermission != true { - let sessionName = availableSessions.first(where: { $0.id == sessionId })?.displayName ?? String(localized: "Claude Code") - sendPermissionNotification(toolName: tool.toolName, sessionName: sessionName) - } + // In-notch visual hint only — no OS notification. The + // 2.5s "no tool_result yet" heuristic can't tell a + // permission wait from a tool that simply runs long, so + // notifying here spammed "Claude attend ta permission". sessionStates[sessionId]?.needsPermission = true sessionStates[sessionId]?.pendingPermissionTool = tool.toolName break @@ -1247,6 +1434,16 @@ final class ClaudeCodeManager: ObservableObject { } private struct StatuslineSnapshot { + /// False when the cache file couldn't be read/parsed at all — the + /// consumer keeps the last-good values instead of blanking everything + /// on a transient unreadable file. + var hasData: Bool + /// True only when the cache's `session_id` matches the SELECTED + /// session. All sessions share one cache file; when it belongs to + /// another session (or our session id isn't known yet) the token / + /// context fields are NOT ours, so we keep last-good instead of + /// blinking to 0 or to another session's numbers. + var tokensValidForSession: Bool var rateLimits: ClaudeRateLimits var currentRequestTokens: Int var contextWindowSize: Int @@ -1265,12 +1462,20 @@ final class ClaudeCodeManager: ObservableObject { // Only publish if the session hasn't changed since we kicked off the read. // Otherwise we'd apply stale data to a freshly-selected session. guard self.state.sessionId == currentSessionId else { return } + // Transient unreadable file → keep everything last-good. + guard snapshot.hasData else { return } + // Quota windows are account-global → always safe to apply. self.state.rateLimits = snapshot.rateLimits - self.state.currentRequestTokens = snapshot.currentRequestTokens - if snapshot.contextWindowSize > 0 { - self.state.contextWindowSize = snapshot.contextWindowSize + // Token / context fields are session-specific → only apply when + // the cache is actually for the selected session, otherwise keep + // the last-good value (no blink to 0 / wrong session). + if snapshot.tokensValidForSession { + self.state.currentRequestTokens = snapshot.currentRequestTokens + if snapshot.contextWindowSize > 0 { + self.state.contextWindowSize = snapshot.contextWindowSize + } + self.state.contextUsedPercentage = snapshot.contextUsedPercentage } - self.state.contextUsedPercentage = snapshot.contextUsedPercentage } } } @@ -1281,6 +1486,8 @@ final class ClaudeCodeManager: ObservableObject { currentSessionId: String ) -> StatuslineSnapshot { let empty = StatuslineSnapshot( + hasData: false, + tokensValidForSession: false, rateLimits: ClaudeRateLimits(), currentRequestTokens: 0, contextWindowSize: 0, @@ -1292,11 +1499,12 @@ final class ClaudeCodeManager: ObservableObject { return empty } - if let cacheSessionId = object.string("session_id"), - !currentSessionId.isEmpty, - cacheSessionId != currentSessionId { - return empty - } + // The token / context fields are only ours when the cache's session_id + // matches the selected session AND we actually know our session id. + let cacheSessionId = object.string("session_id") + let tokensValid = !currentSessionId.isEmpty + && cacheSessionId != nil + && cacheSessionId == currentSessionId let rateLimits = parseRateLimitsStatic(from: object.dictionary("rate_limits")) let contextWindow = object.dictionary("context_window") @@ -1304,6 +1512,8 @@ final class ClaudeCodeManager: ObservableObject { let size = contextWindow?["context_window_size"] as? Int ?? 0 let usedPct = contextWindow?.double("used_percentage") ?? 0 return StatuslineSnapshot( + hasData: true, + tokensValidForSession: tokensValid, rateLimits: rateLimits, currentRequestTokens: tokens, contextWindowSize: size, @@ -1364,9 +1574,10 @@ final class ClaudeCodeManager: ObservableObject { state.gitBranch = gitBranch } - // Parse message content + // Parse message content. Thread the line's transcript timestamp so the + // chrono anchors to real wall-clock (survives mid-work attach & re-arm). if let message = json["message"] as? [String: Any] { - parseMessage(message) + parseMessage(message, timestamp: Self.parseTimestamp(json["timestamp"])) } // Parse tool use results @@ -1383,6 +1594,14 @@ final class ClaudeCodeManager: ObservableObject { /// - content String → prompt texte direct → vrai prompt. /// - content array contenant uniquement des `tool_result` → continuation. /// - content array avec au moins un bloc `text` → vrai prompt. + /// Parse the top-level ISO8601 `timestamp` of a JSONL line into a `Date`. + /// Reuses the fractional-second-aware parser already shipped for Codex so + /// the chrono reflects the transcript's real wall-clock instead of the + /// moment we happened to parse the line. + nonisolated static func parseTimestamp(_ value: Any?) -> Date? { + CodexJSONHelpers.parseDate(value as? String) + } + nonisolated static func messageIsRealUserPrompt(_ message: [String: Any]) -> Bool { if message["content"] is String { return true } if let items = message["content"] as? [[String: Any]] { @@ -1394,7 +1613,7 @@ final class ClaudeCodeManager: ObservableObject { return true } - private func parseMessage(_ message: [String: Any]) { + private func parseMessage(_ message: [String: Any], timestamp: Date? = nil) { // Extract model if let model = message["model"] as? String { state.model = model @@ -1414,10 +1633,11 @@ final class ClaudeCodeManager: ObservableObject { // chrono + tokens repartent à 0. state.latestThinking = "" state.isThinking = true - state.activityStartedAt = Date() + state.activityStartedAt = timestamp ?? Date() state.lastCompletedAt = nil state.lastCompletedDuration = 0 state.promptTokensTotal = 0 + countedMsgIds.removeAll() lastActivityTime = Date() } else { // Message role=user portant uniquement des tool_result @@ -1440,14 +1660,20 @@ final class ClaudeCodeManager: ObservableObject { state.tokenUsage.cacheReadInputTokens = usage["cache_read_input_tokens"] as? Int ?? state.tokenUsage.cacheReadInputTokens state.tokenUsage.cacheCreationInputTokens = usage["cache_creation_input_tokens"] as? Int ?? state.tokenUsage.cacheCreationInputTokens - // Cumulative tokens for the current prompt cycle. Each assistant - // turn contributes its own usage; we sum until role=user resets. - let turnTokens = (usage["input_tokens"] as? Int ?? 0) - + (usage["output_tokens"] as? Int ?? 0) - + (usage["cache_creation_input_tokens"] as? Int ?? 0) - + (usage["cache_read_input_tokens"] as? Int ?? 0) + // Cumulative tokens for the current prompt cycle. Two corrections + // vs. the naive per-line sum that inflated this 60-600×: + // 1. De-dup by message id — one assistant response spans several + // JSONL lines repeating an identical `usage` block. + // 2. EXCLUDE cache_read_input_tokens — it's the whole context + // re-read every turn (~150k each), not work done this turn. if !isLoadingHistory { - state.promptTokensTotal += turnTokens + let mid = message["id"] as? String + if mid == nil || !countedMsgIds.contains(mid!) { + if let mid { countedMsgIds.insert(mid) } + state.promptTokensTotal += (usage["input_tokens"] as? Int ?? 0) + + (usage["output_tokens"] as? Int ?? 0) + + (usage["cache_creation_input_tokens"] as? Int ?? 0) + } } } @@ -1458,16 +1684,26 @@ final class ClaudeCodeManager: ObservableObject { if let stopReason = message["stop_reason"] as? String, stopReason == "end_turn", !isLoadingHistory { - captureCompletionIfNeeded(for: &state, at: Date()) + // `hadCycle` is true ONLY when this end_turn closes a real, + // tracked prompt cycle (activityStartedAt was set). That gate + // stops "Claude a terminé — 0 s" notifications for stray end_turns + // (mid-work attach, /compact summaries, auto turns). + let hadCycle = captureCompletionIfNeeded(for: &state, at: timestamp ?? Date()) state.isThinking = false - // Notification "Claude a terminé" pour la session sélectionnée. - if let session = selectedSession { - sendCompletionNotification( - sessionId: session.id, - sessionName: session.displayName, - durationSec: Int(state.lastCompletedDuration.rounded()), - tokens: state.promptTokensTotal - ) + // Notification "Claude a terminé" pour la session sélectionnée — + // uniquement à la vraie fin du travail, et dédupliquée par l'id du + // message end_turn (écrit sur plusieurs lignes JSONL). + if hadCycle, let session = selectedSession { + let endTurnId = message["id"] as? String + if endTurnId == nil || lastNotifiedEndTurnId[session.id] != endTurnId { + if let endTurnId { lastNotifiedEndTurnId[session.id] = endTurnId } + sendCompletionNotification( + sessionId: session.id, + sessionName: session.displayName, + durationSec: Int(state.lastCompletedDuration.rounded()), + tokens: state.promptTokensTotal + ) + } } } @@ -1657,10 +1893,9 @@ final class ClaudeCodeManager: ObservableObject { if elapsed >= permissionCheckDelay { // This tool has been pending too long - likely needs permission if let tool = state.activeTools.first(where: { $0.id == toolId }) { - if !state.needsPermission { - log.info("Tool '\(tool.toolName)' waiting for permission") - sendPermissionNotification(toolName: tool.toolName, sessionName: selectedSession?.displayName ?? String(localized: "Claude Code")) - } + // In-notch visual hint only — see note in the multi-session + // path. No OS notification: the 2.5s heuristic false-fires + // on every long-running auto-approved tool. state.needsPermission = true state.pendingPermissionTool = tool.toolName markSelectedStateActive() @@ -1668,7 +1903,6 @@ final class ClaudeCodeManager: ObservableObject { } else { // Tool not in activeTools - still show permission indicator if !state.needsPermission { - sendPermissionNotification(toolName: "outil", sessionName: selectedSession?.displayName ?? String(localized: "Claude Code")) state.needsPermission = true state.pendingPermissionTool = "Tool" markSelectedStateActive() @@ -1743,38 +1977,10 @@ final class ClaudeCodeManager: ObservableObject { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } } - /// Tracking pour dédoublonner les notifications de permission. - /// Clé = "sessionName:toolName", valeur = date de dernière notif. - /// Fenêtre de dédup : 60s — évite le spam si le même outil reste - /// en attente longtemps (le watcher relance la check toutes les 2.5s). - private var lastPermissionNotifAt: [String: Date] = [:] - /// Tracking pour dédoublonner les notifications de fin de prompt. /// Clé = sessionId, valeur = date de dernière notif. Fenêtre 5s. private var lastCompletionNotifAt: [String: Date] = [:] - private func sendPermissionNotification(toolName: String, sessionName: String) { - guard Defaults[.enableCodeAssistantNotifications] else { return } - let key = "\(sessionName):\(toolName)" - if let last = lastPermissionNotifAt[key], Date().timeIntervalSince(last) < 60 { - return - } - lastPermissionNotifAt[key] = Date() - - let content = UNMutableNotificationContent() - content.title = String(localized: "Claude attend ta permission") - content.body = String(localized: "\(sessionName) — outil : \(toolName)") - content.sound = .default - content.interruptionLevel = .active - - let request = UNNotificationRequest( - identifier: "claude-permission-\(key)", - content: content, - trigger: nil - ) - UNUserNotificationCenter.current().add(request) - } - /// Notifie l'utilisateur que Claude a terminé son tour (stop_reason == /// end_turn). Dédoublonné par session sur 5s pour éviter les doubles. fileprivate func sendCompletionNotification( diff --git a/NotchIA/managers/CodexManager.swift b/NotchIA/managers/CodexManager.swift index 7b16c26..8fa57d1 100644 --- a/NotchIA/managers/CodexManager.swift +++ b/NotchIA/managers/CodexManager.swift @@ -67,9 +67,19 @@ final class CodexManager: ObservableObject { private var selectedSessionReader: JSONLSessionReader? private var isLoadingHistory: Bool = false - // MARK: - Idle Timer (prevents flickering between tool calls) + // MARK: - Idle Timer (crash safety net only) private var idleCheckTimer: Timer? - private let idleCheckDelay: TimeInterval = 8.0 + /// Long fallback only. The authoritative end-of-turn is now the live + /// `task_complete` / `turn_aborted` event. This timer just catches a + /// crashed/orphaned turn that never wrote a terminator. It must be far + /// longer than any silent reasoning gap — at 8s it was freezing the chrono + /// mid-turn on every pause ("le chrono se coupe alors que l'agent n'a pas + /// fini"); and it never clears a turn we still know is open. + private let idleCheckDelay: TimeInterval = 90.0 + /// Live turn boundaries tracked from `task_started` / `task_complete` / + /// `turn_aborted` so the chrono is governed by real events, not silence. + private var liveTaskStartedAt: Date? + private var liveTaskCompletedAt: Date? private var lastActivityTime: Date = .distantPast private let activityGracePeriod: TimeInterval = 2.0 private let completionDisplayDuration: TimeInterval = 8.0 @@ -224,6 +234,19 @@ final class CodexManager: ObservableObject { // and never re-fires already-replayed lines. loadRecentHistory(from: selectedSession.fileURL) + // Reconstruct an open turn from the FULL transcript (a task_started can + // sit above the 50-line tail on a long turn). Without this, attaching + // mid-turn showed a dead chrono until the next live write — which can + // be minutes away. Only copy the turn-state fields so we don't clobber + // the tokens/tools/metadata loadRecentHistory just seeded. + let seeded = parseSession(from: selectedSession) + if seeded.activityStartedAt != nil { + state.activityStartedAt = seeded.activityStartedAt + state.isThinking = true + liveTaskStartedAt = seeded.activityStartedAt + liveTaskCompletedAt = nil + } + let reader = JSONLSessionReader() let started = reader.start( file: selectedSession.fileURL, @@ -306,6 +329,8 @@ final class CodexManager: ObservableObject { state.lastCompletedAt = nil state.lastCompletedDuration = 0 state.lastUpdateTime = Date() + liveTaskStartedAt = nil + liveTaskCompletedAt = nil } // MARK: - Idle Timer @@ -314,19 +339,62 @@ final class CodexManager: ObservableObject { idleCheckTimer?.invalidate() idleCheckTimer = Timer.scheduledTimer(withTimeInterval: idleCheckDelay, repeats: false) { [weak self] _ in Task { @MainActor in - self?.state.isThinking = false - self?.markStateIdleIfNeeded() + guard let self else { return } + // Rest the "thinking" dots after a long silence, but NEVER + // freeze the chrono while a turn is still known to be open — + // only `task_complete` / `turn_aborted` ends a turn. This is + // the safety net for a crashed turn that never terminated. + self.state.isThinking = false + if !self.liveHasOpenTurn { + self.markStateIdleIfNeeded() + } } } } + /// Note that the file just produced output. Deliberately does NOT arm or + /// clear the chrono — that is owned exclusively by `beginLiveTurn` / + /// `endLiveTurn` so a trailing `onChunkComplete` (which fires right after a + /// `task_complete` line) can never re-arm a phantom chrono or wipe the + /// "Terminé" celebration. private func markStateActive() { lastActivityTime = Date() - state.lastCompletedAt = nil - state.lastCompletedDuration = 0 + } + + /// True while a Codex turn is open (a `task_started` with no matching + /// `task_complete` / `turn_aborted` since). + private var liveHasOpenTurn: Bool { + guard let started = liveTaskStartedAt else { return false } + guard let completed = liveTaskCompletedAt else { return true } + return started > completed + } + + /// Open a turn at the given transcript timestamp: arm the chrono (if not + /// already running) and mark thinking. Called on `task_started` and + /// `user_message`. + private func beginLiveTurn(at ts: Date?) { + let now = ts ?? Date() + liveTaskStartedAt = now + liveTaskCompletedAt = nil if state.activityStartedAt == nil { - state.activityStartedAt = Date() + state.activityStartedAt = now } + state.isThinking = true + state.lastCompletedAt = nil + state.lastCompletedDuration = 0 + lastActivityTime = Date() + } + + /// Close the current turn authoritatively: freeze the chrono and stop the + /// idle fallback. Called on `task_complete` and `turn_aborted`. + private func endLiveTurn(at ts: Date?) { + let now = ts ?? Date() + liveTaskCompletedAt = now + captureCompletionIfNeeded(at: now) + state.activityStartedAt = nil + state.isThinking = false + idleCheckTimer?.invalidate() + idleCheckTimer = nil } private func markStateIdleIfNeeded() { @@ -416,8 +484,13 @@ final class CodexManager: ObservableObject { switch payloadType { case "task_started": - state.isThinking = true - markStateActive() + beginLiveTurn(at: timestamp) + + case "task_complete", "turn_aborted": + // Authoritative end of a Codex turn — freeze the chrono now + // instead of waiting for the idle timer (which used to end it + // 8s late, or freeze it mid-turn on a silent reasoning gap). + endLiveTurn(at: timestamp) case "agent_message": let message = (payload.string("message") ?? "").trimmingCharacters(in: .whitespacesAndNewlines) @@ -427,7 +500,7 @@ final class CodexManager: ObservableObject { state.lastMessageTime = timestamp } if payload.string("phase") == "final_answer" { - // Keep isThinking true — the idle timer will set it to false + // Keep isThinking true — task_complete/idle ends the turn. state.isThinking = true markStateActive() } @@ -438,8 +511,7 @@ final class CodexManager: ObservableObject { state.lastUserMessage = message } // User message means Codex is about to respond - state.isThinking = true - markStateActive() + beginLiveTurn(at: timestamp) case "exec_command_end", "patch_apply_end": if let callId = payload.string("call_id") { @@ -904,6 +976,12 @@ final class CodexManager: ObservableObject { case "task_started": latestTaskStartedAt = timestamp + case "task_complete", "turn_aborted": + // Authoritative turn terminator — more reliable than the + // final_answer/final phase heuristics, which can scroll out + // of the tail on long turns. + latestTaskCompletedAt = timestamp + case "agent_message": let message = (payload.string("message") ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/NotchIA/managers/DigestManager.swift b/NotchIA/managers/DigestManager.swift index ad70f7c..9868749 100644 --- a/NotchIA/managers/DigestManager.swift +++ b/NotchIA/managers/DigestManager.swift @@ -417,10 +417,17 @@ final class DigestManager: ObservableObject { private func selectRelevantItems(from items: [DigestItem]) -> [DigestItem] { let focusTerms = digestFocusTerms() let cutoff = Date().addingTimeInterval(-86_400) - let pool = items.filter { item in - guard let publishedAt = item.publishedAt else { return false } + let recent = items.filter { item in + // Keep undated items (unparseable dates) rather than dropping them + // outright — they were silently excluded before. + guard let publishedAt = item.publishedAt else { return true } return publishedAt >= cutoff } + // If nothing is recent (slow-publishing feeds, or all dates failed to + // parse), degrade to the full fetched set instead of producing an + // empty pool that surfaces as a Digest error despite a successful + // network fetch. + let pool = recent.isEmpty ? items : recent let sourceCount = Set(pool.map(\.sourceTitle)).count let sourceLimit = sourceCount > 3 ? 5 : 10 let targetCount = 24 diff --git a/NotchIA/managers/JSONLSessionReader.swift b/NotchIA/managers/JSONLSessionReader.swift index 0d1a8bf..ca3ccc2 100644 --- a/NotchIA/managers/JSONLSessionReader.swift +++ b/NotchIA/managers/JSONLSessionReader.swift @@ -70,13 +70,20 @@ final class JSONLSessionReader { // file system — no instance state is read or written. This lets callers // run it from a Task.detached without bouncing back to the main actor // for I/O, which was causing 10–200 ms hitches when seeding session UI. + /// + /// Returns the absolute end offset (file size at read time). Hand this to + /// `start(file:fromOffset:)` so the live watcher resumes EXACTLY where the + /// replay stopped — closing the lost-line gap where appends written between + /// the replay snapshot and the watcher's own `seekToEndOfFile` would belong + /// to neither path (and a user prompt landing there never started the chrono). + @discardableResult nonisolated func loadRecent( file: URL, maxBytesFromEnd: UInt64 = JSONLSessionReader.defaultTailBytes, maxLines: Int = 50, onLine: (String) -> Void - ) { - guard let handle = try? FileHandle(forReadingFrom: file) else { return } + ) -> UInt64 { + guard let handle = try? FileHandle(forReadingFrom: file) else { return 0 } defer { try? handle.close() } let fileSize = handle.seekToEndOfFile() @@ -84,7 +91,7 @@ final class JSONLSessionReader { handle.seek(toFileOffset: startPosition) guard let data = try? handle.readToEnd(), - let content = String(data: data, encoding: .utf8) else { return } + let content = String(data: data, encoding: .utf8) else { return fileSize } let lines = content.components(separatedBy: .newlines) // If we started mid-file the first split element is probably half of @@ -95,6 +102,8 @@ final class JSONLSessionReader { for line in toProcess where !line.isEmpty { onLine(line) } + + return fileSize } /// Open the file, seek to end, install a vnode watcher. Each `.write`/ @@ -109,9 +118,17 @@ final class JSONLSessionReader { /// lines from a single vnode event has been dispatched. Use this for /// per-chunk side effects (idle timer reset, rate-limit refresh) that /// shouldn't fire per-line. + /// + /// `fromOffset` (optional): when supplied, the watcher resumes reading from + /// this absolute byte offset instead of the current EOF — pass the value + /// returned by `loadRecent` so replay and live-tail are contiguous with + /// zero gap and zero overlap. Clamped to the current EOF if the file was + /// truncated/rotated since the offset was captured (defensive — session + /// JSONLs are append-only). @discardableResult func start( file: URL, + fromOffset: UInt64? = nil, onLine: @escaping (String) -> Void, onChunkComplete: (() -> Void)? = nil ) -> Bool { @@ -127,8 +144,13 @@ final class JSONLSessionReader { return false } - handle.seekToEndOfFile() - let position = handle.offsetInFile + let eof = handle.seekToEndOfFile() + let position: UInt64 + if let fromOffset, fromOffset <= eof { + position = fromOffset + } else { + position = eof + } let fd = open(file.path, O_EVTONLY) guard fd >= 0 else { diff --git a/NotchIA/managers/LicenseManager.swift b/NotchIA/managers/LicenseManager.swift index e30ce23..805de00 100644 --- a/NotchIA/managers/LicenseManager.swift +++ b/NotchIA/managers/LicenseManager.swift @@ -306,10 +306,13 @@ final class LicenseManager: ObservableObject { } applyTokenPayload(payload) } catch { - // Clé en Keychain mais cryptographiquement invalide — on l'efface. + // Clé en Keychain mais cryptographiquement invalide — on l'efface + // et on surface explicitement l'erreur à l'utilisateur (au lieu de + // retomber silencieusement en .free, ce qui laissait l'état mort + // .proInvalidKey jamais affiché malgré son écran de paywall dédié). logger.error("Stored key fails signature verification — clearing") deleteKeyFromKeychain() - state = .free + state = .proInvalidKey } }