Skip to content

fix(notify): stop nudging about answered prompts, and rate-limit finished-turn DMs - #173

Merged
hiskudin merged 4 commits into
mainfrom
fix/post-1.33-fixes
Sep 14, 2026
Merged

hiskudin merged 4 commits into
mainfrom
fix/post-1.33-fixes

Conversation

@hiskudin

Copy link
Copy Markdown
Collaborator

Two notification bugs found in daily use, both diagnosed from live evidence on the reporting machine rather than from reading the code.


1. Answering a prompt elsewhere didn't clear it

Symptom: approve a plan in the terminal and the notification stays — in the panel and in Slack — and keeps re-nudging.

Cause: the panel treated "the FIFO file exists" as proof a prompt was still blocking. That holds only while the hook reading it is alive, and notify.sh cleans up with a trap … EXIT — which bash honours for SIGTERM (I tested this) but nothing honours for SIGKILL, which is what the agent does to the hook when you answer in its own UI. The file outlives the process, so the panel kept the prompt in the menu-bar count and fired reminders at the user and at Slack for the full 550s.

Evidence: 536 leaked FIFO directories in $TMPDIR going back three months, every one still holding a live FIFO — so the trap had not run once. Newest was from the previous day.

Fix: the hook's pid now travels with the event. The FIFO answers "was a prompt raised?"; the pid answers "is anyone still listening?". Both are required, so a prompt whose hook has gone retires within a tick. An event from an older notify.sh carries no pid and falls back to the previous behaviour, rather than having Allow/Deny silently disabled for the first event after an upgrade.

Also traps INT/TERM/HUP explicitly and sweeps leaked dirs older than 30 minutes — comfortably past the 550s a prompt can live, so one in use is never in range. Neither is load-bearing now the panel doesn't trust the trap, but 536 leaked FIFOs is its own small problem.

Considered and rejected: probing with open(O_WRONLY|O_NONBLOCK) and reading ENXIO as "no reader". Needs no new field — but the hook selects on the read end, so a probe opening and closing the write end delivers EOF, the hook exits with an empty decision, and the panel's Allow/Deny stops working. Worse than the bug being fixed.

2. An hour away was fifty Slack DMs

Cause: 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 passed it independently. The setting answers "are you away?" and nothing answered "how often should I interrupt you while you are?"

Evidence: the reporting machine's event log — 1284 records, 96% of them stop, with busy hours running 50–56 events. With "also notify on finished turns" on, an hour away was ~50 DMs, not the six a ten-minute idle setting implies.

Fix: finished turns collapse into one message per 15 minutes, and that message says what it swallowed ("finished a turn · 7 more while you were away") so a quiet hour reads as one message about eight turns rather than looking like eight went missing. The cooldown resets while the user is at the machine, so the first stop of each absence still arrives promptly. With the idle gate on Always there's no "present" to detect and it runs continuously, which is what that setting asks for.

Permission prompts are deliberately exempt — each blocks an agent until answered, they're rare (45 of those 1284), and repeats of a single one are already capped by maxReminders. Throttling them would withhold precisely the notifications that are actionable.


Testing

./build.sh clean · make test-without-xcode678 tests, 1773 assertions, 0 failures

New coverage is policy-level and injectable (AttentionPolicy.isAnswerable, SlackDelivery.throttleStop), including:

  • a FIFO that outlived its hook is not answerable; a missing pid falls back to the old behaviour
  • liveness isn't consulted for events that can never qualify
  • the burst test asserts conservation — every finished turn is either reported in a sent message or still pending in the suppressed count. A message count alone passed while silently dropping events, which is the failure that actually matters.

The find sweep was dry-run against the real 536 directories: all matches our own pattern, nothing else caught.

Note for whoever merges

feat/slack-permission-responses also modifies notifySlack, so whichever lands second needs a small manual resolution.

hiskudin and others added 3 commits September 10, 2026 14:46
…essages

The idle threshold is a floor, not a rate limit. Once you cross it the condition
stays true for the whole time you are away, so every event passed it
independently — the setting answers "are you away?" and nothing answered "how
often should I interrupt you while you are?"

