From 6a367a0caace7dc1fb3736090cba4d05c628e53a Mon Sep 17 00:00:00 2001 From: Jon Grace-Cox <30441316+jongracecox@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:57:55 -0500 Subject: [PATCH 1/3] feat: add background session monitor with macOS notifications Send macOS notifications when background sessions transition to tool approval state while user is attached to a tmux session. Monitors all project sessions via a daemon thread, skipping the currently-attached session. --- CLAUDE.md | 11 +++ src/fujimoto/cli.py | 30 ++++-- src/fujimoto/monitor.py | 127 ++++++++++++++++++++++++ tests/test_cli.py | 9 ++ tests/test_monitor.py | 210 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 src/fujimoto/monitor.py create mode 100644 tests/test_monitor.py diff --git a/CLAUDE.md b/CLAUDE.md index 62f53e4..c2ccee3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ src/fujimoto/ ├── cli.py # Textual TUI app, entry point (main()), all UI screens and event handlers ├── config.py # Environment variable loading, path construction, session metadata ├── git.py # Git subprocess wrappers (worktree lifecycle, branch operations) +├── monitor.py # Background session monitor with macOS notifications ├── terminal.py # Open native terminal windows (iTerm2 with Terminal.app fallback) ├── vscode.py # Open directories in VS Code via the `code` CLI ├── tmux.py # tmux session lifecycle (create, attach, kill, list, install) @@ -99,6 +100,15 @@ src/fujimoto/ - `delete_branch(branch, remote)` — `git branch -D`, optionally remote - `cherry_pick_branch(branch, onto)` — cherry-picks commit range onto target +**`monitor.py`** — Background session monitor with macOS notifications: +- `SessionMonitor` — background thread that polls Claude JSONL logs during tmux attachment +- `_poll_once(paths, snapshot, attached_path)` — single poll cycle: detects state transitions, sends notifications +- `_send_notification(title, message)` — macOS notification via `osascript` +- `_state_display(state)` — human-readable label for session states +- Monitors all project sessions while user is attached to a tmux session +- Skips the currently-attached session (user can see it directly) +- Notifies on `WAITING_FOR_TOOL_APPROVAL` transitions via macOS Notification Center + **`terminal.py`** — Open native terminal windows in a session's directory: - `open_terminal(directory)` — opens iTerm2 if installed, otherwise Terminal.app. Raises `OSError` on non-macOS. - `_has_iterm()` — checks for `/Applications/iTerm.app` @@ -168,6 +178,7 @@ Three custom exception types, all caught in `main()`: - **Per-session tmux config**: Prefix remapped to Ctrl+A, status bar with shortcut hints — all set via `tmux set-option -t` so the user's global config is untouched. - **Global install via `uv tool`**: Requires `--force --reinstall` to rebuild the wheel from source. Plain `--force` reuses cached builds. - **Session metadata**: `.fujimoto-meta.json` stored in worktree directory records the base branch for cherry-pick targeting. +- **Background session monitor**: While the user is attached to a tmux session, a `SessionMonitor` daemon thread polls all project session logs every 3 seconds. When a background session transitions to `WAITING_FOR_TOOL_APPROVAL`, a macOS notification is sent via `osascript`. The monitor builds an initial snapshot without notifying to avoid false alerts on startup, and skips the currently-attached session. The thread is started before `tmux attach` and stopped (via `Event.set()`) after detach. - **Background PR creation**: Uses `claude -p --allowedTools "Bash(git:*) Bash(gh:*)"` in a tmux session for unattended PR creation. - **Claude session integration**: The home screen fetches Claude session state from `~/.claude/projects/` JSONL logs via the log parser. Session states: 👀 awaiting input (`WAITING_FOR_USER`), 🛡️ approve tool (`WAITING_FOR_TOOL_APPROVAL`), ⚙ working (`WORKING`), 💤 idle (`IDLE`), no indicator (`UNKNOWN`). State logic: `last-prompt` marker → `IDLE` (session ended). For assistant entries: `stop_reason=tool_use` without a following `tool_result` → `WAITING_FOR_TOOL_APPROVAL` (pending user approval), `stop_reason=tool_use` with `tool_result` → `WORKING`, any other stop reason or no stop reason → `WAITING_FOR_USER`. Last entry is user → `WORKING`. Previous Claude sessions (from the project root, capped at 5) appear as resumable items. Resuming launches `claude --resume SESSION_ID` in a new tmux session. The latest Claude session per path is "claimed" by the corresponding tmux/worktree item to avoid duplication. - **Live polling**: The home screen uses `set_interval(3s)` to poll Claude JSONL logs for state changes. When a session's state changes, labels are updated in-place via `label.update()` — the screen is never cleared or rebuilt, which avoids blank-screen flicker. A snapshot dict (`path → (session_id, state)`) is compared each tick to detect changes efficiently. The timer is stopped when navigating away (`_clear_main` cancels it) and restarted by `_show_home`. diff --git a/src/fujimoto/cli.py b/src/fujimoto/cli.py index 42841e2..6e9feb0 100644 --- a/src/fujimoto/cli.py +++ b/src/fujimoto/cli.py @@ -50,6 +50,7 @@ push_branch, remove_worktree, ) +from fujimoto.monitor import SessionMonitor from fujimoto.terminal import open_terminal from fujimoto.vscode import open_vscode from fujimoto.tmux import ( @@ -1663,13 +1664,30 @@ def main() -> None: if resume_id else _build_system_prompt(session_type, project_name, working_dir) ) - launch_claude_in_tmux( - project_name, - working_dir, - tmux_name, - system_prompt=system_prompt, - resume_session_id=resume_id, + + # Collect all paths to monitor for background notifications + monitor_paths: list[Path] = [] + if app._project_root: + monitor_paths.append(app._project_root) + monitor_paths.extend(app._existing_worktrees) + if working_dir not in monitor_paths: + monitor_paths.append(working_dir) + + monitor = SessionMonitor( + paths=monitor_paths, + attached_path=working_dir, ) + monitor.start() + try: + launch_claude_in_tmux( + project_name, + working_dir, + tmux_name, + system_prompt=system_prompt, + resume_session_id=resume_id, + ) + finally: + monitor.stop() else: break set_terminal_title("") diff --git a/src/fujimoto/monitor.py b/src/fujimoto/monitor.py new file mode 100644 index 0000000..383e420 --- /dev/null +++ b/src/fujimoto/monitor.py @@ -0,0 +1,127 @@ +"""Background monitor for Claude session state changes. + +Polls Claude JSONL logs and sends macOS notifications when sessions +transition to states that need user attention (e.g. tool approval). +""" + +from __future__ import annotations + +import subprocess +import threading +from pathlib import Path + +from fujimoto.claude import ( + ClaudeSession, + SessionState, + get_sessions_for_path, +) + + +def _send_notification(title: str, message: str) -> None: + """Send a macOS notification via osascript.""" + script = ( + f'display notification "{message}" with title "{title}" sound name "default"' + ) + subprocess.run( + ["osascript", "-e", script], + capture_output=True, + ) + + +def _state_display(state: SessionState) -> str: + """Human-readable label for a session state.""" + return { + SessionState.WAITING_FOR_TOOL_APPROVAL: "Needs tool approval", + SessionState.WAITING_FOR_USER: "Waiting for input", + }.get(state, state.value) + + +# States that warrant a notification +_NOTIFY_STATES = frozenset( + { + SessionState.WAITING_FOR_TOOL_APPROVAL, + } +) + + +def _poll_once( + paths: list[Path], + snapshot: dict[str, tuple[str, SessionState]], + attached_path: Path | None, +) -> dict[str, tuple[str, SessionState]]: + """Poll all paths and notify on interesting state transitions. + + Returns the new snapshot. + """ + new_snapshot: dict[str, tuple[str, SessionState]] = {} + + for path in paths: + sessions = get_sessions_for_path(path) + if not sessions: + continue + latest: ClaudeSession = sessions[0] + new_snapshot[str(path)] = (latest.session_id, latest.state) + + # Skip the session the user is currently looking at + if attached_path and path == attached_path: + continue + + old = snapshot.get(str(path)) + if old and old == (latest.session_id, latest.state): + continue + + # State changed — check if it's interesting + if latest.state in _NOTIFY_STATES: + session_label = path.name + _send_notification( + f"fujimoto — {session_label}", + _state_display(latest.state), + ) + + return new_snapshot + + +class SessionMonitor: + """Background thread that monitors Claude sessions for state changes.""" + + def __init__( + self, + paths: list[Path], + attached_path: Path | None = None, + interval: float = 3.0, + ) -> None: + self._paths = paths + self._attached_path = attached_path + self._interval = interval + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + + def start(self) -> None: + """Start the background monitor thread.""" + if self._thread is not None: + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the monitor and wait for the thread to finish.""" + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=5) + self._thread = None + + def _run(self) -> None: + snapshot: dict[str, tuple[str, SessionState]] = {} + + # Build initial snapshot without notifying + for path in self._paths: + sessions = get_sessions_for_path(path) + if sessions: + snapshot[str(path)] = (sessions[0].session_id, sessions[0].state) + + while not self._stop_event.is_set(): + self._stop_event.wait(self._interval) + if self._stop_event.is_set(): + break + snapshot = _poll_once(self._paths, snapshot, self._attached_path) diff --git a/tests/test_cli.py b/tests/test_cli.py index e01f394..61a61ca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -147,6 +147,8 @@ def test_launches_tmux_then_loops_back(self) -> None: # Second iteration: no target -> exit loop app1 = SessionApp.__new__(SessionApp) app1._launch_target = ("proj", Path("/tmp/test"), None, "worktree", None) + app1._project_root = Path("/tmp/repo") + app1._existing_worktrees = [] app2 = SessionApp.__new__(SessionApp) app2._launch_target = None @@ -158,6 +160,7 @@ def test_launches_tmux_then_loops_back(self) -> None: patch("fujimoto.cli.launch_claude_in_tmux") as mock_launch, patch("fujimoto.cli._build_system_prompt", return_value="test") as mock_sp, patch("fujimoto.cli._session_terminal_title", return_value="test-title"), + patch("fujimoto.cli.SessionMonitor"), ): main() mock_sp.assert_called_once_with("worktree", "proj", Path("/tmp/test")) @@ -191,6 +194,8 @@ def test_launches_with_tmux_name(self) -> None: "direct", None, ) + app1._project_root = Path("/tmp/repo") + app1._existing_worktrees = [] app2 = SessionApp.__new__(SessionApp) app2._launch_target = None @@ -202,6 +207,7 @@ def test_launches_with_tmux_name(self) -> None: patch("fujimoto.cli.launch_claude_in_tmux") as mock_launch, patch("fujimoto.cli._build_system_prompt", return_value="test"), patch("fujimoto.cli._session_terminal_title", return_value="test-title"), + patch("fujimoto.cli.SessionMonitor"), ): main() mock_launch.assert_called_once_with( @@ -2234,6 +2240,8 @@ def test_resume_skips_system_prompt(self) -> None: "direct", "resume-session-id", ) + app1._project_root = Path("/tmp/repo") + app1._existing_worktrees = [] app2 = SessionApp.__new__(SessionApp) app2._launch_target = None @@ -2243,6 +2251,7 @@ def test_resume_skips_system_prompt(self) -> None: patch.object(app1, "run"), patch.object(app2, "run"), patch("fujimoto.cli.launch_claude_in_tmux") as mock_launch, + patch("fujimoto.cli.SessionMonitor"), ): main() mock_launch.assert_called_once_with( diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..0bf792c --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,210 @@ +"""Tests for fujimoto.monitor.""" + +from __future__ import annotations + +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +from fujimoto.claude import ClaudeSession, EntryType, SessionState, StopReason +from fujimoto.monitor import ( + SessionMonitor, + _poll_once, + _send_notification, + _state_display, +) + + +def _make_session( + path: Path, + state: SessionState = SessionState.WORKING, + session_id: str = "abc123", +) -> ClaudeSession: + """Create a minimal ClaudeSession for testing.""" + from datetime import datetime, timezone + + return ClaudeSession( + jsonl_path=path / f"{session_id}.jsonl", + session_id=session_id, + state=state, + last_entry_type=EntryType.ASSISTANT, + stop_reason=StopReason.TOOL_USE + if state == SessionState.WAITING_FOR_TOOL_APPROVAL + else None, + cwd=path, + git_branch="main", + last_activity=datetime.now(tz=timezone.utc), + ) + + +class TestStateDisplay: + def test_tool_approval(self) -> None: + assert ( + _state_display(SessionState.WAITING_FOR_TOOL_APPROVAL) + == "Needs tool approval" + ) + + def test_waiting_for_user(self) -> None: + assert _state_display(SessionState.WAITING_FOR_USER) == "Waiting for input" + + def test_other_state(self) -> None: + assert _state_display(SessionState.WORKING) == "working" + + +class TestSendNotification: + @patch("fujimoto.monitor.subprocess.run") + def test_calls_osascript(self, mock_run: MagicMock) -> None: + _send_notification("Test Title", "Test message") + + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert args[0] == "osascript" + assert args[1] == "-e" + assert "Test Title" in args[2] + assert "Test message" in args[2] + assert 'sound name "default"' in args[2] + + +class TestPollOnce: + @patch("fujimoto.monitor.get_sessions_for_path") + @patch("fujimoto.monitor._send_notification") + def test_no_notification_on_initial_same_state( + self, mock_notify: MagicMock, mock_get: MagicMock + ) -> None: + path = Path("/tmp/project") + session = _make_session(path, SessionState.WORKING) + mock_get.return_value = [session] + + snapshot = {str(path): ("abc123", SessionState.WORKING)} + new_snapshot = _poll_once([path], snapshot, None) + + mock_notify.assert_not_called() + assert new_snapshot[str(path)] == ("abc123", SessionState.WORKING) + + @patch("fujimoto.monitor.get_sessions_for_path") + @patch("fujimoto.monitor._send_notification") + def test_notifies_on_tool_approval_transition( + self, mock_notify: MagicMock, mock_get: MagicMock + ) -> None: + path = Path("/tmp/project") + session = _make_session(path, SessionState.WAITING_FOR_TOOL_APPROVAL) + mock_get.return_value = [session] + + snapshot = {str(path): ("abc123", SessionState.WORKING)} + _poll_once([path], snapshot, None) + + mock_notify.assert_called_once() + title, message = mock_notify.call_args[0] + assert "project" in title + assert "tool approval" in message.lower() + + @patch("fujimoto.monitor.get_sessions_for_path") + @patch("fujimoto.monitor._send_notification") + def test_skips_attached_path( + self, mock_notify: MagicMock, mock_get: MagicMock + ) -> None: + path = Path("/tmp/project") + session = _make_session(path, SessionState.WAITING_FOR_TOOL_APPROVAL) + mock_get.return_value = [session] + + snapshot = {str(path): ("abc123", SessionState.WORKING)} + _poll_once([path], snapshot, attached_path=path) + + mock_notify.assert_not_called() + + @patch("fujimoto.monitor.get_sessions_for_path") + @patch("fujimoto.monitor._send_notification") + def test_no_notification_for_non_notify_state( + self, mock_notify: MagicMock, mock_get: MagicMock + ) -> None: + path = Path("/tmp/project") + session = _make_session(path, SessionState.WAITING_FOR_USER) + mock_get.return_value = [session] + + snapshot = {str(path): ("abc123", SessionState.WORKING)} + _poll_once([path], snapshot, None) + + mock_notify.assert_not_called() + + @patch("fujimoto.monitor.get_sessions_for_path") + @patch("fujimoto.monitor._send_notification") + def test_handles_empty_sessions( + self, mock_notify: MagicMock, mock_get: MagicMock + ) -> None: + path = Path("/tmp/project") + mock_get.return_value = [] + + new_snapshot = _poll_once([path], {}, None) + + mock_notify.assert_not_called() + assert str(path) not in new_snapshot + + @patch("fujimoto.monitor.get_sessions_for_path") + @patch("fujimoto.monitor._send_notification") + def test_notifies_on_new_session_in_notify_state( + self, mock_notify: MagicMock, mock_get: MagicMock + ) -> None: + path = Path("/tmp/project") + session = _make_session(path, SessionState.WAITING_FOR_TOOL_APPROVAL) + mock_get.return_value = [session] + + # Empty snapshot — first time seeing this session + _poll_once([path], {}, None) + + mock_notify.assert_called_once() + + +class TestSessionMonitor: + @patch("fujimoto.monitor.get_sessions_for_path") + @patch("fujimoto.monitor._send_notification") + def test_start_and_stop(self, mock_notify: MagicMock, mock_get: MagicMock) -> None: + mock_get.return_value = [] + monitor = SessionMonitor(paths=[Path("/tmp/test")], interval=0.1) + monitor.start() + time.sleep(0.3) + monitor.stop() + + assert monitor._thread is None + + @patch("fujimoto.monitor.get_sessions_for_path") + @patch("fujimoto.monitor._send_notification") + def test_detects_state_change( + self, mock_notify: MagicMock, mock_get: MagicMock + ) -> None: + path = Path("/tmp/project") + + # First call (initial snapshot): working. Subsequent calls: tool approval. + working = _make_session(path, SessionState.WORKING) + approval = _make_session(path, SessionState.WAITING_FOR_TOOL_APPROVAL) + # After the explicit sequence, return approval forever + call_count = 0 + sequence = [ + [working], # initial snapshot build + [approval], # first poll tick + ] + + def get_side_effect(*args: object, **kwargs: object) -> list[ClaudeSession]: + nonlocal call_count + if call_count < len(sequence): + result = sequence[call_count] + call_count += 1 + return result + return [approval] + + mock_get.side_effect = get_side_effect + + monitor = SessionMonitor(paths=[path], interval=0.1) + monitor.start() + time.sleep(0.4) + monitor.stop() + + mock_notify.assert_called_once() + + def test_start_is_idempotent(self) -> None: + with patch("fujimoto.monitor.get_sessions_for_path", return_value=[]): + monitor = SessionMonitor(paths=[], interval=0.1) + monitor.start() + thread = monitor._thread + monitor.start() # second start should be a no-op + assert monitor._thread is thread + monitor.stop() From cf9a3d4f7e1116cdb1390141c97f140b79de0887 Mon Sep 17 00:00:00 2001 From: Jon Grace-Cox <30441316+jongracecox@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:10:52 -0500 Subject: [PATCH 2/3] feat: use terminal-notifier with tool context in notifications Switch from osascript to terminal-notifier for proper macOS notification center integration. Include Claude's explanation text in notifications so users know what tool approval is needed. Add skip option to installer prompt and FUJIMOTO_SKIP_NOTIFICATIONS env var to disable notifications entirely. Guard monitor startup so it only runs when terminal-notifier is available. --- src/fujimoto/claude/log_parser.py | 58 +++++++++++++ src/fujimoto/cli.py | 81 ++++++++++++++---- src/fujimoto/monitor.py | 63 ++++++++++++-- tests/test_cli.py | 136 +++++++++++++++++++++++++++++- tests/test_monitor.py | 95 +++++++++++++++++++-- 5 files changed, 398 insertions(+), 35 deletions(-) diff --git a/src/fujimoto/claude/log_parser.py b/src/fujimoto/claude/log_parser.py index 2e35315..5eb2f2a 100644 --- a/src/fujimoto/claude/log_parser.py +++ b/src/fujimoto/claude/log_parser.py @@ -81,6 +81,7 @@ class ClaudeSession: cwd: Path git_branch: str | None last_activity: datetime + pending_tool_summary: str | None = None @property def is_active(self) -> bool: @@ -91,6 +92,45 @@ def is_active(self) -> bool: ) +def _extract_tool_summary( + entry: dict, + preceding_text: str | None, +) -> str | None: + """Extract a human-readable summary of the pending tool use from an assistant entry. + + Uses the preceding assistant text (Claude's explanation) as the primary summary, + falling back to a tool name + detail description. + """ + if preceding_text: + if len(preceding_text) > 120: + preceding_text = preceding_text[:117] + "..." + return preceding_text + + # Fall back to tool name + detail if no preceding text + content = entry.get("message", {}).get("content", []) + if not isinstance(content, list): + return None + + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + name = block.get("name", "Unknown") + inp = block.get("input", {}) + + if name == "Bash" and "command" in inp: + cmd = inp["command"] + if len(cmd) > 80: + cmd = cmd[:77] + "..." + return f"Bash: {cmd}" + if name in ("Edit", "Write") and "file_path" in inp: + return f"{name}: {Path(inp['file_path']).name}" + if name == "Read" and "file_path" in inp: + return f"Read: {Path(inp['file_path']).name}" + return name + + return None + + def encode_project_path(path: Path) -> str: """Encode a project path for use as a Claude projects directory name. @@ -135,6 +175,7 @@ def parse_session(jsonl_path: Path) -> ClaudeSession: last_any: dict | None = None session_ended = False tool_result_after_last_tool_use = False + last_assistant_text: str | None = None for line in text.splitlines(): line = line.strip() @@ -164,6 +205,16 @@ def parse_session(jsonl_path: Path) -> ClaudeSession: if entry_type in (EntryType.ASSISTANT, EntryType.USER): last_meaningful = entry + # Track assistant text for tool approval context + if entry_type == EntryType.ASSISTANT: + content = entry.get("message", {}).get("content", []) + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text_val = block.get("text", "").strip() + if text_val: + last_assistant_text = text_val + # Track whether a tool_result follows the most recent tool_use if entry_type == EntryType.ASSISTANT: raw_stop = entry.get("message", {}).get("stop_reason") @@ -222,6 +273,12 @@ def parse_session(jsonl_path: Path) -> ClaudeSession: # Last meaningful entry is USER → working state = SessionState.WORKING + tool_summary = ( + _extract_tool_summary(last_meaningful, last_assistant_text) + if state == SessionState.WAITING_FOR_TOOL_APPROVAL + else None + ) + return ClaudeSession( jsonl_path=jsonl_path, session_id=session_id, @@ -231,6 +288,7 @@ def parse_session(jsonl_path: Path) -> ClaudeSession: cwd=Path(last_meaningful.get("cwd", "/")), git_branch=last_meaningful.get("gitBranch"), last_activity=_parse_timestamp(last_meaningful.get("timestamp", "")), + pending_tool_summary=tool_summary, ) diff --git a/src/fujimoto/cli.py b/src/fujimoto/cli.py index 6e9feb0..8e53cd0 100644 --- a/src/fujimoto/cli.py +++ b/src/fujimoto/cli.py @@ -50,7 +50,13 @@ push_branch, remove_worktree, ) -from fujimoto.monitor import SessionMonitor +from fujimoto.monitor import ( + NotifierError, + SessionMonitor, + install_notifier, + is_notifier_installed, + notifications_skipped, +) from fujimoto.terminal import open_terminal from fujimoto.vscode import open_vscode from fujimoto.tmux import ( @@ -388,6 +394,9 @@ async def on_mount(self) -> None: if not is_tmux_installed(): await self._show_tmux_install() return + if not notifications_skipped() and not is_notifier_installed(): + await self._show_notifier_install() + return self._init_git_info() await self._show_home() except (ConfigError, GitError) as e: @@ -450,6 +459,27 @@ async def _show_tmux_install(self) -> None: ) self.query_one("#tmux-install-list").focus() + async def _show_notifier_install(self) -> None: + await self._clear_main() + main = self.query_one("#main") + await main.mount( + Container( + Label("terminal-notifier is not installed", classes="form-label"), + Static( + "terminal-notifier is required for background session notifications." + ), + Static(""), + ListView( + ListItem(Label("Install with brew"), id="install-notifier"), + ListItem(Label("Skip"), id="skip-notifier"), + ListItem(Label("Quit"), id="quit-app"), + id="notifier-install-list", + ), + id="conflict-panel", + ) + ) + self.query_one("#notifier-install-list").focus() + # -- Home screen -- async def _show_home(self) -> None: @@ -1448,6 +1478,24 @@ async def on_tmux_install_selected(self, event: ListView.Selected) -> None: else: self.exit() + @on(ListView.Selected, "#notifier-install-list") + async def on_notifier_install_selected(self, event: ListView.Selected) -> None: + if event.item.id == "install-notifier": + try: + install_notifier() + self._init_git_info() + await self._show_home() + except (NotifierError, ConfigError, GitError) as e: + await self._show_error(str(e)) + elif event.item.id == "skip-notifier": + try: + self._init_git_info() + await self._show_home() + except (ConfigError, GitError) as e: + await self._show_error(str(e)) + else: + self.exit() + @on(Input.Submitted, "#direct-title-input") async def on_direct_title_submitted(self, event: Input.Submitted) -> None: value = event.value.strip() @@ -1665,19 +1713,21 @@ def main() -> None: else _build_system_prompt(session_type, project_name, working_dir) ) - # Collect all paths to monitor for background notifications - monitor_paths: list[Path] = [] - if app._project_root: - monitor_paths.append(app._project_root) - monitor_paths.extend(app._existing_worktrees) - if working_dir not in monitor_paths: - monitor_paths.append(working_dir) - - monitor = SessionMonitor( - paths=monitor_paths, - attached_path=working_dir, - ) - monitor.start() + # Start background monitor if notifications are available + monitor: SessionMonitor | None = None + if is_notifier_installed(): + monitor_paths: list[Path] = [] + if app._project_root: + monitor_paths.append(app._project_root) + monitor_paths.extend(app._existing_worktrees) + if working_dir not in monitor_paths: + monitor_paths.append(working_dir) + monitor = SessionMonitor( + paths=monitor_paths, + attached_path=working_dir, + ) + monitor.start() + try: launch_claude_in_tmux( project_name, @@ -1687,7 +1737,8 @@ def main() -> None: resume_session_id=resume_id, ) finally: - monitor.stop() + if monitor: + monitor.stop() else: break set_terminal_title("") diff --git a/src/fujimoto/monitor.py b/src/fujimoto/monitor.py index 383e420..eb2e003 100644 --- a/src/fujimoto/monitor.py +++ b/src/fujimoto/monitor.py @@ -6,6 +6,8 @@ from __future__ import annotations +import os +import shutil import subprocess import threading from pathlib import Path @@ -16,15 +18,57 @@ get_sessions_for_path, ) +ICON_WIZARD = "\U0001f9d9\U0001f3fd\u200d\u2642\ufe0f" -def _send_notification(title: str, message: str) -> None: - """Send a macOS notification via osascript.""" - script = ( - f'display notification "{message}" with title "{title}" sound name "default"' + +class NotifierError(Exception): + pass + + +def is_notifier_installed() -> bool: + """Check if terminal-notifier is on PATH.""" + return shutil.which("terminal-notifier") is not None + + +def notifications_skipped() -> bool: + """Check if notifications are disabled via environment variable.""" + return os.environ.get("FUJIMOTO_SKIP_NOTIFICATIONS", "").lower() in ( + "1", + "true", + "yes", ) - subprocess.run( - ["osascript", "-e", script], - capture_output=True, + + +def install_notifier() -> None: + """Install terminal-notifier via brew. Raises NotifierError on failure.""" + if not shutil.which("brew"): + raise NotifierError( + "brew is not installed. Install terminal-notifier manually." + ) + result = subprocess.run(["brew", "install", "terminal-notifier"]) + if result.returncode != 0: + raise NotifierError("Failed to install terminal-notifier via brew") + if not shutil.which("terminal-notifier"): + raise NotifierError("terminal-notifier was installed but not found on PATH") + + +def _send_notification(title: str, message: str) -> None: + """Send a macOS notification via terminal-notifier. + + Launched via ``Popen`` so it doesn't block the monitor thread. + """ + subprocess.Popen( + [ + "terminal-notifier", + "-title", + f"{ICON_WIZARD} {title}", + "-message", + message, + "-sound", + "default", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) @@ -73,9 +117,12 @@ def _poll_once( # State changed — check if it's interesting if latest.state in _NOTIFY_STATES: session_label = path.name + message = _state_display(latest.state) + if latest.pending_tool_summary: + message = f"{message}\n{latest.pending_tool_summary}" _send_notification( f"fujimoto — {session_label}", - _state_display(latest.state), + message, ) return new_snapshot diff --git a/tests/test_cli.py b/tests/test_cli.py index 61a61ca..6740750 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -22,6 +22,7 @@ ) from fujimoto.config import ConfigError from fujimoto.git import GitError +from fujimoto.monitor import NotifierError from fujimoto.tmux import TmuxError @@ -55,6 +56,7 @@ def _ctx(): with ( patch("fujimoto.cli.is_tmux_installed", return_value=True), + patch("fujimoto.cli.is_notifier_installed", return_value=True), patch("fujimoto.cli.get_project_name", return_value=project), patch( "fujimoto.cli.get_repo_root", @@ -160,7 +162,7 @@ def test_launches_tmux_then_loops_back(self) -> None: patch("fujimoto.cli.launch_claude_in_tmux") as mock_launch, patch("fujimoto.cli._build_system_prompt", return_value="test") as mock_sp, patch("fujimoto.cli._session_terminal_title", return_value="test-title"), - patch("fujimoto.cli.SessionMonitor"), + patch("fujimoto.cli.is_notifier_installed", return_value=False), ): main() mock_sp.assert_called_once_with("worktree", "proj", Path("/tmp/test")) @@ -207,7 +209,7 @@ def test_launches_with_tmux_name(self) -> None: patch("fujimoto.cli.launch_claude_in_tmux") as mock_launch, patch("fujimoto.cli._build_system_prompt", return_value="test"), patch("fujimoto.cli._session_terminal_title", return_value="test-title"), - patch("fujimoto.cli.SessionMonitor"), + patch("fujimoto.cli.is_notifier_installed", return_value=False), ): main() mock_launch.assert_called_once_with( @@ -1440,6 +1442,7 @@ class TestSessionAppErrors: async def test_shows_error_on_git_failure(self) -> None: with ( patch("fujimoto.cli.is_tmux_installed", return_value=True), + patch("fujimoto.cli.is_notifier_installed", return_value=True), patch( "fujimoto.cli.get_project_name", side_effect=GitError("not a repo"), @@ -1454,6 +1457,7 @@ async def test_shows_error_on_git_failure(self) -> None: async def test_shows_error_on_config_error(self) -> None: with ( patch("fujimoto.cli.is_tmux_installed", return_value=True), + patch("fujimoto.cli.is_notifier_installed", return_value=True), patch( "fujimoto.cli.get_project_name", side_effect=ConfigError("env not set"), @@ -1582,6 +1586,132 @@ async def test_install_failure_shows_error(self) -> None: await pilot.pause() +class TestSessionAppNotifierInstall: + @pytest.mark.asyncio + async def test_shows_install_prompt_when_missing(self) -> None: + with ( + patch("fujimoto.cli.is_tmux_installed", return_value=True), + patch("fujimoto.cli.is_notifier_installed", return_value=False), + ): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + assert len(app.query("#notifier-install-list")) > 0 + + @pytest.mark.asyncio + async def test_quit_from_install_prompt(self) -> None: + with ( + patch("fujimoto.cli.is_tmux_installed", return_value=True), + patch("fujimoto.cli.is_notifier_installed", return_value=False), + ): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + lst = app.query_one("#notifier-install-list", ListView) + lst.index = 2 # "Quit" is the 3rd item + await pilot.press("enter") + await pilot.pause() + + @pytest.mark.asyncio + async def test_skip_proceeds_to_home(self) -> None: + with ( + patch("fujimoto.cli.is_tmux_installed", return_value=True), + patch("fujimoto.cli.is_notifier_installed", return_value=False), + patch("fujimoto.cli.get_project_name", return_value="proj"), + patch("fujimoto.cli.get_repo_root", return_value=Path("/fake/repo")), + patch("fujimoto.cli.get_current_branch", return_value="main"), + patch("fujimoto.cli.get_default_branch", return_value="main"), + patch("fujimoto.cli.list_project_sessions", return_value=[]), + patch( + "fujimoto.cli.get_project_worktrees_dir", + return_value=Path("/nonexistent"), + ), + patch("fujimoto.cli.get_sessions_for_path", return_value=[]), + ): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + lst = app.query_one("#notifier-install-list", ListView) + lst.index = 1 # "Skip" is the 2nd item + await pilot.press("enter") + await pilot.pause() + assert len(app.query("#home-list")) > 0 + + @pytest.mark.asyncio + async def test_skipped_via_env_var(self) -> None: + with ( + patch("fujimoto.cli.is_tmux_installed", return_value=True), + patch("fujimoto.cli.is_notifier_installed", return_value=False), + patch("fujimoto.cli.notifications_skipped", return_value=True), + patch("fujimoto.cli.get_project_name", return_value="proj"), + patch("fujimoto.cli.get_repo_root", return_value=Path("/fake/repo")), + patch("fujimoto.cli.get_current_branch", return_value="main"), + patch("fujimoto.cli.get_default_branch", return_value="main"), + patch("fujimoto.cli.list_project_sessions", return_value=[]), + patch( + "fujimoto.cli.get_project_worktrees_dir", + return_value=Path("/nonexistent"), + ), + patch("fujimoto.cli.get_sessions_for_path", return_value=[]), + ): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + # Should go straight to home, no install prompt + assert len(app.query("#notifier-install-list")) == 0 + assert len(app.query("#home-list")) > 0 + + @pytest.mark.asyncio + async def test_install_success_shows_home(self) -> None: + installed = False + + def fake_is_installed() -> bool: + return installed + + with ( + patch("fujimoto.cli.is_tmux_installed", return_value=True), + patch("fujimoto.cli.is_notifier_installed", side_effect=fake_is_installed), + patch("fujimoto.cli.install_notifier") as mock_install, + patch("fujimoto.cli.get_project_name", return_value="proj"), + patch("fujimoto.cli.get_repo_root", return_value=Path("/fake/repo")), + patch("fujimoto.cli.get_current_branch", return_value="main"), + patch("fujimoto.cli.get_default_branch", return_value="main"), + patch("fujimoto.cli.list_project_sessions", return_value=[]), + patch( + "fujimoto.cli.get_project_worktrees_dir", + return_value=Path("/nonexistent"), + ), + patch("fujimoto.cli.get_sessions_for_path", return_value=[]), + ): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + + def do_install() -> None: + nonlocal installed + installed = True + + mock_install.side_effect = do_install + await pilot.press("enter") # Select "Install with brew" + await pilot.pause() + mock_install.assert_called_once() + + @pytest.mark.asyncio + async def test_install_failure_shows_error(self) -> None: + with ( + patch("fujimoto.cli.is_tmux_installed", return_value=True), + patch("fujimoto.cli.is_notifier_installed", return_value=False), + patch( + "fujimoto.cli.install_notifier", + side_effect=NotifierError("brew failed"), + ), + ): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.press("enter") # Select "Install with brew" + await pilot.pause() + + class TestSessionAppProjectSwitch: @pytest.mark.asyncio async def test_switch_project_shown_when_projects_available( @@ -2251,7 +2381,7 @@ def test_resume_skips_system_prompt(self) -> None: patch.object(app1, "run"), patch.object(app2, "run"), patch("fujimoto.cli.launch_claude_in_tmux") as mock_launch, - patch("fujimoto.cli.SessionMonitor"), + patch("fujimoto.cli.is_notifier_installed", return_value=False), ): main() mock_launch.assert_called_once_with( diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 0bf792c..c273626 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -6,12 +6,18 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import pytest + from fujimoto.claude import ClaudeSession, EntryType, SessionState, StopReason from fujimoto.monitor import ( + NotifierError, SessionMonitor, _poll_once, _send_notification, _state_display, + install_notifier, + is_notifier_installed, + notifications_skipped, ) @@ -37,6 +43,77 @@ def _make_session( ) +class TestIsNotifierInstalled: + @patch( + "fujimoto.monitor.shutil.which", return_value="/usr/local/bin/terminal-notifier" + ) + def test_installed(self, _mock: object) -> None: + assert is_notifier_installed() is True + + @patch("fujimoto.monitor.shutil.which", return_value=None) + def test_not_installed(self, _mock: object) -> None: + assert is_notifier_installed() is False + + +class TestInstallNotifier: + @patch( + "fujimoto.monitor.shutil.which", + side_effect=["/usr/local/bin/brew", "/usr/local/bin/terminal-notifier"], + ) + @patch("fujimoto.monitor.subprocess.run") + def test_successful_install(self, mock_run: MagicMock, _which: object) -> None: + mock_run.return_value = MagicMock(returncode=0) + install_notifier() + mock_run.assert_called_once_with(["brew", "install", "terminal-notifier"]) + + @patch("fujimoto.monitor.shutil.which", return_value=None) + def test_raises_without_brew(self, _mock: object) -> None: + with pytest.raises(NotifierError, match="brew is not installed"): + install_notifier() + + @patch("fujimoto.monitor.shutil.which", return_value="/usr/local/bin/brew") + @patch("fujimoto.monitor.subprocess.run") + def test_raises_on_brew_failure(self, mock_run: MagicMock, _which: object) -> None: + mock_run.return_value = MagicMock(returncode=1) + with pytest.raises(NotifierError, match="Failed to install"): + install_notifier() + + @patch("fujimoto.monitor.shutil.which", side_effect=["/usr/local/bin/brew", None]) + @patch("fujimoto.monitor.subprocess.run") + def test_raises_when_not_on_path_after_install( + self, mock_run: MagicMock, _which: object + ) -> None: + mock_run.return_value = MagicMock(returncode=0) + with pytest.raises(NotifierError, match="not found on PATH"): + install_notifier() + + +class TestNotificationsSkipped: + @patch.dict("os.environ", {"FUJIMOTO_SKIP_NOTIFICATIONS": "1"}) + def test_skipped_with_1(self) -> None: + assert notifications_skipped() is True + + @patch.dict("os.environ", {"FUJIMOTO_SKIP_NOTIFICATIONS": "true"}) + def test_skipped_with_true(self) -> None: + assert notifications_skipped() is True + + @patch.dict("os.environ", {"FUJIMOTO_SKIP_NOTIFICATIONS": "yes"}) + def test_skipped_with_yes(self) -> None: + assert notifications_skipped() is True + + @patch.dict("os.environ", {"FUJIMOTO_SKIP_NOTIFICATIONS": "TRUE"}) + def test_skipped_case_insensitive(self) -> None: + assert notifications_skipped() is True + + @patch.dict("os.environ", {"FUJIMOTO_SKIP_NOTIFICATIONS": ""}) + def test_not_skipped_with_empty(self) -> None: + assert notifications_skipped() is False + + @patch.dict("os.environ", {}, clear=True) + def test_not_skipped_when_unset(self) -> None: + assert notifications_skipped() is False + + class TestStateDisplay: def test_tool_approval(self) -> None: assert ( @@ -52,17 +129,17 @@ def test_other_state(self) -> None: class TestSendNotification: - @patch("fujimoto.monitor.subprocess.run") - def test_calls_osascript(self, mock_run: MagicMock) -> None: + @patch("fujimoto.monitor.subprocess.Popen") + def test_calls_terminal_notifier(self, mock_popen: MagicMock) -> None: _send_notification("Test Title", "Test message") - mock_run.assert_called_once() - args = mock_run.call_args[0][0] - assert args[0] == "osascript" - assert args[1] == "-e" - assert "Test Title" in args[2] - assert "Test message" in args[2] - assert 'sound name "default"' in args[2] + mock_popen.assert_called_once() + args = mock_popen.call_args[0][0] + assert args[0] == "terminal-notifier" + assert "-title" in args + assert "Test Title" in args[args.index("-title") + 1] + assert "-message" in args + assert args[args.index("-message") + 1] == "Test message" class TestPollOnce: From 3acc6c388e8c1b7380bd8f4a28cda76c52b157da Mon Sep 17 00:00:00 2001 From: Jon Grace-Cox <30441316+jongracecox@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:34:25 -0500 Subject: [PATCH 3/3] docs: update CLAUDE.md for terminal-notifier and skip notifications --- CLAUDE.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2ccee3..cb8afe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,12 +16,14 @@ uv tool install --force --reinstall . # Install globally (re-run after ```sh export FUJIMOTO_WORKTREE_ROOT=~/git/worktrees/ # Where worktrees are created export FUJIMOTO_GIT_ROOT=~/git/ # Optional: enables project switching +export FUJIMOTO_SKIP_NOTIFICATIONS=1 # Optional: disable background notifications ``` ## Prerequisites - Python 3.11+ - tmux (auto-installs via brew if missing) +- terminal-notifier (auto-installs via brew if missing, skippable) - git ## Project Structure @@ -103,11 +105,15 @@ src/fujimoto/ **`monitor.py`** — Background session monitor with macOS notifications: - `SessionMonitor` — background thread that polls Claude JSONL logs during tmux attachment - `_poll_once(paths, snapshot, attached_path)` — single poll cycle: detects state transitions, sends notifications -- `_send_notification(title, message)` — macOS notification via `osascript` +- `_send_notification(title, message)` — macOS notification via `terminal-notifier` - `_state_display(state)` — human-readable label for session states +- `is_notifier_installed()` / `install_notifier()` — detection and brew install of `terminal-notifier` +- `notifications_skipped()` — checks `FUJIMOTO_SKIP_NOTIFICATIONS` env var +- `NotifierError` — raised on install failure - Monitors all project sessions while user is attached to a tmux session - Skips the currently-attached session (user can see it directly) - Notifies on `WAITING_FOR_TOOL_APPROVAL` transitions via macOS Notification Center +- Notifications include Claude's explanation text for context **`terminal.py`** — Open native terminal windows in a session's directory: - `open_terminal(directory)` — opens iTerm2 if installed, otherwise Terminal.app. Raises `OSError` on non-macOS. @@ -178,7 +184,7 @@ Three custom exception types, all caught in `main()`: - **Per-session tmux config**: Prefix remapped to Ctrl+A, status bar with shortcut hints — all set via `tmux set-option -t` so the user's global config is untouched. - **Global install via `uv tool`**: Requires `--force --reinstall` to rebuild the wheel from source. Plain `--force` reuses cached builds. - **Session metadata**: `.fujimoto-meta.json` stored in worktree directory records the base branch for cherry-pick targeting. -- **Background session monitor**: While the user is attached to a tmux session, a `SessionMonitor` daemon thread polls all project session logs every 3 seconds. When a background session transitions to `WAITING_FOR_TOOL_APPROVAL`, a macOS notification is sent via `osascript`. The monitor builds an initial snapshot without notifying to avoid false alerts on startup, and skips the currently-attached session. The thread is started before `tmux attach` and stopped (via `Event.set()`) after detach. +- **Background session monitor**: While the user is attached to a tmux session, a `SessionMonitor` daemon thread polls all project session logs every 3 seconds. When a background session transitions to `WAITING_FOR_TOOL_APPROVAL`, a macOS notification is sent via `terminal-notifier` with Claude's explanation text for context. The monitor builds an initial snapshot without notifying to avoid false alerts on startup, and skips the currently-attached session. The thread is started before `tmux attach` and stopped (via `Event.set()`) after detach. Only runs when `terminal-notifier` is installed; skippable via `FUJIMOTO_SKIP_NOTIFICATIONS=1`. - **Background PR creation**: Uses `claude -p --allowedTools "Bash(git:*) Bash(gh:*)"` in a tmux session for unattended PR creation. - **Claude session integration**: The home screen fetches Claude session state from `~/.claude/projects/` JSONL logs via the log parser. Session states: 👀 awaiting input (`WAITING_FOR_USER`), 🛡️ approve tool (`WAITING_FOR_TOOL_APPROVAL`), ⚙ working (`WORKING`), 💤 idle (`IDLE`), no indicator (`UNKNOWN`). State logic: `last-prompt` marker → `IDLE` (session ended). For assistant entries: `stop_reason=tool_use` without a following `tool_result` → `WAITING_FOR_TOOL_APPROVAL` (pending user approval), `stop_reason=tool_use` with `tool_result` → `WORKING`, any other stop reason or no stop reason → `WAITING_FOR_USER`. Last entry is user → `WORKING`. Previous Claude sessions (from the project root, capped at 5) appear as resumable items. Resuming launches `claude --resume SESSION_ID` in a new tmux session. The latest Claude session per path is "claimed" by the corresponding tmux/worktree item to avoid duplication. - **Live polling**: The home screen uses `set_interval(3s)` to poll Claude JSONL logs for state changes. When a session's state changes, labels are updated in-place via `label.update()` — the screen is never cleared or rebuilt, which avoids blank-screen flicker. A snapshot dict (`path → (session_id, state)`) is compared each tick to detect changes efficiently. The timer is stopped when navigating away (`_clear_main` cancels it) and restarted by `_show_home`.