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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ History is deliberately a separate pane rather than more rows in the queue. The

Records live in `~/.stack-nudge/events.jsonl` (mode 0600), one JSON object per line, holding timestamp, agent, kind, title, message, repo path, and session id — the descriptive fields only, never the FIFO paths or PIDs. They're kept for 30 days or 10,000 records, whichever binds first, trimmed once per launch. **The log contains prompt and tool text**, so if that isn't something you want on disk, turn it off in Settings → **Event history** (`STACKNUDGE_EVENT_HISTORY=false`); Settings → **Clear event history** deletes the file outright. Nothing is ever sent anywhere — this is a local file, same as the rest of `~/.stack-nudge`.

**Answering elsewhere clears the prompt.** A permission prompt is tracked by two things: the FIFO notify.sh creates for your decision, and the hook process waiting to read it. Both are required. The FIFO alone isn't proof — the cleanup runs on exit, and nothing runs on `SIGKILL`, which is what the agent does to the hook when you answer in its own UI. So approving a plan in the terminal used to leave the panel convinced the prompt was still blocking, keeping it in the menu-bar count and re-nudging you (and Slack) for the full 550 seconds. Now a prompt whose hook has gone is retired within a tick, because there's nobody left to read a decision.

**Reminders for prompts you didn't answer.** macOS slides a banner into Notification Center after a few seconds, so a permission prompt you missed used to leave the agent blocked with nothing on screen to say so. stack-nudge now re-nudges: while a prompt is still waiting, it fires again on an interval (`STACKNUDGE_REMIND_MIN`, one of 1 / 2 / 5 minutes, default 2, `Off` to disable), up to three times — or fewer at longer intervals, since reminders stop once the hook hits its own 550-second timeout. The reminder carries the same **Allow** / **Deny** buttons and reads *"Still waiting 4m · Bash(rm -rf build/)"*, and the menu-bar icon shows a live count of prompts waiting on you — the one signal that survives an expired banner.

Reminders only fire for prompts stack-nudge can *prove* are unanswered. Claude Code and Codex permission hooks block on a FIFO that's removed the moment the hook exits, so its presence means nobody has answered — in the panel, on the banner, or in the terminal. Gemini and Antigravity route permissions through fire-and-forget notification hooks with no such signal, so they get the single banner they always did rather than reminders that might be about something you already handled. Reminders stop at three, and stop early once the hook hits its own 550-second timeout and the agent falls back to prompting in the terminal. A per-session mute silences them like any other nudge. A *global* mute silences the banner and sound but not the Slack DM — see the Slack section for why.
Expand All @@ -184,7 +186,8 @@ The token lives in the **Keychain**, never `~/.stack-nudge/config`. For scripted

Three things worth knowing about how it behaves:

- **It's idle-gated, not a mirror.** By default nothing reaches Slack until the Mac has been untouched for 5 minutes — there's no point pinging your phone about a prompt you're already looking at. Set it to `Always` if you'd rather have everything. Reminders for prompts you never answered skip the gate: you may be back at the desk and still not have seen the banner.
- **It's idle-gated, not a mirror.** By default nothing reaches Slack until the Mac has been untouched for 5 minutes — there's no point pinging your phone about a prompt you're already looking at. Set it to `Always` if you'd rather have everything — that removes the *idle* gate, not the rate limit below. Reminders for prompts you never answered skip the gate: you may be back at the desk and still not have seen the banner.
- **Finished turns are rate-limited; permission prompts aren't.** The limit is global rather than per session, so with several agents running the headline names one of them and the rest are folded into the count — the DM means "go and look", not "look here specifically". The idle setting decides *whether* you're away, not how often you're interrupted while you are — so on its own it would send a DM per event, and an unattended hour can produce fifty. Finished turns are therefore folded into one message per 15 minutes, which says what it swallowed (*"finished a turn · 7 more while you were away"*). Permission prompts are never batched: each one is blocking something until you answer it, and repeats of a single prompt are already capped at three. Coming back to the machine clears the wait — but only a stay longer than the idle threshold does, since a single stray keystroke is indistinguishable from a trackpad bump.
- **A global mute does *not* silence Slack.** Mute means "stop interrupting me *here*", and Slack exists precisely because you're elsewhere. Use the Slack switch to stop it. A *per-session* mute does apply, same as it does to banners.
- **Titles only, by default.** A DM reads *"Claude Code in attack-lib needs permission"*. The tool call itself (`Bash(rm -rf build/)`) stays on your machine unless you turn on **Include message text**, because command lines carry paths, hostnames, and sometimes secrets, and this is the one path that leaves the Mac.

