Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
67 changes: 65 additions & 2 deletions notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}" \
Expand All @@ -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"),
Expand Down Expand Up @@ -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.
Expand All @@ -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
}


Expand Down
4 changes: 3 additions & 1 deletion panel/EventListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
)
}
}
8 changes: 7 additions & 1 deletion panel/EventStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,19 @@ 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,
windowTitle: String? = nil, ipcHook: String? = nil,
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
Expand All @@ -58,6 +63,7 @@ struct NudgeEvent: Identifiable, Equatable {
self.terminalApp = terminalApp
self.termProgram = termProgram
self.sessionID = sessionID
self.fifoPath = fifoPath
}
}

Expand Down
49 changes: 48 additions & 1 deletion panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 22 additions & 16 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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))
Expand Down
15 changes: 8 additions & 7 deletions panel/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down