Diagnosed against a real event log: 1284 records, 96% of them `stop`, with busy
hours running 50 to 56 events. With "also notify on finished turns" on, an hour
away was around fifty DMs rather than the six a ten-minute idle setting suggests.

Finished turns now collapse into one message per 15 minutes, and that message
says what it swallowed ("finished a turn · 7 more while you were away") so a
quiet hour reads as one message about eight turns rather than looking like eight
went missing. The cooldown resets whenever the user is back at the machine, so
the first stop of each absence still arrives promptly instead of being eaten by
a window left running from the previous one. With the idle gate set to Always
there is no "present" to detect and the cooldown simply runs continuously, which
is what that setting asks for.

Permission prompts are deliberately exempt. Each blocks an agent until it is
answered, they are rare — 45 of those 1284 — and repeats of any single one are
already bounded by AttentionPolicy.maxReminders. Throttling them would withhold
precisely the notifications that are actionable.

The burst test asserts conservation rather than a message count: every finished
turn is either reported in a sent message or still pending in the suppressed
count. A count alone would have passed while silently dropping events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bout it

Approving a plan in the terminal left the notification standing, locally and in
Slack, and kept re-nudging about a prompt that had already been answered.

The panel treated the existence of notify.sh's FIFO as proof a prompt was still
blocking. That holds only while the hook that created it is alive to read a
decision, and the cleanup is a trap on EXIT — which bash honours for SIGTERM
(verified) but nothing honours for SIGKILL, which is what the agent does to the
hook when the user answers in its own UI. The file then outlives the process.

The evidence was sitting on disk: 536 leaked FIFO directories going back three
months, every single one still holding a live FIFO, so the trap had not run
once, and the newest was from yesterday.

So liveness is now checked alongside the file. The FIFO answers "was a prompt
raised?"; the hook's pid, which now travels with the event, answers "is anyone
still listening?". A prompt whose hook has gone cannot be answered from the
panel — there is nothing left to read the decision — so it retires within a tick
and stops being counted and reminded about. An event from an older notify.sh
carries no pid, and falls back to the previous behaviour rather than having its
Allow/Deny silently disabled for the first event after an upgrade.

Also traps INT/TERM/HUP explicitly and sweeps leaked directories older than 30
minutes, comfortably past the 550s a prompt can live for so one in use is never
in range. Neither is load-bearing now that the panel no longer trusts the trap,
but 536 leaked FIFOs is its own small problem.

Considered and rejected: probing the FIFO with open(O_WRONLY|O_NONBLOCK) and
reading ENXIO as "no reader". It needs no new field, but the hook selects on the
read end, so a probe opening and closing the write end delivers EOF, the hook
exits with an empty decision, and the panel's Allow/Deny stops working — a worse
bug than the one being fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adversarial review found the throttle's headline property was false end to end.
resetStopThrottleIfPresent zeroed suppressedStopCount along with the cooldown, so
turns that finished while the user was away and had not been reported yet were
discarded rather than folded into the next message.

Idle is measured from the last HID event, which makes this easy to hit without
ever returning: a stray trackpad bump or a notification click reads as "present",
the pending count is wiped, and the next DM claims a bare "finished a turn" as
though nothing preceded it. The burst test asserted conservation over
throttleStop alone and so never saw it.

Only the cooldown is cleared now. Keeping the count is accurate rather than
merely safe: it is incremented only past the idle gate in notifySlack, so it can
only ever hold turns that genuinely finished while the user was away, however
many brief presences the absence is split across.

Also records why pid reuse is bounded, since it is the obvious objection to the
liveness check: a watch is created with firstSeenAt taken from the event and
retired in the same pass once that exceeds promptLifetime, so a reused pid cannot
resurrect a stale prompt — the window is 550s from the prompt, not the 30 minute
sweep interval, and inside it the worst case is the behaviour this replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hiskudin

Copy link
Copy Markdown
Collaborator Author

Ran an adversarial pass over this before asking anyone else to look. It found one real bug, now fixed in 0adcd12.

Fixed — the throttle's headline property was false end to end

resetStopThrottleIfPresent zeroed suppressedStopCount along with the cooldown, so turns that finished while you were away and hadn't been reported yet were discarded rather than folded into the next message.

