Skip to content

feat: desktop notifications when a Claude session needs input - #90

Open
ishaankalra wants to merge 5 commits into
mainfrom
feat/notify-session-needs-input
Open

feat: desktop notifications when a Claude session needs input#90
ishaankalra wants to merge 5 commits into
mainfrom
feat/notify-session-needs-input

Conversation

@ishaankalra

Copy link
Copy Markdown
Contributor

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. Autonomous flow do --auto runs 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 — prefers terminal-notifier (a signed .app bundle with its own bundle id, which is what lets -execute attach a click action), degrading to osascript when 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 to permission_prompt / idle_prompt. 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 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. FocusSession was previously reachable only from inside flow do.
  • Banners group per session, so a chatty task replaces its own banner rather than burying the others.
  • flow skill install auto-installs terminal-notifier via 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

  1. spawner.FocusSession sent Warp and Ghostty to iTerm2. Its default: arm meant both backends drove iTerm2's AppleScript dictionary despite having working SpawnTab paths — and neither package implemented FocusSession at all. Both now have real implementations, and every backend dispatches explicitly so a new Backend constant can't silently inherit the wrong one.

  2. iterm.focusByTTY used tell application "iTerm2", which macOS rejects with -1728 — the bundle registers as "iTerm". SpawnTab always 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

Backend Click-to-focus
iTerm2, Terminal.app, kitty, zellij Focuses the exact tab
Ghostty Works once 1.4.0 ships — the tty property it needs (#11922) isn't in 1.3.x, so it probes and degrades
Warp Foregrounds the app only — Warp exposes no scripting surface for selecting a tab, so it reports an honest miss rather than claiming a focus

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. Covered by TestFocusCommandRejectsNonUUID.

Testing

Full suite green, go vet clean. Beyond ~40 new unit tests, verified against real sessions:

  • A live session going idle fired the hook organically and produced a correct banner (flow: notify-probe2 / Notify live probe 2 / Claude is waiting for your input).
  • Both auto-run branches confirmed with real headless runs — completed and dead (the latter carrying the log path).
  • Noise types produce zero banners; malformed payloads exit 0 silently.
  • Install/uninstall are idempotent and leave sibling hooks byte-identical.
  • tty extraction verified against verbatim ps -axo pid,tty,command output from a live flow do session.

Note for reviewers

Notification payloads carry a generic message ("Claude needs your permission"), so the task name in the title is what identifies the tab, not the body text.

🤖 Generated with Claude Code

ishaankalra and others added 5 commits August 6, 2026 10:59
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>
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.

1 participant