feat: desktop notifications when a Claude session needs input - #90
Open
ishaankalra wants to merge 5 commits into
Open
feat: desktop notifications when a Claude session needs input#90ishaankalra wants to merge 5 commits into
ishaankalra wants to merge 5 commits into
Conversation
spawner.FocusSession handled Zellij/Kitty/Terminal and sent everything else to iTerm2 via `default:`. Warp and Ghostty have Backend constants and fully-implemented SpawnTab paths, so both silently drove iTerm2's AppleScript dictionary — raising an osascript error on machines without iTerm2, or focusing an unrelated iTerm2 tab that happened to match the tty. Neither package implemented FocusSession at all. - warp: activates the app, always reports a miss. Warp exposes no AppleScript dictionary, CLI, or IPC socket, so selecting a specific tab is impossible. Returning (false, nil) keeps the caller's "switch to that tab manually" guidance; claiming a focus would be a lie. - ghostty: real tty-matching, but version-gated. The `tty` property on Ghostty's terminal class only exists on the 1.4.0 line (ghostty-org/ghostty#11922, closing #11592) and is absent from v1.3.1 and earlier, where the script errors instead of returning empty. So it probes and degrades to activate-and-miss. Uses Ghostty's `terminals` collection and `focus` command — not iTerm2's `sessions` or a `selected` property, which are read-only and would no-op. - spawner: every backend now dispatches explicitly; unknown backends return a clean miss so a new Backend constant can't silently inherit the wrong implementation again. Also fixes a separate pre-existing bug: iterm.focusByTTY said `tell application "iTerm2"`, which macOS rejects with -1728 ("Can't get application") — the bundle registers as "iTerm". SpawnTab always had it right, so focus-by-tty could never have succeeded on iTerm2. Verified against a live session: "iTerm2" throws -1728, "iTerm" returns cleanly. A regression test now pins both scripts to the same name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
spawner.FocusSession was reachable only from inside `flow do`'s live-session guard (do.go:217) — there was no way to focus a session by ID from outside a flow session. The Notification hook needs exactly that, since a banner's click action runs a shell command. Accepts either a session UUID (what the hook payload carries) or a task slug (what a human would type); slugs are tried only when the argument isn't UUID-shaped, so a UUID-like slug can't shadow a real session. Resolves the harness from the task so sessions opened under codex/gemini filter the process table by the right binary name rather than a hardcoded "claude". Archived tasks resolve too — an archived task can still have a live tab worth reaching. Exit codes follow the repo convention: 0 focused, 1 miss or runtime error, 2 usage. A miss is 1 rather than 0 so callers can branch on whether the focus actually happened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Questions asked across many open flow tabs go unnoticed and stall tasks. A Claude Code Notification hook now raises a banner the moment a session blocks on a human, titled with the flow task resolved from the payload's session_id, so you know which tab is asking without hunting. Clicking it runs `flow focus <session-id>` and brings that tab to the front. - internal/notify: prefers terminal-notifier (a signed .app bundle with its own bundle id, which is what lets -execute attach a click action), degrading to osascript `display notification` when absent. osascript banners cannot carry a custom action — that needs a signed app registering UNNotificationAction categories — so the fallback posts without a click. Knowing which task is asking is still most of the value. - `flow hook notification`: filters to permission_prompt/idle_prompt, the two types meaning "blocked waiting on a human". The other types (auth_success, elicitation_*, agent_needs_input, agent_completed) would be noise across many tabs. settings.json carries a matcher so Claude Code filters server-side; the in-code check is the belt to that braces for a hand-edited config. Always exits 0 — a Notification hook cannot block anything, and one that errors on every prompt would be worse than no hook. - Banners group per session, so a chatty task replaces its own banner instead of burying the others. - finalizeAutoRun notifies on completed/dead. Autonomous runs have no tab and no human watching, so their outcome is otherwise invisible; notifying at the choke point covers every terminal transition, including the early return where the harness fails to resolve. - `flow skill install` auto-installs terminal-notifier via brew when missing. Soft in every direction: no brew or non-darwin skips it, a brew failure warns and continues, and delivery re-checks at runtime. SECURITY: terminal-notifier hands -execute to a shell, so the session id reaching that string is validated against a strict UUID shape rather than quoted. Anything else produces no click action at all, leaving no character a shell could act on. Verified end to end against real sessions: a live session going idle fired the hook organically and produced a correct banner, and both auto-run branches (completed and dead) were confirmed with real headless runs. Noise types produce zero banners and malformed payloads exit 0 silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Banners auto-dismissed after a few seconds and were easy to miss, which defeats the point of the feature — the whole premise is that a blocked session goes unnoticed and stalls. Two halves to the fix, only one of which flow controls: - In flow's control: blocked-session banners (permission_prompt / idle_prompt) now post with -ignoreDnD, so an active Focus mode can't silence a session that is stalled waiting on a human. Auto-run completion banners deliberately leave the flag off — those are informational and can wait. - Not in flow's control: banner-vs-alert persistence. macOS reserves that for the user in System Settings → Notifications, and no CLI flag overrides it. The notification is owned by terminal-notifier (the signed bundle that posts it), so it is terminal-notifier's entry that has to change, not flow's. Rather than fail silently, notify exposes PersistenceHint, printed after the dependency installs, and the README documents setting Alert style to Alerts for Calendar-like behaviour. Confirmed working after switching the setting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a banner did nothing. Three independent causes, all found by
testing the click path in a minimal environment rather than a normal
shell — the earlier verification passed only because an interactive
shell has a rich PATH that the real click handler does not.
1. The click command was a bare `flow focus <id>`. terminal-notifier's
-execute handler runs without sourcing shell rc files, so its PATH is
the system default and ~/.local/bin is absent. It now uses the
running binary's absolute path via os.Executable(), single-quoted so
checkout paths containing spaces survive.
2. A stale installed binary predating the focus subcommand answers
`unknown subcommand "focus"`. README now says to `make install` after
building from source, since the click invokes the installed binary.
3. Focus followed the ambient environment instead of the tab. Detect()
answers "where would a NEW tab go" and $FLOW_TERM outranks
$TERM_PROGRAM there — correct for spawning, wrong for focusing. A
user with FLOW_TERM=iterm and older tabs in Terminal.app had every
focus aimed at iTerm2. FocusSession now tries the detected backend
first, then sweeps the other searchable backends until one claims the
session. Safe because each matches on the session's controlling tty,
so a backend that doesn't host the tab reports a miss rather than
focusing something wrong.
Warp and Ghostty are excluded from the sweep: neither can select a
tab and both foreground their app as a side effect, so probing them
speculatively would steal focus without finishing the job.
Only the detected backend's error surfaces. A speculative probe of an
uninstalled terminal ("kitty: executable file not found") says
nothing about whether the session exists and previously masked the
real "no matching tab" message.
Verified end to end: clicking a banner now focuses the correct iTerm2
tab (confirmed by the user), and cross-terminal focus works — a tab in
Terminal.app is found while FLOW_TERM=iterm, from a minimal env.
Terminal.app focus is now confirmed working too; kitty, zellij and
Ghostty remain untested (not installed here).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Fires a macOS banner whenever a Claude session blocks on input, titled with the flow task resolved from the payload's
session_id. Clicking it brings that tab to the front. Autonomousflow do --autoruns also notify when they finish or die.Questions asked across many open flow tabs currently go unnoticed and stall tasks — this collapses the discovery gap to a glance and a click.
How it works
internal/notify— prefersterminal-notifier(a signed.appbundle with its own bundle id, which is what lets-executeattach a click action), degrading toosascriptwhen absent. osascript banners cannot carry a custom action, so the fallback posts without a click; knowing which task is asking is still most of the value.flow hook notification— filters topermission_prompt/idle_prompt. The other types (auth_success,elicitation_*,agent_needs_input,agent_completed) would be noise across many tabs.settings.jsoncarries a matcher so Claude Code filters server-side; the in-code check covers a hand-edited config. Always exits 0 — a Notification hook can't block anything, and one that errors every prompt is worse than none.flow focus <session-id|task-slug>— the CLI surface the click invokes.FocusSessionwas previously reachable only from insideflow do.flow skill installauto-installsterminal-notifiervia brew when missing — soft in every direction (no brew / non-darwin skips it, brew failure warns and continues, delivery re-checks at runtime).Two pre-existing bugs fixed
spawner.FocusSessionsent Warp and Ghostty to iTerm2. Itsdefault:arm meant both backends drove iTerm2's AppleScript dictionary despite having workingSpawnTabpaths — and neither package implementedFocusSessionat all. Both now have real implementations, and every backend dispatches explicitly so a newBackendconstant can't silently inherit the wrong one.iterm.focusByTTYusedtell application "iTerm2", which macOS rejects with-1728— the bundle registers as"iTerm".SpawnTabalways had it right, so iTerm2 click-to-focus could never have worked. Verified against a live session; a regression test now pins both scripts to the same name.Terminal support
ttyproperty it needs (#11922) isn't in 1.3.x, so it probes and degradesSecurity
terminal-notifierhands-executeto a shell, so the session id reaching that string is validated against a strict UUID shape rather than quoted. Anything else produces no click action at all, leaving no character a shell could act on. Covered byTestFocusCommandRejectsNonUUID.Testing
Full suite green,
go vetclean. Beyond ~40 new unit tests, verified against real sessions:flow: notify-probe2 / Notify live probe 2 / Claude is waiting for your input).completedanddead(the latter carrying the log path).ps -axo pid,tty,commandoutput from a liveflow dosession.Note for reviewers
Notificationpayloads carry a genericmessage("Claude needs your permission"), so the task name in the title is what identifies the tab, not the body text.🤖 Generated with Claude Code