Expand Down
63 changes: 63 additions & 0 deletions Tests/StackNudgePanelCoreTests/AttentionPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,67 @@ final class AttentionPolicyTests: XCTestCase {
XCTAssertEqual(list.first, 0, "each list needs an Off entry at index 0")
}
}

// MARK: - isAnswerable

private func answerable(kind: NudgeKind = .permission,
fifo: String? = "/tmp/x/fifo",
hookPID: Int? = 4242,
fifoExists: Bool = true,
alive: Bool = true) -> Bool {
AttentionPolicy.isAnswerable(kind: kind, fifoPath: fifo, hookPID: hookPID,
fifoExists: { _ in fifoExists },
processAlive: { _ in alive })
}

func test_answerable_whenTheHookIsStillWaiting() {
XCTAssertTrue(answerable())
}

// The bug this exists for. The FIFO's trap is on EXIT, which nothing honours
// for SIGKILL, and the agent kills the hook when the user answers in its own
// UI — so the file outlives the process. Evidence from one machine: 536
// leaked FIFO dirs over three months, every one still holding a live FIFO.
// Treating the file alone as proof kept the prompt in the menu-bar count and
// fired reminders, at the user and at Slack, for the full 550 seconds after
// they had already answered.
func test_notAnswerable_whenTheFifoOutlivedItsHook() {
XCTAssertFalse(answerable(fifoExists: true, alive: false),
"a FIFO nobody is reading cannot carry a decision")
}

func test_notAnswerable_onceTheFifoIsGone() {
XCTAssertFalse(answerable(fifoExists: false))
}

// Observability-only agents get no FIFO, and a finished turn has nothing to
// answer — neither should ever be counted or reminded about.
func test_notAnswerable_withoutAFifo() {
XCTAssertFalse(answerable(fifo: nil))
}

func test_notAnswerable_forNonPermissionEvents() {
XCTAssertFalse(answerable(kind: .stop))
XCTAssertFalse(answerable(kind: .other))
}

// A hook from before this field existed reports no pid. Treating that as
// dead would silently disable the panel's Allow/Deny for the first event
// after an upgrade, which is worse than the leak it protects against.
func test_missingHookPIDFallsBackToTheOldBehaviour() {
XCTAssertTrue(answerable(hookPID: nil, alive: false),
"no pid reported means we cannot judge liveness, so trust the FIFO")
XCTAssertFalse(answerable(hookPID: nil, fifoExists: false))
}

// Liveness is only consulted when there is a FIFO to answer — otherwise a
// dead-process check would be doing work for events that can never qualify.
func test_livenessIsNotConsultedWithoutAFifo() {
var asked = false
_ = AttentionPolicy.isAnswerable(kind: .permission, fifoPath: nil, hookPID: 1,
fifoExists: { _ in true },
processAlive: { _ in asked = true; return true })
XCTAssertFalse(asked)
}

}
29 changes: 29 additions & 0 deletions Tests/StackNudgePanelCoreTests/EventListenerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,33 @@ final class EventListenerTests: XCTestCase {
XCTAssertEqual(EventListener.parseEvents(Data()).count, 0)
XCTAssertEqual(EventListener.parseEvents(payload("\n\n\n")).count, 0)
}

// MARK: - hookPID validation

