diff --git a/install.sh b/install.sh index 27679b6..e1a7494 100755 --- a/install.sh +++ b/install.sh @@ -172,14 +172,19 @@ else: settings = {} hooks = settings.setdefault("hooks", {}) -for event, arg in [("Stop", "stop"), ("PermissionRequest", "permission")]: +# Permission hook blocks on a FIFO until the user approves via stack-nudge, +# so it needs a longer timeout than Claude Code's 600s default. +for event, arg, timeout in [("Stop", "stop", 30), ("PermissionRequest", "permission", 600)]: groups = hooks.setdefault(event, []) cmd = f"{notify} claude-code {arg}" if not any( any(h.get("command") == cmd for h in g.get("hooks", [])) for g in groups ): - groups.append({"matcher": "", "hooks": [{"type": "command", "command": cmd}]}) + groups.append({ + "matcher": "", + "hooks": [{"type": "command", "command": cmd, "timeout": timeout}], + }) path.write_text(json.dumps(settings, indent=2) + "\n") print(f" Updated {path}") diff --git a/notify.sh b/notify.sh index eea0304..9cf0e29 100755 --- a/notify.sh +++ b/notify.sh @@ -282,6 +282,7 @@ post_to_panel() { NUDGE_WINDOW="$4" \ NUDGE_IPC_HOOK="${VSCODE_IPC_HOOK_CLI:-}" \ NUDGE_HAS_ACTION="$5" \ + NUDGE_FIFO="${6:-}" \ NUDGE_SOCK="$PANEL_SOCK" \ NUDGE_AGENT_PID="${AGENT_PID:-}" \ NUDGE_SHELL_PID="${SHELL_PID:-}" \ @@ -308,6 +309,7 @@ optional = { "bundle_id": env.get("NUDGE_BUNDLE"), "window_title": env.get("NUDGE_WINDOW"), "ipc_hook": env.get("NUDGE_IPC_HOOK"), + "fifo_path": env.get("NUDGE_FIFO"), "agent_pid": env.get("NUDGE_AGENT_PID"), "shell_pid": env.get("NUDGE_SHELL_PID"), "terminal_pid": env.get("NUDGE_TERMINAL_PID"), @@ -410,12 +412,16 @@ notify_macos() { fi local has_action="false" - [[ "${EVENT}" == "permission" ]] && has_action="true" + local fifo_path="" + if [[ "${EVENT}" == "permission" ]]; then + has_action="true" + fifo_path=$(create_perm_fifo) + fi # Post to the persistent app — it handles both the panel history and the # UNUserNotification banner based on the user's config. Backgrounded so # Python startup (~50ms) doesn't block the agent hook. - post_to_panel "${title}" "${message}" "${bundle_id}" "${project_name:-}" "${has_action}" & + post_to_panel "${title}" "${message}" "${bundle_id}" "${project_name:-}" "${has_action}" "${fifo_path}" & # Sound fires independently via afplay — guaranteed even if macOS throttles # or the app isn't running yet. Voice replaces the chime when enabled. @@ -424,6 +430,63 @@ notify_macos() { fi speak_notification "${voice_message}" + + # For permission events, block reading from the FIFO. The user's Allow + # click in the panel/banner writes "allow" to it; we then output the + # PermissionRequest decision JSON to stdout so Claude Code skips its + # own UI prompt entirely. Timeout falls back to Claude Code's UI. + if [[ -n "$fifo_path" ]]; then + wait_for_permission_response "$fifo_path" + fi +} + +# Create a unique FIFO at /tmp for the user's response. Echoes the path. +# Returns empty if mkfifo fails. +create_perm_fifo() { + local fifo + fifo="/tmp/stack-nudge-perm-$$-$(date +%s)-$RANDOM.fifo" + if mkfifo -m 0600 "$fifo" 2>/dev/null; then + echo "$fifo" + fi +} + +# Block reading the user's decision from the FIFO with a timeout. Outputs +# the PermissionRequest JSON to stdout when read; silent on timeout so +# Claude Code falls back to its own UI prompt. +# Uses Python because bash's `read -t` doesn't time out the FIFO open() call. +wait_for_permission_response() { + local fifo="$1" + local timeout=550 # Claude Code's hook timeout defaults to 600s — leave buffer + + trap 'rm -f "$fifo"' EXIT + + local decision + decision=$(NUDGE_FIFO="$fifo" NUDGE_TIMEOUT="$timeout" python3 - <<'PY' 2>/dev/null +import os, select, sys +fifo = os.environ["NUDGE_FIFO"] +timeout = float(os.environ["NUDGE_TIMEOUT"]) +try: + fd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK) +except OSError: + sys.exit(0) +try: + r, _, _ = select.select([fd], [], [], timeout) + if r: + data = os.read(fd, 1024).decode("utf-8", errors="replace").strip() + sys.stdout.write(data) +finally: + os.close(fd) +PY +) + + case "$decision" in + allow) + printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}\n' + ;; + deny) + printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied via stack-nudge"}}}\n' + ;; + esac } diff --git a/panel/EventListener.swift b/panel/EventListener.swift index e394694..531da61 100644 --- a/panel/EventListener.swift +++ b/panel/EventListener.swift @@ -123,6 +123,7 @@ private struct NudgeEventDTO: Decodable { let terminal_app: String? let term_program: String? let session_id: String? + let fifo_path: String? func toNudgeEvent() -> NudgeEvent { NudgeEvent( @@ -141,7 +142,8 @@ private struct NudgeEventDTO: Decodable { terminalPID: terminal_pid, terminalApp: terminal_app, termProgram: term_program, - sessionID: session_id + sessionID: session_id, + fifoPath: fifo_path ) } } diff --git a/panel/EventStore.swift b/panel/EventStore.swift index 9641169..a5c336d 100644 --- a/panel/EventStore.swift +++ b/panel/EventStore.swift @@ -33,6 +33,10 @@ struct NudgeEvent: Identifiable, Equatable { let terminalApp: String? let termProgram: String? let sessionID: String? + // FIFO that the source notify.sh hook is blocking on. Writing + // "allow" or "deny" to it lets stack-nudge return a PermissionRequest + // decision to Claude Code without touching the terminal UI. + let fifoPath: String? init(agent: String, kind: NudgeKind, title: String, message: String, projectPath: String? = nil, bundleID: String? = nil, @@ -40,7 +44,8 @@ struct NudgeEvent: Identifiable, Equatable { hasActionButton: Bool = false, timestamp: Date = Date(), agentPID: Int? = nil, shellPID: Int? = nil, terminalPID: Int? = nil, terminalApp: String? = nil, - termProgram: String? = nil, sessionID: String? = nil) { + termProgram: String? = nil, sessionID: String? = nil, + fifoPath: String? = nil) { self.id = UUID() self.agent = agent self.kind = kind @@ -58,6 +63,7 @@ struct NudgeEvent: Identifiable, Equatable { self.terminalApp = terminalApp self.termProgram = termProgram self.sessionID = sessionID + self.fifoPath = fifoPath } } diff --git a/panel/Panel.swift b/panel/Panel.swift index 9218f71..91201d5 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -323,6 +323,22 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, startConfigWatcher() setupNotificationCenter() store.onAppend = { [weak self] event in self?.postBannerIfNeeded(event) } + nav.loadFromConfig() // populate panelPinned + other live values up-front + + // Auto-hide when the panel loses key focus, if pin is off. + // Detect "click outside" without polling — NSWindow fires this when + // another window or app takes focus. + NotificationCenter.default.addObserver( + self, + selector: #selector(panelDidResignKey), + name: NSWindow.didResignKeyNotification, + object: panel + ) + } + + @objc private func panelDidResignKey(_ notification: Notification) { + guard !nav.panelPinned, panel.isVisible else { return } + hidePanel() } // MARK: - UNUserNotificationCenter @@ -394,6 +410,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, store.remove(id: event.id) let approve = response.actionIdentifier == "ALLOW" + + // If we have a FIFO, the source hook is blocking on it — write the + // decision and let Claude Code skip its own UI prompt entirely. + // No keystroke injection or window targeting needed. + if approve, let fifo = event.fifoPath { + DispatchQueue.global(qos: .userInitiated).async { + Self.writeFIFO(fifo, "allow") + } + return + } + guard let bundleID = event.bundleID else { return } // Hide the app first so the system restores focus to the previous @@ -413,6 +440,16 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } } + // Write a decision string to the hook's FIFO. The source notify.sh + // is blocked on `select`, so this returns immediately after writing. + private static func writeFIFO(_ path: String, _ decision: String) { + let fd = Darwin.open(path, O_WRONLY | O_NONBLOCK) + guard fd >= 0 else { return } + defer { Darwin.close(fd) } + let line = decision + "\n" + _ = line.withCString { Darwin.write(fd, $0, strlen($0)) } + } + // Show banners even when the app is frontmost (needed for accessory apps). func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -653,8 +690,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, store.remove(id: event.id) hidePanel() - guard let bundleID = event.bundleID else { return } let sendApproval = approve && event.hasActionButton + + // FIFO path = the source hook is blocking on a permission decision. + // Write "allow" to it and we're done — Claude Code skips its UI prompt. + if sendApproval, let fifo = event.fifoPath { + DispatchQueue.global(qos: .userInitiated).async { + Self.writeFIFO(fifo, "allow") + } + return + } + + guard let bundleID = event.bundleID else { return } DispatchQueue.global(qos: .userInitiated).async { AppActivator.activate( bundleID: bundleID, diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 247578d..e0853e8 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -30,6 +30,7 @@ final class PanelNav: ObservableObject { @Published var hotkeyError: String? @Published var bannerEnabled: Bool = true @Published var voiceEnabled: Bool = false + @Published var panelPinned: Bool = true @Published var soundStop: String = "Glass" @Published var soundPermission: String = "Ping" @Published var voice: String = "af_heart" @@ -68,28 +69,30 @@ final class PanelNav: ObservableObject { "I'd love your input on this.", ] - var rowCount: Int { 10 } + var rowCount: Int { 11 } // Row layout (kept in one place so the controller, view, and indexing // logic all agree on what each row index means): // 0 Hotkey hotkey-record // 1 Banner notifications toggle // 2 Voice notifications toggle - // 3 Agent done sound cycle - // 4 Permission sound cycle - // 5 Voice cycle - // 6 Speed cycle - // 7 Check permissions… action - // 8 Open config file… action - // 9 Quit panel action + // 3 Pin panel toggle + // 4 Agent done sound cycle + // 5 Permission sound cycle + // 6 Voice cycle + // 7 Speed cycle + // 8 Check permissions… action + // 9 Open config file… action + // 10 Quit panel action // MARK: - Disk I/O func loadFromConfig() { let config = ConfigFile.read() hotkeyDisplay = config["STACKNUDGE_PANEL_HOTKEY"] ?? "cmd+opt+n" - bannerEnabled = ConfigFile.bool(config, "STACKNUDGE_BANNER", default: true) - voiceEnabled = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false) + bannerEnabled = ConfigFile.bool(config, "STACKNUDGE_BANNER", default: true) + voiceEnabled = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false) + panelPinned = ConfigFile.bool(config, "STACKNUDGE_PANEL_PIN", default: true) soundStop = config["STACKNUDGE_SOUND_STOP"] ?? "Glass" soundPermission = config["STACKNUDGE_SOUND_PERMISSION"] ?? "Ping" voice = config["STACKNUDGE_VOICE_NAME"] ?? "af_heart" @@ -143,9 +146,9 @@ final class PanelNav: ObservableObject { func activate() { switch selectedSettingIndex { case 0: startRecordingHotkey() - case 7: actions?.checkPermissions() - case 8: actions?.openConfig() - case 9: actions?.quit() + case 8: actions?.checkPermissions() + case 9: actions?.openConfig() + case 10: actions?.quit() default: applyCycle(forward: true) } } @@ -165,15 +168,18 @@ final class PanelNav: ObservableObject { voiceEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_VOICE", value: voiceEnabled ? "true" : "false") case 3: - soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) + panelPinned.toggle() + ConfigFile.write(key: "STACKNUDGE_PANEL_PIN", value: panelPinned ? "true" : "false") case 4: - soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 5: + soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + case 6: guard !voicesLoading, !voicesAvailable.isEmpty else { return } voice = step(voice, in: voicesAvailable, forward: forward, key: "STACKNUDGE_VOICE_NAME", preview: false) let phrase = Self.voicePreviewPhrases.randomElement() ?? "Hello." Speaker.speak(phrase, voice: voice, speed: String(format: "%.2f", voiceSpeed)) - case 6: + case 7: let next = forward ? voiceSpeed + Self.speedStep : voiceSpeed - Self.speedStep voiceSpeed = max(Self.speedMin, min(Self.speedMax, (next * 100).rounded() / 100)) ConfigFile.write(key: "STACKNUDGE_VOICE_SPEED", value: String(format: "%.2f", voiceSpeed)) diff --git a/panel/Settings.swift b/panel/Settings.swift index 6c8b35f..67eb260 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -30,22 +30,23 @@ struct SettingsView: View { section("Toggles") { row(1, label: "Banner notifications", kind: .toggle, value: nav.bannerEnabled ? "On" : "Off") row(2, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") + row(3, label: "Pin panel", kind: .toggle, value: nav.panelPinned ? "On" : "Off") } section("Sounds") { - row(3, label: "Agent done", kind: .cycle, value: nav.soundStop) - row(4, label: "Permission", kind: .cycle, value: nav.soundPermission) + row(4, label: "Agent done", kind: .cycle, value: nav.soundStop) + row(5, label: "Permission", kind: .cycle, value: nav.soundPermission) } section("Voice") { - row(5, label: "Voice", kind: .cycle, value: voiceLabel) - row(6, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) + row(6, label: "Voice", kind: .cycle, value: voiceLabel) + row(7, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) } section("Actions") { - row(7, label: "Check permissions…", kind: .action, value: "") - row(8, label: "Open config file…", kind: .action, value: "") - row(9, label: "Quit panel", kind: .action, value: "") + row(8, label: "Check permissions…", kind: .action, value: "") + row(9, label: "Open config file…", kind: .action, value: "") + row(10, label: "Quit panel", kind: .action, value: "") } } .padding(.horizontal, 14)