From 32df863a65d307ee8cc6b9db00da9cadea141832 Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 27 May 2026 10:42:35 +0100 Subject: [PATCH 1/7] fix(quota): prefer ~/.claude/.credentials.json over keychain when present Claude Code rotates its keychain item every ~8h and the rewrite wipes the trusted-app ACL, re-firing the password prompt despite "Always Allow." Upstream (anthropics/claude-code#22144) closed the fix as not planned. The credentials file path is the documented escape hatch: users who write ~/.claude/.credentials.json (mode 0600) get zero prompts because Claude itself reads the file first and we now do the same. Co-Authored-By: Claude Opus 4.7 --- panel/SessionUsage.swift | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index b45a7a3..af3763f 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -112,15 +112,32 @@ final class QuotaProbe { }.resume() } - // MARK: - Keychain - - // Read the Claude Code credentials JSON blob from the macOS Keychain and - // extract `claudeAiOauth.accessToken`. macOS prompts the user the first - // time stack-nudge reads this entry; subsequent reads are silent until - // Claude Code rotates the item, which wipes the ACL and re-fires the - // prompt. Callers cache the returned token and only re-invoke this on - // an API 401 to keep prompt frequency to a minimum. + // MARK: - Token sources + + // Prefer ~/.claude/.credentials.json when present. Claude Code itself + // reads this file before falling back to the Keychain, and users who + // want to avoid the periodic Keychain prompt (caused by Claude rotating + // the Keychain item ~every 8h, wiping the ACL grant — anthropics/claude-code#22144, + // closed as not planned) can opt in by writing the file at mode 0600. private func readAccessToken() -> String? { + if let token = readCredentialsFile() { return token } + return readKeychainToken() + } + + private func readCredentialsFile() -> String? { + let path = "\(NSHomeDirectory())/.claude/.credentials.json" + guard FileManager.default.fileExists(atPath: path), + let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let blob = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let oauth = blob["claudeAiOauth"] as? [String: Any], + let token = oauth["accessToken"] as? String, + !token.isEmpty else { + return nil + } + return token + } + + private func readKeychainToken() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: Self.keychainService, From 33914d882a423e540bd9c254bfdcf4217d6d6709 Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 27 May 2026 10:42:45 +0100 Subject: [PATCH 2/7] fix(permissions): use real AppleEvent to trigger Automation prompt AEDeterminePermissionToAutomateTarget(...askUserIfNeeded: true) often returns the cached decision without dispatching the TCC dialog, so "Reset & prompt" on Automation -> System Events appeared to do nothing. Send a real harmless AppleScript command to System Events instead; macOS reliably fires the prompt on the first AppleEvent send after a tccutil reset clears the decision. Also surface tccutil non-zero exits via stderr so silent failures become visible during dev. Co-Authored-By: Claude Opus 4.7 --- panel/Permissions.swift | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/panel/Permissions.swift b/panel/Permissions.swift index 33d689d..c84553a 100644 --- a/panel/Permissions.swift +++ b/panel/Permissions.swift @@ -67,9 +67,7 @@ enum Permissions { let options = [key: kCFBooleanTrue!] as CFDictionary _ = AXIsProcessTrustedWithOptions(options) case .automation: - let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.systemevents") - _ = AEDeterminePermissionToAutomateTarget( - target.aeDesc, typeWildCard, typeWildCard, true) + triggerAutomationPrompt() case .notifications: promptNotifications() } @@ -85,8 +83,16 @@ enum Permissions { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") task.arguments = ["reset", service, bundleID] + let err = Pipe() + task.standardError = err try? task.run() task.waitUntilExit() + if task.terminationStatus != 0 { + let msg = String(data: err.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8) ?? "" + FileHandle.standardError.write(Data( + "stack-nudge: tccutil reset \(service) failed (\(task.terminationStatus)): \(msg)\n".utf8)) + } } switch pane { @@ -95,13 +101,26 @@ enum Permissions { let options = [key: kCFBooleanTrue!] as CFDictionary _ = AXIsProcessTrustedWithOptions(options) case .automation: - let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.systemevents") - _ = AEDeterminePermissionToAutomateTarget( - target.aeDesc, typeWildCard, typeWildCard, true) + triggerAutomationPrompt() case .notifications: promptNotifications() } } + + // Force the TCC Automation prompt for System Events by actually sending + // an AppleEvent — AEDeterminePermissionToAutomateTarget often returns + // the cached decision without dispatching the dialog, so we send a + // harmless command instead. macOS shows the prompt the first time an + // app sends events to a new target, regardless of prior denial state + // (after a tccutil reset clears the decision). + private static func triggerAutomationPrompt() { + let source = "tell application \"System Events\" to count processes" + guard let script = NSAppleScript(source: source) else { return } + DispatchQueue.global(qos: .userInitiated).async { + var error: NSDictionary? + _ = script.executeAndReturnError(&error) + } + } } enum SettingsPane { From 9a3d84628aa9c84aee4e61f14488ba046b9efc4a Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 27 May 2026 10:42:53 +0100 Subject: [PATCH 3/7] fix(events): dedupe hooks fired twice within 2s Claude Code occasionally fires the same Stop hook twice in rapid succession, producing two identical banners + two list entries. Drop the second event when an identical one (same agent/kind/message/ claudeSessionID) landed within a 2s window. Co-Authored-By: Claude Opus 4.7 --- panel/EventStore.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/panel/EventStore.swift b/panel/EventStore.swift index cf0a12b..c03f5c0 100644 --- a/panel/EventStore.swift +++ b/panel/EventStore.swift @@ -143,6 +143,19 @@ final class EventStore: ObservableObject { var onAppend: ((NudgeEvent) -> Void)? func append(_ event: NudgeEvent) { + // Claude Code occasionally fires the same hook twice in rapid + // succession (observed on Stop). Drop the second event when an + // identical one landed within the dedup window. + let dedupWindow: TimeInterval = 2 + if events.contains(where: { existing in + event.timestamp.timeIntervalSince(existing.timestamp) < dedupWindow + && existing.agent == event.agent + && existing.kind == event.kind + && existing.message == event.message + && existing.claudeSessionID == event.claudeSessionID + }) { + return + } events.insert(event, at: 0) if events.count > maxEvents { events = Array(events.prefix(maxEvents)) From fcb763f3e79c2f488cde1cffdeee3782f96cb394 Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 27 May 2026 10:43:03 +0100 Subject: [PATCH 4/7] fix(panel): banner title includes session name; approve keeps panel open when more events remain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Banner title now appends the custom/claudeName label (mirroring Sessions tab and context banners) so users can tell which session fired when multiple sessions are active. - Approve no longer closes the panel unconditionally — only closes when the queue is empty, matching the dismiss flow. Approving the last event still hides so the keystroke lands in the target app. Co-Authored-By: Claude Opus 4.7 --- panel/Panel.swift | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index 1fb06cd..de6e724 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -1219,9 +1219,30 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // for the initial fire and by the snooze timer for re-fires. Request // identifier is a fresh UUID each time (macOS replaces by identifier); // event.id stays in userInfo so click handlers can find the source. + // Banner title with session label appended when we can resolve one. + // Default project name is suppressed (already implied by the title); + // only meaningful custom/claudeName labels are shown. + private func bannerTitle(for event: NudgeEvent) -> String { + guard let id = event.claudeSessionID else { return event.title } + guard let session = sessions.sessions.first(where: { $0.claudeSessionID == id }) + else { return event.title } + let custom = session.customName?.trimmingCharacters(in: .whitespaces) + let claude = session.claudeName?.trimmingCharacters(in: .whitespaces) + let label: String? + if let custom, !custom.isEmpty { + label = custom + } else if let claude, !claude.isEmpty, claude != "main-agent" { + label = claude + } else { + label = nil + } + guard let label else { return event.title } + return "\(event.title) — \(label)" + } + private func postBanner(for event: NudgeEvent) { let content = UNMutableNotificationContent() - content.title = event.title + content.title = bannerTitle(for: event) content.body = event.message content.categoryIdentifier = event.kind == .permission ? "PERMISSION" : "STOP" content.userInfo = ["eventID": event.id.uuidString] @@ -1806,7 +1827,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private func actOnSelected(approve: Bool) { guard let event = store.selectedEvent else { return } store.remove(id: event.id) - hidePanel() + // Stay on the panel if there are more events to act on; otherwise + // close so the system frontmost reverts naturally and the approval + // keystroke lands in the target app's key window (see comment above + // about why hiding must precede the keystroke dispatch). + if store.events.isEmpty { hidePanel() } let sendApproval = approve && event.hasActionButton From 688641ade090a0f53cea2145f3cfb27233d90ec5 Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 27 May 2026 11:29:34 +0100 Subject: [PATCH 5/7] fix(activator): route Cursor/VSCode CLI through captured IPC socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cursor/code --reuse-window picks the most-recently-focused window matching the path, which is wrong when multiple editor windows are open for the same project. notify.sh already captures the per-window VSCODE_IPC_HOOK_CLI as event.ipcHook but the activator never used it. Prefix the shell invocation with the captured socket path so the CLI talks to that specific window's IPC server — pinning activation to the window the agent actually ran in. Co-Authored-By: Claude Opus 4.7 --- shared/AppActivator.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index 17a89d7..3ceba1e 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -44,12 +44,26 @@ struct AppActivator { let trusted = AXIsProcessTrusted() let pressEnter = sendApproval && trusted + // When ipcHook is set (captured from VSCODE_IPC_HOOK_CLI at hook + // time), prefix the CLI invocation with it so the command talks + // to that specific window's IPC server. Without this, --reuse-window + // picks the most-recently-focused matching window — which is the + // wrong one when the user has multiple editor windows open for + // the same project. + let envPrefix: String + if let hook = ipcHook, !hook.isEmpty { + let escapedHook = hook.replacingOccurrences(of: "'", with: "'\\''") + envPrefix = "VSCODE_IPC_HOOK_CLI='\(escapedHook)' " + } else { + envPrefix = "" + } + // Step 1: activate app + switch window via CLI (no Automation needed) var err: NSDictionary? NSAppleScript(source: """ tell application "\(procName)" to activate delay 0.4 - do shell script "'\(escapedCLI)' --reuse-window '\(escapedPath)'" + do shell script "\(envPrefix)'\(escapedCLI)' --reuse-window '\(escapedPath)'" """)?.executeAndReturnError(&err) // Step 2: set frontmost (requires Automation for System Events) From ad34ae4cdde6ff7d8c8613e8daeaa2d853186f05 Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 27 May 2026 11:58:51 +0100 Subject: [PATCH 6/7] fix(activator): route iTerm2 click-to-focus via ITERM_SESSION_ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For iTerm2 with multiple tabs in the same project folder, the AX title-fragment walk picks an arbitrary matching tab — wrong when the agent runs in a specific pane. notify.sh already captures ITERM_SESSION_ID as event.sessionID; use iTerm2's AppleScript dictionary to walk windows -> tabs -> sessions and select the exact session whose id matches. Falls through to the AX path if scripting errors or the session has since been closed. Co-Authored-By: Claude Opus 4.7 --- panel/Panel.swift | 16 +++++++++++++ shared/AppActivator.swift | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/panel/Panel.swift b/panel/Panel.swift index de6e724..7c0e7f9 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -505,6 +505,14 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // logic and didReceive for the cancellation. private var bannerActivationUntil: Date = .distantPast + // Last time onAppend fired. macOS can deliver applicationShouldHandleReopen + // as a side effect of posting a banner (notably under Ghostty), not just + // when the user clicks one — so the bannerActivationUntil veto, which + // only sets in didReceive, doesn't catch this case. Suppress the deferred + // showPanel for ~2s after any event arrival so a banner post never + // pops the panel uninvited. + private var lastEventArrivalAt: Date = .distantPast + // UserDefaults keys for panel size + origin persistence. UserDefaults // lives in ~/Library/Preferences/com.stackonehq.stack-nudge.plist, so it // survives uninstall/reinstall cycles of ~/.stack-nudge/ and across @@ -606,6 +614,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, startConfigWatcher() setupNotificationCenter() store.onAppend = { [weak self] event in + self?.lastEventArrivalAt = Date() self?.postBannerIfNeeded(event) self?.refreshTranscriptStats(for: event) } @@ -1122,6 +1131,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, windowTitle: event.windowTitle, ipcHook: event.ipcHook, projectPath: event.projectPath, + sessionID: event.sessionID, sendApproval: false, agent: event.agent) } @@ -1326,6 +1336,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, windowTitle: event.windowTitle, ipcHook: event.ipcHook, projectPath: event.projectPath, + sessionID: event.sessionID, sendApproval: approve, agent: event.agent) } @@ -1469,6 +1480,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in guard let self else { return } if Date() < self.bannerActivationUntil { return } + // Suppress if a banner just posted — macOS sometimes routes a + // reopen through us as a side effect of the notification arriving, + // and the user did not actually ask for the panel. + if Date().timeIntervalSince(self.lastEventArrivalAt) < 2 { return } self.showPanel() } return false @@ -1851,6 +1866,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, windowTitle: event.windowTitle, ipcHook: event.ipcHook, projectPath: event.projectPath, + sessionID: event.sessionID, sendApproval: sendApproval, agent: event.agent ) diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index 3ceba1e..05cbf22 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -19,6 +19,7 @@ struct AppActivator { static func activate(bundleID: String, windowTitle: String? = nil, ipcHook: String? = nil, projectPath: String? = nil, + sessionID: String? = nil, sendApproval: Bool = false, agent: String? = nil) { // For matching windows/tabs we want a LOOSE fragment that survives // the user switching tabs between event time and click time: @@ -112,6 +113,18 @@ struct AppActivator { return } + // iTerm2: each tab/pane has a unique session id (captured as + // ITERM_SESSION_ID by notify.sh). Walking via AppleScript and + // selecting that exact session disambiguates between multiple + // tabs in the same project folder, which title-fragment matching + // can't resolve. Falls through to the AX-based path if the + // session id is missing or the scripting bridge errors. + if bundleID == "com.googlecode.iterm2", + let sid = sessionID, !sid.isEmpty, + focusIterm2Session(sessionID: sid) { + return + } + // Fallback: activate then AXRaise with retries (works for native terminal apps). // Retry schedule from claude-notifications-go: 150ms → 250ms → 400ms. guard let app = NSRunningApplication @@ -306,6 +319,43 @@ struct AppActivator { NSAppleScript(source: script)?.executeAndReturnError(&err) } + // MARK: - iTerm2 (AppleScript bridge) + + // iTerm2 sets ITERM_SESSION_ID on every shell. Walk windows -> tabs -> + // sessions to find the session whose id matches and select it. The + // returned bool tells the caller whether to fall through to the AX + // path: false means scripting bridge errored or the id didn't match + // any open session (closed since the event fired). + @discardableResult + private static func focusIterm2Session(sessionID: String) -> Bool { + let escaped = sessionID.replacingOccurrences(of: "\"", with: "\\\"") + let script = """ + tell application "iTerm2" + activate + set target to "\(escaped)" + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + try + if (id of s as text) is target then + tell w to select + tell t to select + tell s to select + return "matched" + end if + end try + end repeat + end repeat + end repeat + return "no-match" + end tell + """ + var err: NSDictionary? + let result = NSAppleScript(source: script)?.executeAndReturnError(&err) + guard err == nil else { return false } + return result?.stringValue == "matched" + } + // MARK: - AX tab switching (standalone terminal apps) // For tabbed terminals where the OS-window is already frontmost but From d1e5226de503ee47123a7660ee306b8424ef7c33 Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 27 May 2026 13:49:20 +0100 Subject: [PATCH 7/7] fix(activator): correct iTerm2 session-id match + AX-raise specific Cursor window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iTerm2: ITERM_SESSION_ID is 'w0t0p0:UUID' (window-tab-pane prefix + UUID) but iTerm2's AppleScript 'id of session' returns just the UUID. The previous match compared the prefixed form against the bare UUID and always missed. Strip the prefix before comparing. Cursor/VSCode: --reuse-window routes the open request to the correct window via the IPC hook, but the CLI doesn't raise that window — the most-recently-focused window pops to front instead. Add an AX raise step that matches the captured windowTitle (which includes the open filename and is window-specific), pinning activation to the agent's window. Co-Authored-By: Claude Opus 4.7 --- shared/AppActivator.swift | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index 05cbf22..bf8c2ef 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -73,6 +73,21 @@ struct AppActivator { tell application "System Events" to set frontmost of process "\(procName)" to true """)?.executeAndReturnError(&err2) + // Step 2.5: AX-raise the specific window. --reuse-window routes + // the open request to the right window's IPC server (when + // ipcHook is set), but the CLI doesn't raise that window — + // the app's most-recently-focused window pops to front + // instead. The captured windowTitle has the open filename and + // is window-specific, so AX-matching it pins activation to + // the correct window. + if let title = windowTitle, !title.isEmpty, + let runningApp = NSRunningApplication + .runningApplications(withBundleIdentifier: bundleID).first { + Thread.sleep(forTimeInterval: 0.15) + _ = raiseWindow(pid: runningApp.processIdentifier, + containingTitle: title) + } + // Step 3: focus the agent's terminal pane via AX before sending Enter, // so the keystroke lands in the right pane instead of whatever was // last focused in VS Code. After AX press of the tab, dwell briefly @@ -328,7 +343,11 @@ struct AppActivator { // any open session (closed since the event fired). @discardableResult private static func focusIterm2Session(sessionID: String) -> Bool { - let escaped = sessionID.replacingOccurrences(of: "\"", with: "\\\"") + // ITERM_SESSION_ID is "w0t0p0:UUID" (window-tab-pane prefix + UUID). + // iTerm2's AppleScript exposes the UUID as `id of session`, so strip + // the prefix when present. + let uuid = sessionID.split(separator: ":").last.map(String.init) ?? sessionID + let escaped = uuid.replacingOccurrences(of: "\"", with: "\\\"") let script = """ tell application "iTerm2" activate