Skip to content

Commit e335ee6

Browse files
authored
Merge pull request #63 from StackOneHQ/fix/keychain-permissions-banner-quality
fix: quota keychain prompts, permission reset, banner quality, multi-window activation
2 parents 4728cdf + d1e5226 commit e335ee6

5 files changed

Lines changed: 190 additions & 17 deletions

File tree

panel/EventStore.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ final class EventStore: ObservableObject {
143143
var onAppend: ((NudgeEvent) -> Void)?
144144

145145
func append(_ event: NudgeEvent) {
146+
// Claude Code occasionally fires the same hook twice in rapid
147+
// succession (observed on Stop). Drop the second event when an
148+
// identical one landed within the dedup window.
149+
let dedupWindow: TimeInterval = 2
150+
if events.contains(where: { existing in
151+
event.timestamp.timeIntervalSince(existing.timestamp) < dedupWindow
152+
&& existing.agent == event.agent
153+
&& existing.kind == event.kind
154+
&& existing.message == event.message
155+
&& existing.claudeSessionID == event.claudeSessionID
156+
}) {
157+
return
158+
}
146159
events.insert(event, at: 0)
147160
if events.count > maxEvents {
148161
events = Array(events.prefix(maxEvents))

panel/Panel.swift

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,14 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
505505
// logic and didReceive for the cancellation.
506506
private var bannerActivationUntil: Date = .distantPast
507507

508+
// Last time onAppend fired. macOS can deliver applicationShouldHandleReopen
509+
// as a side effect of posting a banner (notably under Ghostty), not just
510+
// when the user clicks one — so the bannerActivationUntil veto, which
511+
// only sets in didReceive, doesn't catch this case. Suppress the deferred
512+
// showPanel for ~2s after any event arrival so a banner post never
513+
// pops the panel uninvited.
514+
private var lastEventArrivalAt: Date = .distantPast
515+
508516
// UserDefaults keys for panel size + origin persistence. UserDefaults
509517
// lives in ~/Library/Preferences/com.stackonehq.stack-nudge.plist, so it
510518
// survives uninstall/reinstall cycles of ~/.stack-nudge/ and across
@@ -606,6 +614,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
606614
startConfigWatcher()
607615
setupNotificationCenter()
608616
store.onAppend = { [weak self] event in
617+
self?.lastEventArrivalAt = Date()
609618
self?.postBannerIfNeeded(event)
610619
self?.refreshTranscriptStats(for: event)
611620
}
@@ -1122,6 +1131,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
11221131
windowTitle: event.windowTitle,
11231132
ipcHook: event.ipcHook,
11241133
projectPath: event.projectPath,
1134+
sessionID: event.sessionID,
11251135
sendApproval: false,
11261136
agent: event.agent)
11271137
}
@@ -1219,9 +1229,30 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
12191229
// for the initial fire and by the snooze timer for re-fires. Request
12201230
// identifier is a fresh UUID each time (macOS replaces by identifier);
12211231
// event.id stays in userInfo so click handlers can find the source.
1232+
// Banner title with session label appended when we can resolve one.
1233+
// Default project name is suppressed (already implied by the title);
1234+
// only meaningful custom/claudeName labels are shown.
1235+
private func bannerTitle(for event: NudgeEvent) -> String {
1236+
guard let id = event.claudeSessionID else { return event.title }
1237+
guard let session = sessions.sessions.first(where: { $0.claudeSessionID == id })
1238+
else { return event.title }
1239+
let custom = session.customName?.trimmingCharacters(in: .whitespaces)
1240+
let claude = session.claudeName?.trimmingCharacters(in: .whitespaces)
1241+
let label: String?
1242+
if let custom, !custom.isEmpty {
1243+
label = custom
1244+
} else if let claude, !claude.isEmpty, claude != "main-agent" {
1245+
label = claude
1246+
} else {
1247+
label = nil
1248+
}
1249+
guard let label else { return event.title }
1250+
return "\(event.title)\(label)"
1251+
}
1252+
12221253
private func postBanner(for event: NudgeEvent) {
12231254
let content = UNMutableNotificationContent()
1224-
content.title = event.title
1255+
content.title = bannerTitle(for: event)
12251256
content.body = event.message
12261257
content.categoryIdentifier = event.kind == .permission ? "PERMISSION" : "STOP"
12271258
content.userInfo = ["eventID": event.id.uuidString]
@@ -1305,6 +1336,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
13051336
windowTitle: event.windowTitle,
13061337
ipcHook: event.ipcHook,
13071338
projectPath: event.projectPath,
1339+
sessionID: event.sessionID,
13081340
sendApproval: approve,
13091341
agent: event.agent)
13101342
}
@@ -1448,6 +1480,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
14481480
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
14491481
guard let self else { return }
14501482
if Date() < self.bannerActivationUntil { return }
1483+
// Suppress if a banner just posted — macOS sometimes routes a
1484+
// reopen through us as a side effect of the notification arriving,
1485+
// and the user did not actually ask for the panel.
1486+
if Date().timeIntervalSince(self.lastEventArrivalAt) < 2 { return }
14511487
self.showPanel()
14521488
}
14531489
return false
@@ -1806,7 +1842,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
18061842
private func actOnSelected(approve: Bool) {
18071843
guard let event = store.selectedEvent else { return }
18081844
store.remove(id: event.id)
1809-
hidePanel()
1845+
// Stay on the panel if there are more events to act on; otherwise
1846+
// close so the system frontmost reverts naturally and the approval
1847+
// keystroke lands in the target app's key window (see comment above
1848+
// about why hiding must precede the keystroke dispatch).
1849+
if store.events.isEmpty { hidePanel() }
18101850

18111851
let sendApproval = approve && event.hasActionButton
18121852

@@ -1826,6 +1866,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
18261866
windowTitle: event.windowTitle,
18271867
ipcHook: event.ipcHook,
18281868
projectPath: event.projectPath,
1869+
sessionID: event.sessionID,
18291870
sendApproval: sendApproval,
18301871
agent: event.agent
18311872
)

panel/Permissions.swift

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,7 @@ enum Permissions {
6767
let options = [key: kCFBooleanTrue!] as CFDictionary
6868
_ = AXIsProcessTrustedWithOptions(options)
6969
case .automation:
70-
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.systemevents")
71-
_ = AEDeterminePermissionToAutomateTarget(
72-
target.aeDesc, typeWildCard, typeWildCard, true)
70+
triggerAutomationPrompt()
7371
case .notifications:
7472
promptNotifications()
7573
}
@@ -85,8 +83,16 @@ enum Permissions {
8583
let task = Process()
8684
task.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil")
8785
task.arguments = ["reset", service, bundleID]
86+
let err = Pipe()
87+
task.standardError = err
8888
try? task.run()
8989
task.waitUntilExit()
90+
if task.terminationStatus != 0 {
91+
let msg = String(data: err.fileHandleForReading.readDataToEndOfFile(),
92+
encoding: .utf8) ?? ""
93+
FileHandle.standardError.write(Data(
94+
"stack-nudge: tccutil reset \(service) failed (\(task.terminationStatus)): \(msg)\n".utf8))
95+
}
9096
}
9197

9298
switch pane {
@@ -95,13 +101,26 @@ enum Permissions {
95101
let options = [key: kCFBooleanTrue!] as CFDictionary
96102
_ = AXIsProcessTrustedWithOptions(options)
97103
case .automation:
98-
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.systemevents")
99-
_ = AEDeterminePermissionToAutomateTarget(
100-
target.aeDesc, typeWildCard, typeWildCard, true)
104+
triggerAutomationPrompt()
101105
case .notifications:
102106
promptNotifications()
103107
}
104108
}
109+
110+
// Force the TCC Automation prompt for System Events by actually sending
111+
// an AppleEvent — AEDeterminePermissionToAutomateTarget often returns
112+
// the cached decision without dispatching the dialog, so we send a
113+
// harmless command instead. macOS shows the prompt the first time an
114+
// app sends events to a new target, regardless of prior denial state
115+
// (after a tccutil reset clears the decision).
116+
private static func triggerAutomationPrompt() {
117+
let source = "tell application \"System Events\" to count processes"
118+
guard let script = NSAppleScript(source: source) else { return }
119+
DispatchQueue.global(qos: .userInitiated).async {
120+
var error: NSDictionary?
121+
_ = script.executeAndReturnError(&error)
122+
}
123+
}
105124
}
106125