// The pid decides whether a prompt still counts as blocking, and it arrives
// on the same local socket as fifo_path, which is already validated.
//
// Both rejected values were verified against the real syscall: kill(0, 0)
// signals the caller's whole process group and kill(-1, 0) every process it
// may signal, and both return 0 — so either would make every prompt read as
// alive and quietly undo the fix.
func test_hookPID_rejectsGroupAndBroadcastTargets() {
XCTAssertNil(AttentionPolicy.validHookPID(0))
XCTAssertNil(AttentionPolicy.validHookPID(-1))
XCTAssertNil(AttentionPolicy.validHookPID(-4242))
}

// pid_t is Int32, and pid_t(4_000_000_000) traps rather than wrapping, so an
// oversized number in the payload would crash the panel outright.
func test_hookPID_rejectsAnythingPidTCannotHold() {
XCTAssertNil(AttentionPolicy.validHookPID(Int(pid_t.max) + 1))
XCTAssertNil(AttentionPolicy.validHookPID(4_000_000_000))
XCTAssertEqual(AttentionPolicy.validHookPID(Int(pid_t.max)), Int(pid_t.max))
}

func test_hookPID_acceptsARealPidAndPassesNilThrough() {
XCTAssertEqual(AttentionPolicy.validHookPID(4242), 4242)
XCTAssertNil(AttentionPolicy.validHookPID(nil))
}

}
155 changes: 155 additions & 0 deletions Tests/StackNudgePanelCoreTests/SlackDeliveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,159 @@ final class SlackDeliveryTests: XCTestCase {
XCTAssertEqual(SlackDelivery.idleLabel(5), "5m")
XCTAssertEqual(SlackDelivery.idleLabel(60), "60m")
}

// MARK: - Finished-turn rate limiting

private let t0 = Date(timeIntervalSince1970: 1_800_000_000)

// The bug: the idle threshold is a floor, not a rate limit. Once you cross
// it the condition stays true for the whole absence, so every event passes
// independently. Diagnosed from a real log — 1284 events, 96% of them stop,
// busy hours running 50+ — which meant an hour away was ~50 DMs.
func test_burstOfFinishedTurnsCollapsesToOneMessage() {
let turns = 50
let spacing = 72.0 // ~every 72s, i.e. just under an hour total
var lastSent: Date?
var suppressed = 0
var sent = 0
var reported = 0 // events accounted for in a sent message

for i in 0..<turns {
let now = t0.addingTimeInterval(Double(i) * spacing)
switch SlackDelivery.throttleStop(now: now, lastSentAt: lastSent,
suppressed: suppressed) {
case .suppress:
suppressed += 1
case .send(let folded):
sent += 1
reported += folded + 1 // the folded ones, plus this one
lastSent = now
suppressed = 0
}
}

// The property that matters: a handful of messages, not one per event.
let span = Double(turns - 1) * spacing
XCTAssertEqual(sent, Int(span / SlackDelivery.stopCooldown) + 1,
"one immediately, then one per cooldown window")
XCTAssertLessThan(sent, turns / 10, "50 turns must not be 50 DMs")

// And nothing vanishes: every turn is either reported or still pending
// in the suppressed count waiting for the next window.
XCTAssertEqual(reported + suppressed, turns,
"every finished turn must be accounted for somewhere")
}

// Returning to the machine clears the cooldown but must NOT discard turns
// that are still waiting to be reported. Idle is measured from the last HID
// event, so a stray trackpad bump reads as "present" — and an earlier cut
// zeroed the pending count there, so everything missed vanished and the next
// DM read as a bare "finished a turn" with nothing preceding it.
func test_pendingCountSurvivesAReturnToTheMachine() {
// Away: one sent, then three swallowed inside the cooldown.
let suppressed = 3
var lastSent: Date? = t0

// The user brushes the trackpad; the controller clears only the cooldown.
lastSent = nil

// They leave again and another turn finishes: it sends at once, and
// reports the three that were waiting.
XCTAssertEqual(SlackDelivery.throttleStop(now: t0.addingTimeInterval(60),
lastSentAt: lastSent,
suppressed: suppressed),
.send(coalesced: 3),
"turns missed while away must not be dropped on a stray keystroke")
}