Easy to hit without ever really returning: idle is measured from the last HID event, so a stray trackpad bump or a notification click reads as "present", the pending count is wiped, and the next DM claims a bare "finished a turn" as though nothing preceded it.

The burst test asserted conservation over throttleStop alone, which is exactly why it didn't catch this — the loss happened in the controller, outside the function under test. A regression test now covers the return-to-machine path.

Only the cooldown is cleared now. Keeping the count is accurate rather than merely safe: it's incremented only past the idle gate in notifySlack, so it can only ever hold turns that genuinely finished while you were away, however many brief presences that absence gets split across.

Checked and dismissed

  • Pid reuse making a dead prompt look alive — bounded and benign, and I've added the reasoning to the code since it's the obvious objection. A watch is created with firstSeenAt taken from the event and retired in the same pass once that exceeds promptLifetime, so a reused pid can't resurrect a stale prompt. The window is 550s from the prompt, not the 30-minute sweep interval, and inside it the worst case is simply the behaviour this PR replaces.
  • Data race on the throttle counters — none. EventStore.append always arrives via DispatchQueue.main.async, and the ticker is a main-runloop Timer, so everything touching that state is single-threaded.
  • Stale pid surviving a restartEventLog deliberately excludes fifoPath/hookPID from the persisted record ("a replayed record cannot masquerade as actionable"), so a reloaded event never carries one.
  • Sweep safetyfind -type d doesn't match symlinks and rm -rf doesn't dereference them; cross-user removal would need $TMPDIR unset and would still be blocked by the sticky bit on /tmp.
  • Zombie processes answering kill(0) — real but self-corrects on the next 5s tick.
  • Double cleanup from trap … EXIT INT TERM HUP — happens, harmless: rm -f / rmdir 2>/dev/null are idempotent.

Known gap, not closed here

The real kill(pid, 0) wiring and all of notify.sh are untested — bash -n is the only check the shell script gets. I verified the liveness path empirically instead, reproducing the exact bug against a faithful mock of the hook's structure:

recorded $$ = 14023
is that pid the live blocking script?   bash          <- $$ is correct
kill(pid,0) while alive:                ALIVE
--- SIGKILL, as the agent does ---
FIFO still exists after SIGKILL:        YES  (the leak)
kill(pid,0) after:                      DEAD -> prompt retires

That's evidence, not coverage. A bash test harness for notify.sh is worth its own PR — it'd also cover the stop/permission payload shape, which is the same class of untested producer/consumer contract that let a format string break silently in #169.

./build.sh clean · 679 tests, 1775 assertions, 0 failures

@StuBehan StuBehan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid pair of fixes and I think both diagnoses are right - the pid and the fifo answering two different questions is the bit that makes it work, rather than trying to make the trap more reliable.

Pulled it and ran make test-without-xcode, 679 tests / 1775 assertions / 0 failures, same as you got.

Two things I'd want sorting before this merges - the sweep sitting on the hot path, and the suppressed count outliving the absence it belongs to. Rest is questions and nits, flagged as such.

Also on the zombie point in your adversarial pass - it self corrects because node reaps its children more or less straight away, but that's the load bearing bit isn't it? Worth a clause in the comment, a SIGKILLed child still answering kill(0) is exactly the case this whole thing exists for.

Comment thread notify.sh Outdated
Comment thread panel/Panel.swift Outdated
Comment thread panel/Panel.swift Outdated
Comment thread panel/Panel.swift Outdated
Comment thread panel/Panel.swift Outdated
Comment thread panel/SlackNotifier.swift
Comment thread panel/EventListener.swift Outdated
Comment thread Tests/StackNudgePanelCoreTests/SlackDeliveryTests.swift Outdated
Comment thread panel/SlackNotifier.swift
…that means it

Nine review threads. The two blockers:

The stale-FIFO sweep ran before post_to_panel, so it sat on the path to the
banner. Measured on a large $TMPDIR: 2.2s cold, i.e. two seconds before the user
learns an agent is blocked, on the one path where latency is the product. It is
backgrounded now — the dir just created is seconds old so can never be in range
of -mmin +30, and the hook lives 550s, so the sweep has all the time it needs.

