Skip to content
13 changes: 13 additions & 0 deletions panel/EventStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
45 changes: 43 additions & 2 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -1219,9 +1229,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]
Expand Down Expand Up @@ -1305,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)
}
Expand Down Expand Up @@ -1448,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
Expand Down Expand Up @@ -1806,7 +1842,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

Expand All @@ -1826,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
)
Expand Down
31 changes: 25 additions & 6 deletions panel/Permissions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
33 changes: 25 additions & 8 deletions panel/SessionUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
85 changes: 84 additions & 1 deletion shared/AppActivator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -44,12 +45,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)
Expand All @@ -58,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
Expand Down Expand Up @@ -98,6 +128,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
Expand Down Expand Up @@ -292,6 +334,47 @@ 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 {
// 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
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
Expand Down