107126
enum SettingsPane {

panel/SessionUsage.swift

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,32 @@ final class QuotaProbe {
112112
}.resume()
113113
}
114114

115-
// MARK: - Keychain
116-
117-
// Read the Claude Code credentials JSON blob from the macOS Keychain and
118-
// extract `claudeAiOauth.accessToken`. macOS prompts the user the first
119-
// time stack-nudge reads this entry; subsequent reads are silent until
120-
// Claude Code rotates the item, which wipes the ACL and re-fires the
121-
// prompt. Callers cache the returned token and only re-invoke this on
122-
// an API 401 to keep prompt frequency to a minimum.
115+
// MARK: - Token sources
116+
117+
// Prefer ~/.claude/.credentials.json when present. Claude Code itself
118+
// reads this file before falling back to the Keychain, and users who
119+
// want to avoid the periodic Keychain prompt (caused by Claude rotating
120+
// the Keychain item ~every 8h, wiping the ACL grant — anthropics/claude-code#22144,
121+
// closed as not planned) can opt in by writing the file at mode 0600.
123122
private func readAccessToken() -> String? {
123+
if let token = readCredentialsFile() { return token }
124+
return readKeychainToken()
125+
}
126+
127+
private func readCredentialsFile() -> String? {
128+
let path = "\(NSHomeDirectory())/.claude/.credentials.json"
129+
guard FileManager.default.fileExists(atPath: path),
130+
let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
131+
let blob = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
132+
let oauth = blob["claudeAiOauth"] as? [String: Any],
133+
let token = oauth["accessToken"] as? String,
134+
!token.isEmpty else {
135+
return nil
136+
}
137+
return token
138+
}
139+
140+
private func readKeychainToken() -> String? {
124141
let query: [String: Any] = [
125142
kSecClass as String: kSecClassGenericPassword,
126143
kSecAttrService as String: Self.keychainService,

shared/AppActivator.swift

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ struct AppActivator {
1919

2020
static func activate(bundleID: String, windowTitle: String? = nil,
2121
ipcHook: String? = nil, projectPath: String? = nil,
22+
sessionID: String? = nil,
2223
sendApproval: Bool = false, agent: String? = nil) {
2324
// For matching windows/tabs we want a LOOSE fragment that survives
2425
// the user switching tabs between event time and click time:
@@ -44,12 +45,26 @@ struct AppActivator {
4445
let trusted = AXIsProcessTrusted()
4546
let pressEnter = sendApproval && trusted
4647

48+
// When ipcHook is set (captured from VSCODE_IPC_HOOK_CLI at hook
49+
// time), prefix the CLI invocation with it so the command talks
50+
// to that specific window's IPC server. Without this, --reuse-window
51+
// picks the most-recently-focused matching window — which is the
52+
// wrong one when the user has multiple editor windows open for
53+
// the same project.
54+
let envPrefix: String
55+
if let hook = ipcHook, !hook.isEmpty {
56+
let escapedHook = hook.replacingOccurrences(of: "'", with: "'\\''")
57+
envPrefix = "VSCODE_IPC_HOOK_CLI='\(escapedHook)' "
58+
} else {
59+
envPrefix = ""
60+
}
61+
4762
// Step 1: activate app + switch window via CLI (no Automation needed)
4863
var err: NSDictionary?
4964
NSAppleScript(source: """
5065
tell application "\(procName)" to activate
5166
delay 0.4
52-
do shell script "'\(escapedCLI)' --reuse-window '\(escapedPath)'"
67+
do shell script "\(envPrefix)'\(escapedCLI)' --reuse-window '\(escapedPath)'"
5368
""")?.executeAndReturnError(&err)
5469

5570
// Step 2: set frontmost (requires Automation for System Events)
@@ -58,6 +73,21 @@ struct AppActivator {
5873
tell application "System Events" to set frontmost of process "\(procName)" to true
5974
""")?.executeAndReturnError(&err2)
6075

76+
// Step 2.5: AX-raise the specific window. --reuse-window routes
77+
// the open request to the right window's IPC server (when
78+
// ipcHook is set), but the CLI doesn't raise that window —
79+
// the app's most-recently-focused window pops to front
80+
// instead. The captured windowTitle has the open filename and
81+
// is window-specific, so AX-matching it pins activation to
82+
// the correct window.
83+
if let title = windowTitle, !title.isEmpty,
84+
let runningApp = NSRunningApplication
85+
.runningApplications(withBundleIdentifier: bundleID).first {
86+
Thread.sleep(forTimeInterval: 0.15)
87+
_ = raiseWindow(pid: runningApp.processIdentifier,
88+
containingTitle: title)
89+
}
90+
6191
// Step 3: focus the agent's terminal pane via AX before sending Enter,
6292
// so the keystroke lands in the right pane instead of whatever was
6393
// last focused in VS Code. After AX press of the tab, dwell briefly
@@ -98,6 +128,18 @@ struct AppActivator {
98128
return
99129
}
100130

131+
// iTerm2: each tab/pane has a unique session id (captured as
132+
// ITERM_SESSION_ID by notify.sh). Walking via AppleScript and
133+
// selecting that exact session disambiguates between multiple
134+
// tabs in the same project folder, which title-fragment matching
135+
// can't resolve. Falls through to the AX-based path if the
136+
// session id is missing or the scripting bridge errors.
137+
if bundleID == "com.googlecode.iterm2",
138+
let sid = sessionID, !sid.isEmpty,
139+
focusIterm2Session(sessionID: sid) {
140+
return
141+
}
142+
101143
// Fallback: activate then AXRaise with retries (works for native terminal apps).
102144
// Retry schedule from claude-notifications-go: 150ms → 250ms → 400ms.
103145
guard let app = NSRunningApplication
@@ -292,6 +334,47 @@ struct AppActivator {
292334
NSAppleScript(source: script)?.executeAndReturnError(&err)
293335
}
294336

337+
// MARK: - iTerm2 (AppleScript bridge)
338+
339+
// iTerm2 sets ITERM_SESSION_ID on every shell. Walk windows -> tabs ->
340+
// sessions to find the session whose id matches and select it. The
341+
// returned bool tells the caller whether to fall through to the AX
342+
// path: false means scripting bridge errored or the id didn't match
343+
// any open session (closed since the event fired).
344+
@discardableResult
345+
private static func focusIterm2Session(sessionID: String) -> Bool {
346+
// ITERM_SESSION_ID is "w0t0p0:UUID" (window-tab-pane prefix + UUID).
347+
// iTerm2's AppleScript exposes the UUID as `id of session`, so strip
348+
// the prefix when present.
349+
let uuid = sessionID.split(separator: ":").last.map(String.init) ?? sessionID
350+
let escaped = uuid.replacingOccurrences(of: "\"", with: "\\\"")
351+
let script = """
352+
tell application "iTerm2"
353+
activate
354+
set target to "\(escaped)"
355+
repeat with w in windows
356+
repeat with t in tabs of w
357+
repeat with s in sessions of t
358+
try
359+
if (id of s as text) is target then
360+
tell w to select
361+
tell t to select
362+
tell s to select
363+
return "matched"
364+
end if
365+
end try
366+
end repeat
367+
end repeat
368+
end repeat
369+
return "no-match"
370+
end tell
371+
"""
372+
var err: NSDictionary?
373+
let result = NSAppleScript(source: script)?.executeAndReturnError(&err)
374+
guard err == nil else { return false }
375+
return result?.stringValue == "matched"
376+
}
377+
295378
// MARK: - AX tab switching (standalone terminal apps)
296379

297380
// For tabbed terminals where the OS-window is already frontmost but

0 commit comments

Comments
 (0)