// The first stop of an absence is the one worth having promptly.
func test_firstStopSendsImmediately() {
XCTAssertEqual(SlackDelivery.throttleStop(now: t0, lastSentAt: nil, suppressed: 0),
.send(coalesced: 0))
}

func test_secondStopInsideTheWindowIsSuppressed() {
let outcome = SlackDelivery.throttleStop(
now: t0.addingTimeInterval(60), lastSentAt: t0, suppressed: 0)
XCTAssertEqual(outcome, .suppress)
}

func test_stopSendsAgainOnceTheWindowPasses() {
let outcome = SlackDelivery.throttleStop(
now: t0.addingTimeInterval(SlackDelivery.stopCooldown), lastSentAt: t0, suppressed: 4)
XCTAssertEqual(outcome, .send(coalesced: 4))
}

// Nothing is dropped silently — what was swallowed is reported.
func test_coalescedCountReachesTheMessage() {
let event = NudgeEvent(agent: "claude-code", kind: .stop, title: "Claude Code",
message: "", projectPath: "/Users/x/stack-nudge")
let text = SlackDelivery.text(for: event, label: "stack-nudge",
includeDetail: false, isReminder: false, coalesced: 7)
XCTAssertTrue(text.contains("7 more"), "got: \(text)")
}

func test_singleTurnReadsNormally() {
let event = NudgeEvent(agent: "claude-code", kind: .stop, title: "Claude Code",
message: "", projectPath: "/Users/x/stack-nudge")
let text = SlackDelivery.text(for: event, label: "stack-nudge",
includeDetail: false, isReminder: false, coalesced: 0)
XCTAssertEqual(text, "Claude Code in stack-nudge finished a turn")
}

// Permission prompts must NOT be throttled: each blocks an agent until it is
// answered, they are rare, and repeats of one are already capped by
// AttentionPolicy.maxReminders. Throttling them would withhold exactly the
// notifications that are actionable.
func test_permissionPromptsAreNotRateLimited() {
for _ in 0..<20 {
XCTAssertTrue(SlackDelivery.shouldSend(
kind: .permission, isReminder: false, sessionMuted: false,
enabled: true, notifyOnStop: true,
idleSeconds: 3600, idleThresholdMinutes: 10),
"a blocking prompt must always get through")
}
}


// MARK: - Presence

private func effect(idle: TimeInterval, threshold: Int = 10,
presentFor: TimeInterval = 0) -> SlackDelivery.PresenceEffect {
SlackDelivery.presenceEffect(idleSeconds: idle, idleThresholdMinutes: threshold,
presentFor: presentFor)
}

func test_presence_awayWhileIdlePastTheThreshold() {
XCTAssertEqual(effect(idle: 11 * 60), .away)
}

// The discriminator. A single stray HID event keeps idle under the threshold
// for exactly the threshold and not a second longer, so it can never reach
// `sustainedPresent` — which is what stops a trackpad bump discarding the
// record of everything missed.
func test_presence_oneStrayEventCanNeverLookSustained() {
let threshold = 10.0 * 60
// Walk the whole life of a single bump: idle climbs from 0, and the
// time "present" climbs with it, so the two are always equal.
for elapsed in stride(from: 0.0, to: threshold, by: 30) {
XCTAssertEqual(effect(idle: elapsed, presentFor: elapsed), .brieflyPresent,
"at \(elapsed)s a lone event must stay 'brief'")
}
// Past the threshold the bump stops counting as present at all.
XCTAssertEqual(effect(idle: threshold, presentFor: threshold), .away)
}

// Two events far enough apart is a person: idle resets while the presence
// keeps running, so presentFor outgrows idle.
func test_presence_sustainedNeedsMoreThanOneEvent() {
XCTAssertEqual(effect(idle: 60, presentFor: 11 * 60), .sustainedPresent)
}

// "Always" removes the idle gate, so there is no presence to detect and the
// cooldown runs continuously.
func test_presence_alwaysHasNoPresenceToDetect() {
XCTAssertEqual(effect(idle: 0, threshold: 0, presentFor: 9999), .away)
}

}
Loading