From 96b94cb63effc9a8a7daef41b6d00614d4b1dce0 Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 20 May 2026 19:52:21 +0100 Subject: [PATCH 1/5] feat: auto-wire Codex and Gemini CLI hooks; honest README support table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex and Gemini CLI both ship documented, stable hook systems that StackNudge can wire into. Bootstrap previously only auto-wired Claude Code and Cursor; Codex wasn't in the BootstrapAgent enum at all, and Gemini was a deliberate no-op masquerading as supported. README claimed ✅ (experimental) for both, which overstated reality. Codex (https://developers.openai.com/codex/hooks): - Config at ~/.codex/hooks.json - Matcher-group JSON shape, identical to Claude Code's - Stop + PermissionRequest event names, identical to Claude Code's - JSON payload on stdin, identical to Claude Code's Implementation: add `.codex` to BootstrapAgent and route through the existing matcher-group writer (renamed to wireClaudeShapedHooks). Pass `codex` as the agent label so notify.sh's `agent_label` switch shows "Codex" on banners (already handled). Gemini CLI (https://geminicli.com/docs/hooks/): - Config at ~/.gemini/settings.json - Same matcher-group shape; events renamed: `AfterAgent` = turn end, `Notification` (with notification_type=ToolPermission on stdin) = waiting for tool approval. - JSON payload on stdin. Implementation: wire AfterAgent + Notification through the same generic writer. Caveat: Gemini's Notification is observability-only — our hook can surface the banner but can't return an allow/deny decision the way Claude's PermissionRequest can. Documented inline + in README. Wizard pre-selects all four detected agents now that none are no-ops. The Gemini-specific "info-only row" UI is replaced by the standard checkbox path. `availableAgents()` already discovered Codex via the new detectionDirectory; no change there. Uninstall scrubs the four new event keys alongside the existing two, so a Gemini install is cleanly torn down. README: - Updated Supports table: all four ✅, with a footnote explaining the Gemini permission-event caveat. - Rewrote the Manual setup example to reflect that the four primary agents are now auto-wired, and showed a correct Claude-shaped block instead of the previous wrong "stop" example that wouldn't have worked for any of them. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 18 ++++-- panel/Bootstrap.swift | 135 ++++++++++++++++++++++-------------------- panel/Panel.swift | 11 ++-- 3 files changed, 89 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index b7595b0..cf28089 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,12 @@ |-------|--------| | Claude Code | ✅ | | Cursor | ✅ | -| Gemini CLI | ✅ *(experimental)* | -| Codex | ✅ *(experimental)* | +| Codex | ✅ | +| Gemini CLI | ✅ † | | Any hooks-capable agent | ✅ — point it at `notify.sh` | +† Gemini's tool-permission hook is observability-only — the banner shows the prompt, but the actual Allow / Deny click has to happen in Gemini's terminal. Claude Code, Cursor, and Codex permission events can be approved from the panel directly. + **Platforms:** macOS — full app with panel, click-to-focus banners, auto-update, quota tracking, voice. Linux (PulseAudio / ALSA / libnotify) and Windows (Git Bash / WSL) get audio + basic notifications via `notify.sh` only. ## Install @@ -329,15 +331,19 @@ Same set of cleanups as the in-app path, useful when the .app isn't reachable or ## Manual setup -Every supported agent just needs a hook that runs `notify.sh `. Example for Codex (or any other hooks-capable agent): +Claude Code, Cursor, Codex, and Gemini CLI are auto-wired by the first-launch wizard. For other hooks-capable agents (or to integrate from a custom script), all you need is to invoke `notify.sh ` from wherever your agent emits lifecycle events. `` should be `stop` (agent finished a turn) or `permission` (waiting for approval); `` can be anything — it just controls the banner title. + +Example block in any agent's hooks config: ```json { "hooks": { - "stop": [ + "Stop": [ { - "type": "command", - "command": "$HOME/.stack-nudge/notify.sh codex stop" + "matcher": "", + "hooks": [ + { "type": "command", "command": "$HOME/.stack-nudge/notify.sh my-agent stop", "timeout": 30 } + ] } ] } diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index a44ca30..c647340 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -17,6 +17,7 @@ import SwiftUI enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable { case claude case cursor + case codex case gemini var id: String { rawValue } @@ -25,6 +26,7 @@ enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable { switch self { case .claude: return "Claude Code" case .cursor: return "Cursor" + case .codex: return "Codex" case .gemini: return "Gemini CLI" } } @@ -35,6 +37,7 @@ enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable { switch self { case .claude: return "\(NSHomeDirectory())/.claude" case .cursor: return "\(NSHomeDirectory())/.cursor" + case .codex: return "\(NSHomeDirectory())/.codex" case .gemini: return "\(NSHomeDirectory())/.gemini" } } @@ -45,9 +48,14 @@ enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable { switch self { case .claude: return "\(NSHomeDirectory())/.claude/settings.json" case .cursor: return "\(NSHomeDirectory())/.cursor/hooks.json" - // Gemini hook support is experimental; install.sh writes nothing - // for it today. We mirror that — Bootstrap.install skips Gemini - // hook wiring. Selecting it is a no-op aside from acknowledging. + // Codex's hooks file shares Claude Code's matcher-group JSON + // shape and event names (Stop + PermissionRequest). See + // https://developers.openai.com/codex/hooks. + case .codex: return "\(NSHomeDirectory())/.codex/hooks.json" + // Gemini CLI uses ~/.gemini/settings.json (same path as Claude's + // settings.json analog), but its events are renamed: AfterAgent + // (turn end) and Notification (tool-permission alerts). See + // https://geminicli.com/docs/hooks/. case .gemini: return "\(NSHomeDirectory())/.gemini/settings.json" } } @@ -333,39 +341,57 @@ enum Bootstrap { let path = agent.hookConfigPath switch agent { case .claude: - try wireClaudeHooks(at: path) + // Stop (30s) + PermissionRequest (600s); Claude's + // matcher-group JSON shape. + try wireClaudeShapedHooks(at: path, agentArg: "claude-code", + events: [("Stop", "stop", 30), + ("PermissionRequest", "permission", 600)]) case .cursor: try wireCursorHooks(at: path) + case .codex: + // Codex's hooks file is structurally identical to Claude's + // (matcher-groups), with the same event names. Only the + // file path and the agent-arg differ. + try wireClaudeShapedHooks(at: path, agentArg: "codex", + events: [("Stop", "stop", 30), + ("PermissionRequest", "permission", 600)]) case .gemini: - // install.sh just prints "experimental, see README" for - // Gemini today. Mirror that: no-op, but accept the agent - // in `agents` so the wizard checkbox does something - // (acknowledges the user's choice). - break + // Gemini renames Claude's `Stop` to `AfterAgent` and routes + // tool-permission prompts through `Notification` (with + // `notification_type=ToolPermission` on stdin). Same + // matcher-group JSON shape otherwise. Note: Notification + // is observability-only — our hook can surface the banner + // but can't return an allow/deny decision the way Claude's + // PermissionRequest can. + try wireClaudeShapedHooks(at: path, agentArg: "gemini", + events: [("AfterAgent", "stop", 30), + ("Notification", "permission", 30)]) } } - private static func wireClaudeHooks(at path: String) throws { + // Generic writer for the matcher-group JSON shape that Claude, + // Codex, and Gemini all use. Differs from agent to agent only in + // file path, agent-arg passed to notify.sh, and the set of event + // names. Cursor uses a flat-array shape and has its own writer. + private static func wireClaudeShapedHooks( + at path: String, + agentArg: String, + events: [(event: String, arg: String, timeout: Int)] + ) throws { var root = try readJSONObject(at: path) var hooks = root["hooks"] as? [String: Any] ?? [:] - // Two Claude events: Stop (turn ends, 30s timeout) and - // PermissionRequest (blocking on user approval, 600s). - let entries: [(event: String, arg: String, timeout: Int)] = [ - ("Stop", "stop", 30), - ("PermissionRequest", "permission", 600), - ] - - for (event, arg, timeout) in entries { + for (event, arg, timeout) in events { var groups = hooks[event] as? [[String: Any]] ?? [] groups = pruneStaleHookGroups(groups) let ourHook: [String: Any] = [ "type": "command", - "command": "\(notifyPath) claude-code \(arg)", + "command": "\(notifyPath) \(agentArg) \(arg)", "timeout": timeout, ] // Swift's [String: Any] doesn't preserve key order; the - // resulting JSON is still valid. Claude Code parses by key. + // resulting JSON is still valid. All three agents parse + // by key. groups.append([ "matcher": "", "hooks": [ourHook], @@ -432,8 +458,11 @@ enum Bootstrap { var root = try readJSONObject(at: path) guard var hooks = root["hooks"] as? [String: Any] else { return } - // Claude shape: matcher-groups - for event in ["Stop", "PermissionRequest"] { + // Matcher-group shape — shared by Claude (Stop/PermissionRequest), + // Codex (same names), and Gemini (AfterAgent/Notification). + // Iterating all four event names is harmless: events not present + // are simply skipped. + for event in ["Stop", "PermissionRequest", "AfterAgent", "Notification"] { if let groups = hooks[event] as? [[String: Any]] { let cleaned = pruneStaleHookGroups(groups) if cleaned.isEmpty { @@ -809,7 +838,7 @@ struct BootstrapView: View { @ViewBuilder private var agentList: some View { if nav.bootstrapAvailableAgents.isEmpty { - Text("No supported agents detected (~/.claude, ~/.cursor, ~/.gemini). Install one and restart StackNudge to wire it up.") + Text("No supported agents detected (~/.claude, ~/.cursor, ~/.codex, ~/.gemini). Install one and restart StackNudge to wire it up.") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -829,47 +858,27 @@ struct BootstrapView: View { @ViewBuilder private func agentRow(_ agent: BootstrapAgent) -> some View { - if agent == .gemini { - // Gemini hook wiring isn't implemented — show the row as - // informational only so the user doesn't think they can toggle - // it on. - HStack(alignment: .top, spacing: 10) { - Image(systemName: "info.circle") - .font(.callout) - .foregroundStyle(.tertiary) - .frame(width: 20, alignment: .center) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 1) { - Text(agent.displayName).font(.subheadline.weight(.medium)) - Text("Detected, but hook wiring is manual. See README for setup.") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - } - } else { - let isSelected = nav.bootstrapSelectedAgents.contains(agent) - HStack(alignment: .top, spacing: 10) { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.callout) - .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) - .frame(width: 20, alignment: .center) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 1) { - Text(agent.displayName).font(.subheadline.weight(.medium)) - Text("Hooks will be added to \((agent.hookConfigPath as NSString).abbreviatingWithTildeInPath)") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() + let isSelected = nav.bootstrapSelectedAgents.contains(agent) + HStack(alignment: .top, spacing: 10) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.callout) + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .frame(width: 20, alignment: .center) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 1) { + Text(agent.displayName).font(.subheadline.weight(.medium)) + Text("Hooks will be added to \((agent.hookConfigPath as NSString).abbreviatingWithTildeInPath)") + .font(.caption) + .foregroundStyle(.secondary) } - .contentShape(Rectangle()) - .onTapGesture { - if isSelected { - nav.bootstrapSelectedAgents.remove(agent) - } else { - nav.bootstrapSelectedAgents.insert(agent) - } + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + if isSelected { + nav.bootstrapSelectedAgents.remove(agent) + } else { + nav.bootstrapSelectedAgents.insert(agent) } } } diff --git a/panel/Panel.swift b/panel/Panel.swift index c6e3f35..6cbe8ad 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -499,12 +499,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // wizard again. if !Bootstrap.isInstalled(), nav.mode != .postUpdate { nav.bootstrapAvailableAgents = Bootstrap.availableAgents() - // Exclude Gemini from the default-selected set — its row is - // info-only (hook wiring is manual), so pre-selecting it would - // mislead the user into thinking we'll wire something. - nav.bootstrapSelectedAgents = Set( - nav.bootstrapAvailableAgents.filter { $0 != .gemini } - ) + // Pre-select every detected agent — Claude, Cursor, Codex, + // and Gemini all wire real hooks. Earlier versions excluded + // Gemini because its row was info-only; that's no longer + // true (AfterAgent + Notification are wired now). + nav.bootstrapSelectedAgents = Set(nav.bootstrapAvailableAgents) nav.bootstrapPhase = .idle nav.mode = .bootstrap DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in From c143d893e452b089ec19dd44a71b45bfc3442ccd Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 20 May 2026 20:43:36 +0100 Subject: [PATCH 2/5] fix: Gemini hook timeout is milliseconds, not seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini's `timeout` field is documented in milliseconds; we were writing 30 (== 30 ms), which killed the hook before the shell even forked. Hence no banners from `gemini -p ...` despite the file looking correct. Multiply by 1000 in the .gemini case so the on-disk value is 30_000. Claude Code and Codex stay in seconds — same writer, different unit per agent. Inline note added so the next person to touch this doesn't get burned the same way. Source: https://geminicli.com/docs/hooks/reference — "Execution timeout in milliseconds (default: 60000)". Co-Authored-By: Claude Opus 4.7 (1M context) --- panel/Bootstrap.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index c647340..7155831 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -359,13 +359,20 @@ enum Bootstrap { // Gemini renames Claude's `Stop` to `AfterAgent` and routes // tool-permission prompts through `Notification` (with // `notification_type=ToolPermission` on stdin). Same - // matcher-group JSON shape otherwise. Note: Notification - // is observability-only — our hook can surface the banner - // but can't return an allow/deny decision the way Claude's - // PermissionRequest can. + // matcher-group JSON shape otherwise. + // + // CRITICAL DIFFERENCE FROM CLAUDE/CODEX: Gemini's `timeout` + // is measured in **milliseconds**, not seconds. Sending 30 + // would kill the hook after 30 ms — before the shell even + // forks. Multiply by 1000 to match the writer's + // milliseconds-only convention for Gemini. + // + // Note: Notification is observability-only — our hook can + // surface the banner but can't return an allow/deny + // decision the way Claude's PermissionRequest can. try wireClaudeShapedHooks(at: path, agentArg: "gemini", - events: [("AfterAgent", "stop", 30), - ("Notification", "permission", 30)]) + events: [("AfterAgent", "stop", 30_000), + ("Notification", "permission", 30_000)]) } } From cbbf5e9aa1f79828a533de2f2b1445f0c11313c1 Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 20 May 2026 21:05:13 +0100 Subject: [PATCH 3/5] feat: surface unwired agents in Settings + one-click reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real-world cases all silently miss hook setup today: 1. User installs a new agent (eg Codex) after the bootstrap wizard. 2. A StackNudge release adds support for an agent the user already has installed; existing users auto-update + never see the wizard. 3. User manually deletes our hook entry then forgets. Add a reconciliation banner at the top of Settings that lists agents detected on disk without a notify.sh hook, with "Set up" (wires every unwired agent) and "Not now" (dismisses, persisted to ~/.stack-nudge/dismissed-agents.json) actions. Refreshes on app launch and on every Settings open so the surface stays current without polling. After Set up, the same slot flashes a green "✓ X is set up" confirmation for ~3 s before clearing — so users get feedback that something happened beyond the banner just vanishing. Implementation: - Bootstrap.unwiredAgents() / isAgentWired() — read each agent's config file, match against the staleHookRegex (same one uninstall uses for stale-hook scrubbing). - Bootstrap.wireSingleAgent() — exposes the existing per-agent wireHooks for one-at-a-time wiring without rerunning install(). - PanelNav.{refreshUnwiredAgents,wireSingleAgent,dismissUnwiredAgent} + recentlyWiredAgents transient state + dismissed-agents.json I/O. - Settings.swift unwiredAgentsRow + wiredConfirmationRow occupy the same slot above the existing update-available row. Not part of the keyboard-indexed nav at v1 (mouse-only). - PanelController calls refreshUnwiredAgents() in applicationDidFinishLaunching alongside loadFromConfig(). Co-Authored-By: Claude Opus 4.7 (1M context) --- panel/Bootstrap.swift | 51 ++++++++++++++++++++ panel/PanelNav.swift | 91 ++++++++++++++++++++++++++++++++++++ panel/Settings.swift | 105 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+) diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index 7155831..0fc2c68 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -171,6 +171,57 @@ enum Bootstrap { } } + // MARK: Reconciliation + + // Agents detected on disk whose hook config has no entry pointing at + // our notify.sh. Powers the Settings-tab "agent detected without hooks" + // banner: covers three scenarios in one mechanism — + // 1. User installs a NEW agent on their Mac after first-run wizard. + // 2. New StackNudge release adds support for an agent the user already + // has installed (eg v1.8 lands Codex support; v1.7 user updates). + // 3. User manually deleted our hook entry then forgot. + // + // Cheap: one JSON parse per detected agent, all on the main thread. + // Called from app launch + every Settings tab open. + static func unwiredAgents() -> [BootstrapAgent] { + availableAgents().filter { !isAgentWired($0) } + } + + // Looks at the agent's on-disk config for any hook command matching + // our notify.sh path. The same staleHookRegex used for uninstall does + // the right thing here — it matches both `tinynudge/notify.sh` and + // `stack-nudge/notify.sh`, which is what we want for "is *anything* + // of ours wired?" + static func isAgentWired(_ agent: BootstrapAgent) -> Bool { + let path = agent.hookConfigPath + guard let root = try? readJSONObject(at: path), + let hooks = root["hooks"] as? [String: Any] else { return false } + for (_, value) in hooks { + if let groups = value as? [[String: Any]] { + // Matcher-group shape (Claude / Codex / Gemini). + if groups.contains(where: { group in + let inner = group["hooks"] as? [[String: Any]] ?? [] + return inner.contains { isStaleHook(command: ($0["command"] as? String) ?? "") } + }) { return true } + // Cursor's flat shape (entries directly under the event). + if groups.contains(where: { isStaleHook(command: ($0["command"] as? String) ?? "") }) { + return true + } + } + } + return false + } + + // Wire a single agent — used by the reconciliation row's "Set up" + // action. Same per-agent dispatch as install(agents:) does in its + // loop, just exposed for one-at-a-time wiring without re-running + // the full bootstrap. Idempotent: calling on an already-wired agent + // adds another entry (existing entries are detected by the next + // unwiredAgents() refresh). + static func wireSingleAgent(_ agent: BootstrapAgent) throws { + try wireHooks(for: agent) + } + // MARK: Install // Install stack-nudge: copy bundled resources to ~/.stack-nudge/, diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index a98a14f..9007027 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -114,6 +114,27 @@ final class PanelNav: ObservableObject { @Published var uninstallPhase: UninstallPhase = .confirm @Published var uninstallLog: String = "" + // Reconciliation state. `unwiredAgents` is the live list of detected + // agents whose hook configs don't reference our notify.sh. Drives the + // "Set up X" banner at the top of Settings. Refreshed on app launch + // and every Settings.onAppear so post-update / post-agent-install + // scenarios surface naturally. + // + // `dismissedAgents` holds rawValue strings the user clicked away on; + // persisted to ~/.stack-nudge/dismissed-agents.json so the banner + // doesn't re-pester them between launches. An agent re-appears in + // the banner if it leaves and re-enters the unwired set — eg they + // wire it manually, then delete the entry; or upgrade lands new + // event types we should wire. + @Published var unwiredAgents: [BootstrapAgent] = [] + @Published var dismissedAgents: Set = [] + // Transient confirmation state. When the user clicks Set up on the + // reconciliation banner, `recentlyWiredAgents` holds the agents we + // just wired so the Settings view can flash a "✓ Wired up X" message + // in place of the now-empty unwired banner. Cleared automatically + // a few seconds later. + @Published var recentlyWiredAgents: [BootstrapAgent] = [] + var actions: SettingsActions? // Wired by PanelController so nav can re-register the global hotkey // without owning the Hotkey instance directly. Returns true if the @@ -199,6 +220,76 @@ final class PanelNav: ObservableObject { quotaAlertThreshold = Self.quotaThresholds.min(by: { abs($0 - rawThreshold) < abs($1 - rawThreshold) }) ?? 80 } + // MARK: - Agent reconciliation + + // Re-scan the on-disk agent configs and surface anything our + // notify.sh isn't wired into yet. Dismissed agents stay hidden + // until either the file goes back to "wired" or the dismissal + // file is deleted. + func refreshUnwiredAgents() { + loadDismissedAgents() + let detected = Bootstrap.unwiredAgents() + let visible = detected.filter { !dismissedAgents.contains($0.rawValue) } + if visible != unwiredAgents { unwiredAgents = visible } + } + + // Wire one agent in-place, then refresh the unwired list so the + // row disappears immediately on success. Records the agent in + // recentlyWiredAgents so the Settings view can show a transient + // "✓ Wired up X" confirmation; cleared after a few seconds. + func wireSingleAgent(_ agent: BootstrapAgent) { + do { + try Bootstrap.wireSingleAgent(agent) + recentlyWiredAgents.append(agent) + // Auto-clear so the confirmation doesn't linger forever. + // Re-dispatching is harmless: each new wire extends the + // visible window, then the latest scheduler clears the list. + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in + self?.recentlyWiredAgents.removeAll { $0 == agent } + } + } catch { + FileHandle.standardError.write(Data( + "stack-nudge: wire \(agent.rawValue) failed: \(error)\n".utf8)) + } + // Always refresh — even on error the file state may have partly + // changed and we want the UI to reflect reality. + refreshUnwiredAgents() + } + + // Click "Not now" on the banner. Persist to ~/.stack-nudge/ + // dismissed-agents.json so the user isn't pestered next launch. + // We re-show only if the agent leaves the unwired set (eg user + // manually adds a hook then deletes it) — see refreshUnwiredAgents. + func dismissUnwiredAgent(_ agent: BootstrapAgent) { + dismissedAgents.insert(agent.rawValue) + saveDismissedAgents() + refreshUnwiredAgents() + } + + private static let dismissedAgentsPath = + "\(NSHomeDirectory())/.stack-nudge/dismissed-agents.json" + + private func loadDismissedAgents() { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: Self.dismissedAgentsPath)), + let arr = try? JSONSerialization.jsonObject(with: data) as? [String] + else { return } + dismissedAgents = Set(arr) + } + + private func saveDismissedAgents() { + let arr = Array(dismissedAgents).sorted() + guard let data = try? JSONSerialization.data(withJSONObject: arr, options: [.prettyPrinted]) + else { return } + let url = URL(fileURLWithPath: Self.dismissedAgentsPath) + // ~/.stack-nudge/ may not yet exist if reconciliation runs before + // the bootstrap wizard completes. Create the parent on demand. + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try? data.write(to: url, options: [.atomic]) + } + func refreshVoiceModelCached() { voiceModelCached = Speaker.voiceModelCached() } diff --git a/panel/Settings.swift b/panel/Settings.swift index bd5c29c..b08bfc8 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -14,6 +14,17 @@ struct SettingsView: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 14) { + // Reconciliation banner — appears above all other + // rows when one or more detected agents lack our + // notify.sh hook. Not part of the keyboard index; + // mouse-only at v1. After Set up is clicked, the + // success state takes over the slot for a few + // seconds before disappearing. + if !nav.unwiredAgents.isEmpty { + unwiredAgentsRow(nav.unwiredAgents) + } else if !nav.recentlyWiredAgents.isEmpty { + wiredConfirmationRow(nav.recentlyWiredAgents) + } // Index 0 when present, shifting everything else by // +1. The offset is held on nav (updateRowOffset). if let version = nav.updateAvailable { @@ -101,7 +112,101 @@ struct SettingsView: View { if nav.voiceModelCached, nav.voicesAvailable.isEmpty { nav.loadVoices() } + // Re-scan agent configs on every Settings open so the + // "unwired agent" banner reflects current disk state + // (covers: user just installed Codex; user manually edited + // a hook file; old install lacks events added in a recent + // StackNudge release). + nav.refreshUnwiredAgents() + } + } + + // Transient success confirmation that takes the reconciliation + // banner's slot for ~3 s after the user clicks Set up. Disappears + // by itself once `recentlyWiredAgents` clears. + @ViewBuilder + private func wiredConfirmationRow(_ agents: [BootstrapAgent]) -> some View { + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .font(.body) + .foregroundStyle(.green) + VStack(alignment: .leading, spacing: 1) { + Text(agents.count == 1 + ? "\(agents[0].displayName) is set up." + : "\(agents.count) agents are set up.") + .font(.subheadline.weight(.semibold)) + Text("New banners will fire when the agent finishes a turn or waits for approval.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.green.opacity(0.12)) + ) + .transition(.opacity) + } + + // Reconciliation banner. Shown above all other settings rows when one + // or more detected agents lack a notify.sh hook entry. Two click + // targets: "Set up" (wire every unwired agent) and "Not now" (dismiss + // for this/future launches until the agent's state changes again). + // Not part of the keyboard-indexed nav at v1 — mouse-only. + @ViewBuilder + private func unwiredAgentsRow(_ agents: [BootstrapAgent]) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "sparkle") + .font(.body) + .foregroundStyle(Color.accentColor) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + Text(agents.count == 1 + ? "Wire up \(agents[0].displayName)?" + : "Wire up \(agents.count) agents?") + .font(.subheadline.weight(.semibold)) + Text(agents.map(\.displayName).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + Text("Detected on this Mac without StackNudge hooks. Set up to start getting banners.") + .font(.caption) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + Button { + for agent in agents { nav.wireSingleAgent(agent) } + } label: { + Text("Set up") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor) + ) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + Button { + for agent in agents { nav.dismissUnwiredAgent(agent) } + } label: { + Text("Not now") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.top, 2) + } + Spacer() } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(0.12)) + ) } // Replaces the Voice + Speed rows when the Kokoro model hasn't been From ae1f37b8558f0fdae89a7d158796bd910d95733c Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 20 May 2026 21:05:14 +0100 Subject: [PATCH 4/5] fix: kill the panel flash on banner click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a notification banner triggered visible flash: panel briefly appeared then disappeared. Two AppKit delegate methods fire on a banner click and they race — 1. applicationShouldHandleReopen (added earlier for Dock-icon reopens) → our handler called showPanel() → panel appears. 2. userNotificationCenter(_:didReceive:) → our handler called NSApp.hide(nil) → panel disappears. The reopen delegate is part of the banner-activation sequence, not just real user-initiated reopens. Defer the reopen-driven showPanel by 200 ms and let didReceive veto by bumping a `bannerActivationUntil` deadline. Real app-icon reopens see no veto and the panel appears (200 ms latency vs immediate — acceptable trade-off). Banner clicks suppress the show cleanly: no flash. Co-Authored-By: Claude Opus 4.7 (1M context) --- panel/Panel.swift | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index 6cbe8ad..0e19f32 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -388,6 +388,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // resets_at slides forward every poll so we can't trust it). private var quotaLastFired: [String: (maxBucketFired: Int, peakUtil: Double)] = [:] + // When a notification banner is clicked, macOS fires + // applicationShouldHandleReopen BEFORE userNotificationCenter(_:didReceive:). + // Our reopen handler shows the panel; didReceive then hides it as + // part of the banner-click flow — producing a visible flash. We + // defer the reopen-show and let didReceive cancel it by setting + // this deadline. See applicationShouldHandleReopen for the deferral + // logic and didReceive for the cancellation. + private var bannerActivationUntil: 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 @@ -478,6 +487,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, setupNotificationCenter() store.onAppend = { [weak self] event in self?.postBannerIfNeeded(event) } nav.loadFromConfig() // populate panelPinned + other live values up-front + // Scan agent configs for missing wires (post-update / post-install + // reconciliation). Surfaces a "Set up X" banner in Settings when + // any detected agent lacks our notify.sh hook. + nav.refreshUnwiredAgents() updateChecker = UpdateChecker(nav: nav) updateChecker?.start() @@ -951,6 +964,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { defer { completionHandler() } + // Veto the deferred panel-show that applicationShouldHandleReopen + // queued: we know this activation came from a banner click, not + // a user re-opening the app. + bannerActivationUntil = Date().addingTimeInterval(0.5) guard let eventID = response.notification.request.content.userInfo["eventID"] as? String, let event = store.events.first(where: { $0.id.uuidString == eventID }) else { return } @@ -1124,12 +1141,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } // Fired when the user re-opens the app while it's already running — - // double-click from Finder, `open -a StackNudge`, Spotlight, etc. - // LSUIElement apps have no Dock icon, so this is the single entry - // point for "I clicked the app." Show the panel and return false so - // macOS knows we handled it and doesn't spawn a second process. + // double-click from Finder, `open -a StackNudge`, Spotlight — AND + // (less obviously) as part of the system activation sequence that + // accompanies a notification-banner click. In the banner-click case + // this delegate fires BEFORE userNotificationCenter(_:didReceive:), + // so calling showPanel() here flashes the panel up just before + // didReceive's NSApp.hide() takes it back down. + // + // Defer the show so didReceive can veto. If a banner-click delegate + // arrives within the window, it bumps bannerActivationUntil and we + // skip. For a true app-icon reopen, no banner delegate fires and + // the panel appears after the brief delay. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { - showPanel() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self else { return } + if Date() < self.bannerActivationUntil { return } + self.showPanel() + } return false } From fc2e4336795f259cc9dbbcd0028735636354b8fe Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 20 May 2026 21:23:44 +0100 Subject: [PATCH 5/5] docs(readme): add StackNudge wordmark below hero logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero image is the abstract green mark on its own — visitors to the GitHub page would see no "StackNudge" text until the maintained-by footer. Add an h1 below the image so the name is the most prominent thing on the page, and align the image's alt text with the displayed brand. Shrink the mark to 200 px since the wordmark below now carries the brand weight. Backlog: replace the mark + h1 with a proper combined logo-with- wordmark asset once a designer produces one. The bare mark stays useful for square uses (favicon, menubar, App Store). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cf28089..0445570 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@
- stack-nudge + StackNudge +

StackNudge

Notifications for AI coding agents.

Get a banner + sound when your agent finishes a task or pauses for your approval — step away without missing a beat.