Keeping the suppressed count on any presence traded one bug for another: a count
from yesterday's absence could be reported on tomorrow's first DM. The
discriminator is how long the presence lasted. A single stray HID event keeps
idle under the threshold for exactly the threshold and not a second more, so a
presence that outlasts it cannot be one event — it takes two, far enough apart,
which is a person. A brief presence now resets only the cooldown; a sustained
one clears what was missed, because the panel has had it on screen throughout.

Also from review:

The hook pid was unvalidated while fifo_path on the same socket was not. Verified
against the real syscall: kill(0, 0) signals the caller's process group and
kill(-1, 0) every process it may signal, both returning 0, so either value made
every prompt read as alive and undid the fix. Worse and unflagged — pid_t is
Int32 and pid_t(4_000_000_000) traps, so an oversized number in the payload
crashed the panel. Both rejected now, with the conversion made non-trapping as
well rather than left one guard away.

A dead prompt kept offering Allow/Deny, where writeFIFO gets ENXIO and returns
silently — a button that does nothing. Now the two are distinguishable, the
affordance goes with the liveness. The check moved to NudgeEvent.isStillBlocking
so the view layer and the controller ask it the same way.

Two comments asserted the belief this PR disproves ("the file existing means
nobody has answered yet", "retire on the FIFO alone"). Corrected. The zombie
window now says why reaping is load-bearing rather than incidental.

Dropped a test assertion that checked a local assigned on the line above, which
could not fail. Fair hit, on a PR arguing for conservation over counting.

README: Always removes the idle gate, not the rate limit; and the limit is
global, so the DM means "go and look" rather than "look here specifically".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hiskudin

Copy link
Copy Markdown
Collaborator Author

All nine addressed in 1bc3f47 — replies on each thread, seven resolved, two left open on purpose (the Always question and the cyclable cooldown, both of which are yours to call).

686 tests, 1806 assertions, 0 failures · ./build.sh clean.

Both blockers were right, and the sweep one I should have caught myself — I reasoned "it's only a find" and never measured it on a realistic $TMPDIR. 2.2s in front of the banner is the worst possible place to put it.

The two I'd single out:

The presence discriminator is your design, not mine. I'd fixed "a bump discards the backlog" and created "yesterday's backlog reports tomorrow" without noticing I'd only moved the problem. Using the duration of the presence separates them cleanly, and it's testable in a way my version wasn't — the test now walks the entire life of a single bump and asserts it never once looks sustained, rather than spot-checking either side of a boundary.

The pid finding was worse than flagged. You called it "degrades to the old behaviour, not blocking". It doesn't: pid_t is Int32, so pid_t(4_000_000_000) traps, and that value arrives on a local socket. That's a crash, not a degrade. Validated, and the conversion made non-trapping too rather than relying on the one guard.

Also fixed the two comments that asserted the belief this PR disproves — that's the kind of thing that outlives the code and misleads whoever reads it next.

On the zombie point from your summary: agreed, and it's in the comment now. It self-corrects because the agent reaps promptly, and that's load-bearing rather than incidental — a SIGKILLed child still answering kill(0) is exactly the case this check exists for, so an agent that left hooks unreaped would quietly degrade this back to FIFO-only. Never worse than before, but no longer a fix.

@hiskudin
hiskudin merged commit 677ab14 into main Sep 14, 2026
6 checks passed
@hiskudin
hiskudin deleted the fix/post-1.33-fixes branch September 14, 2026 08:25
hiskudin added a commit that referenced this pull request Sep 14, 2026
Approving a plan in Claude Code's own UI left stack-nudge reminding about it,
locally and in Slack, for about nine more minutes.

ExitPlanMode took the blocking permission path: notify.sh created a FIFO and
waited on it for a decision. But answering in CC's UI doesn't end that hook, so
it ran to its full 550s timeout — and for the whole of that the panel saw a live
hook holding a live FIFO, which is exactly its definition of a prompt still
waiting. #173 retires a prompt whose hook has died; this one hadn't.

ExitPlanMode belongs with AskUserQuestion instead. Both are answered in the
agent's own UI, and for both an "allow" written from the panel means "take the
default" — here, approving a plan the user hasn't read. With has_action=false
there's no FIFO, so panel Enter focuses the editor and no reminder loop starts.

Verified against the installed hook: ExitPlanMode now returns in 0s creating no
FIFO, AskUserQuestion is unchanged, and a Bash permission still creates its FIFO
and blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hiskudin added a commit that referenced this pull request Sep 15, 2026
#177)

* fix(notify): stop nudging about plans already approved in the terminal

Approving a plan in Claude Code's own UI left stack-nudge reminding about it,
locally and in Slack, for about nine more minutes.

ExitPlanMode took the blocking permission path: notify.sh created a FIFO and
waited on it for a decision. But answering in CC's UI doesn't end that hook, so
it ran to its full 550s timeout — and for the whole of that the panel saw a live
hook holding a live FIFO, which is exactly its definition of a prompt still
waiting. #173 retires a prompt whose hook has died; this one hadn't.

ExitPlanMode belongs with AskUserQuestion instead. Both are answered in the
agent's own UI, and for both an "allow" written from the panel means "take the
default" — here, approving a plan the user hasn't read. With has_action=false
there's no FIFO, so panel Enter focuses the editor and no reminder loop starts.

Verified against the installed hook: ExitPlanMode now returns in 0s creating no
FIFO, AskUserQuestion is unchanged, and a Bash permission still creates its FIFO
and blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(notify): repair FIFO cleanup and don't depend on jq for it

Review of the ExitPlanMode change turned up two defects underneath it.

The EXIT trap's cleanup has never run. Its body referenced `$fifo`, a local of
the function that installed it, and a single-quoted trap body expands when the
trap fires — by then the function has returned and the local is gone, so it ran
as `rm -f ""` and `rmdir .`. Every permission prompt leaked its FIFO directory
until the 30-minute sweep collected it. Verified both ways on the normal
completion path: before, the directory survives an answered prompt; after, it's
removed.

That also invalidates a comment in AttentionPolicy: 536 leaked directories were
read as proof the trap had been skipped, when they were proof it had run and
done nothing. The conclusion there still holds — a SIGKILLed hook skips the trap
outright — but the stated evidence for it did not, so it's corrected.

And `is_question_event` bailed out when jq was missing, which meant ExitPlanMode
went back on the blocking path and the panel offered Allow on a plan nobody had
read. Only macOS 15+ preinstalls jq; post_to_panel already parses the same JSON
with python3 for exactly this reason. tool_name now falls back the same way,
confirmed to still return ExitPlanMode with jq off PATH.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: correct the FIFO-cleanup claim the trap fix invalidates

The README said the cleanup 'runs on exit' and is only skipped on SIGKILL. It
never ran at all: the trap body referenced a local that was out of scope by the
time it fired. Says so now, alongside the SIGKILL case which is still true.

* fix(notify): correct which cleanup paths were actually broken

A reviewer falsified the story I told about the trap, and reproducing it proves
them right. Bash defers a trapped signal until the foreground child exits, so on
INT/TERM/HUP the handler runs *inside* wait_for_permission_response, where the
local is still in scope:

  SIGTERM while blocked:  TRAP fifo=[/tmp/scopetest.../fifo]  -> cleaned up
  clean exit:             TRAP fifo=[]                        -> leaked

So the old trap worked on signals and failed only on the clean-exit path —
answered or timed-out prompts. "It cleaned up nothing on any prompt" was wrong,
and I'd written it into the code comment, the README and AttentionPolicy. All
three now say which path was broken.

The 536 leaked directories don't settle it either way: SIGKILL runs no trap at
all and produces exactly the same evidence. That was the original mistake — an
inference the data couldn't support — and I repeated it in the other direction.

Also corrects the INT/TERM/HUP comment, which claimed bash runs the EXIT trap
for a plain SIGTERM. It does, but not promptly: a trapped signal doesn't
terminate bash, so the hook runs on to its 550s timeout and keeps answering
kill(0) regardless.

The fix itself is unchanged and still correct — the clean-exit path was genuinely
leaking, and PERM_FIFO fixes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants