Repository navigation
feat(sutando-app): notify on watcher death (CLI must restart) - #444
Conversation
Chi: 'can the app remind the CLI about watcher'. Going one better — the app now auto-restarts the watcher so no reminder is needed. New 30s Timer in the AppDelegate runs pgrep -f watch-tasks; if empty AND >60s since last intervention, nohup-starts src/watch-tasks.sh in a detached shell. Throttle prevents restart-spam if the watcher is crashing on spawn. Fires a macOS notification on each auto-restart so Chi knows it happened.
Chi: 'auto-start doesn't work in this way. Need to send a msg to the terminal CLI.' — correct. A detached nohup restart means the watcher's output routes to /dev/null, not the Claude Code task-notification pipe. So the CLI never sees new tasks via the fast path. Replaced the auto-restart with a context-drop write: the app writes a timestamped instruction to context-drop.txt when the watcher is down 2+ minutes. The proactive-loop pass reads context-drop.txt at Step 1 on every wake — Claude sees the message, restarts the watcher via its own run_in_background Bash (routing stdout back through task-notification), and clears the drop. Alert throttle bumped to 120s to avoid flooding context-drop if the CLI is slow to act. macOS notification stays so Chi sees it too.
liususan091219
left a comment
There was a problem hiding this comment.
Clean fix for the watcher-strand issue raised in voice this morning. 30s Timer + pgrep check + 60s throttle keeps it defensive without hammering on repeated failures. Dependencies (workspace, notify, logToFile) all already defined in AppDelegate. Minor body/code drift: body says 'dead AND pending tasks' but code restarts on dead regardless — harmless (starting a watcher with nothing to watch is a no-op), just noting.
— Lucy (Mac Studio bot)
Chi: 'watcher must be started by cli as background task'. The app's auto-start (nohup) AND context-drop approaches both ineffective — only a CLI-started run_in_background watcher has its stdout routed to Claude Code's task-notification pipe. Everything else produces a zombie watcher that can't fulfill its purpose. Reverted to pure notification: 30s check, if pgrep empty + 120s throttle expired, fire macOS notification 'Task watcher is down — prompt the CLI to restart it'. Chi sees the notification, types 'watcher' in the Claude Code prompt, CLI restarts it via run_in_background. Manual loop, but the watcher output is usable.
Chi's pick of the two mechanisms I proposed: tmux, session name sutando-core. New scripts/start-cli.sh: tmux new-session -A -s sutando-core 'claude'. Chi launches Claude Code via this wrapper (one-time setup, uses -A so re-running attaches). Sutando.app's checkWatcher() now: 1. pgrep -f watch-tasks. If alive, return. 2. Throttle check (120s). 3. tmuxSendKeys(session: 'sutando-core', keys: 'watcher') — sends the word 'watcher' + Enter to the pane. Claude Code sees it as stdin, parses as prompt, restarts the watcher via its own run_in_background Bash so stdout routes correctly. 4. If tmux session not found, fall back to macOS notification only. tmux binary autodetected at /opt/homebrew/bin/tmux (Apple Silicon) or /usr/local/bin/tmux (Intel).
…eparate wrapper Chi's pushback: the scripts/start-cli.sh wrapper from the previous commit was wrong — it skipped all of startup.sh's pre-work (services, env, etc.) and launched bare claude. Correct approach: integrate the tmux wrapping INTO startup.sh so the existing 'bash src/startup.sh' interface stays identical. User sees Claude Code exactly as before; under the hood it's now running inside a tmux session named sutando-core. Uses tmux new-session -A -s sutando-core (create-or-attach, matches existing 'already running' semantics). Falls back to bare exec claude if tmux is missing, with a warning that the watcher-auto-restart path won't work. Also deleted scripts/start-cli.sh — redundant.
Chi: 'does the app read the real state first? and remind about the watcher only when idle?' Yes, now it does. New cliIsWorking() reads the sutando-core tmux pane PID via tmux list-panes -t sutando-core -F '#{pane_pid}', then walks pgrep -P twice (pane shell -> claude -> claude's Bash/tool children). If the pane shell has ANY non-claude descendant running, claude is actively executing a tool and will handle the watcher restart via its normal proactive-loop Step 9 — no keystroke needed. If only claude itself is running (idle, waiting on stdin), send the 'watcher' keystroke. Integrated into checkWatcher BEFORE the throttle + alert path. Silent log entry when the alert is skipped because CLI is busy.
sonichi
left a comment
There was a problem hiding this comment.
Cold-review from Sutando-Mini. Two concerns:
1. Description/implementation mismatch. The PR body says:
Does NOT auto-restart the watcher (reverted from v1/v2 of this PR).
But the committed code at AppDelegate.checkWatcher() calls tmuxSendKeys(session: "sutando-core", keys: "watcher") — which IS an auto-restart path (it types watcher into the CLI prompt, which Claude reads as a user message and restarts via run_in_background: true per CLAUDE.md). Only the fallback branch (tmux-not-found) just notifies. Either the description is stale or the code went past the stated v3 scope. Worth reconciling before merge so reviewers know what they're approving.
2. send-keys "watcher" Enter during user edit. If Chi is half-way through typing something in the CLI prompt when the 30s timer fires with cliIsWorking() == false, tmux will append watcher\n to his input buffer — submitting whatever he was writing + the word watcher. Low-frequency because cliIsWorking() catches active claude subprocesses, but a pane with claude at an idle prompt is indistinguishable from "waiting on Chi's next typed message". Possible mitigation: check tmux display-message -p '#{pane_in_mode}' or the pane's cursor-at-prompt state before sending, or send a distinguishing prefix.
Not blocking — the tmux-wrapping of sutando-core is a genuinely useful separate win independent of auto-restart. Just worth noting the mismatch before approval.
Dry-run test caught a real bug: tmux's default socket is $TMPDIR/tmux-$(id -u)/default. The shell (where startup.sh runs) has one TMPDIR; the app launched via macOS 'open' has a different sandboxed TMPDIR. Same UID but different socket paths → app's 'tmux has-session' fails even when the session is alive shell-side → watcher-auto-restart falls back to notification instead of sending 'watcher'. Fix: explicit -S /tmp/sutando-tmux.sock on every tmux invocation on both sides — startup.sh's exec tmux new-session AND all three Swift call sites (cliIsWorking list-panes, tmuxSendKeys has-session + send-keys). Re-ran dry-run after the fix: created tmux session with cat > /tmp/sutando-core-test-stdin, killed watcher, 35s later the file contains literal 'watcher\n' and app log reads 'sent watcher to sutando-core tmux'. End-to-end validated.
…in children) Chi picked option 1 after the dry-run exposed that the naive 'any grandchild = working' logic would false-positive on any real Claude Code setup. Chi's live session has 6 long-lived claude children (sourcekit-lsp, caffeinate x2, conversation-server, discord plugin, playwright-mcp). Without age filter, cliIsWorking would return true permanently → every watcher-alert would be skipped → mechanism broken. Tmux runs the command directly (no shell layer), so pane_pid IS claude. The signal 'claude has a tool call in flight' is: claude has a DIRECT child whose etime < 60s. Older children are background services. Implementation: processAgeSeconds(pid) parses ps -o etime= format ([DD-]HH:MM:SS | [HH:]MM:SS | MM:SS) to seconds. Removed the grandchildren walk — not needed since tmux launches claude directly. Tested both paths end-to-end: TEST A: pane has fresh sleep child (age < 60s) → cliIsWorking=true → 'watcher dead; CLI is working — skipping alert' in app log → stdin file EMPTY (no keystroke sent). TEST B: pane is bare cat (no children) → cliIsWorking=false → 'watcher dead; tmux send-keys' in log → stdin file contains 'watcher'. Both gates + throttle validated.
Sutando.app's watcher-auto-restart (PR #444) depends on a tmux-wrapped CLI pane to send `watcher` keystrokes when the task-watcher dies. If tmux is missing on a first-run machine, startup.sh falls back to a bare `exec claude` — functional, but silently drops the auto-restart feature. This adds a 5-line guard: if tmux is missing and Homebrew is available, install tmux before wrapping the CLI. If brew is also missing, keep the existing fallback behavior (warn + bare exec). Tested: `bash -n src/startup.sh` clean. No behavioral change when tmux is already present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(startup): auto-install tmux via Homebrew when missing Sutando.app's watcher-auto-restart (PR #444) depends on a tmux-wrapped CLI pane to send `watcher` keystrokes when the task-watcher dies. If tmux is missing on a first-run machine, startup.sh falls back to a bare `exec claude` — functional, but silently drops the auto-restart feature. This adds a 5-line guard: if tmux is missing and Homebrew is available, install tmux before wrapping the CLI. If brew is also missing, keep the existing fallback behavior (warn + bare exec). Tested: `bash -n src/startup.sh` clean. No behavioral change when tmux is already present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(startup): add ~30s hint to tmux install echo MacBook's review nit on PR #453: the brew install takes ~30s on first run and `| tail -3` hides progress during that window. A single word in the echo signals to the user that the pause is expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The startup script only compiled Sutando.app when the binary was missing, so after PRs like #443 and #444 added new features, the stale Apr 6 binary kept running — hotkeys and menu-bar states were 12 days behind HEAD. Check main.swift mtime vs the binary. If source is newer, rebuild and pkill the stale instance so the fresh binary takes over on the next launch line. Tighten pgrep to match the binary path specifically, which also fixes a latent false-positive against other "Sutando"-named processes in this repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ry (#459) * fix(startup): auto-rebuild Sutando.app when source is newer than binary The startup script only compiled Sutando.app when the binary was missing, so after PRs like #443 and #444 added new features, the stale Apr 6 binary kept running — hotkeys and menu-bar states were 12 days behind HEAD. Check main.swift mtime vs the binary. If source is newer, rebuild and pkill the stale instance so the fresh binary takes over on the next launch line. Tighten pgrep to match the binary path specifically, which also fixes a latent false-positive against other "Sutando"-named processes in this repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): flag Sutando.app as stale when main.swift is newer Natural complement to the startup.sh auto-rebuild — the user can now see "Sutando.app is behind HEAD" without having to re-run startup.sh. Reuses the existing mark_stale_if_outdated helper which already handles git cross-check for mtime-only bumps (touch, git checkout/pull). Verified: content-change + future mtime trips "♻ sutando-app stale running but code is X min newer than process"; revert returns to ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…i#444) * feat(sutando-app): auto-restart task watcher when dead Chi: 'can the app remind the CLI about watcher'. Going one better — the app now auto-restarts the watcher so no reminder is needed. New 30s Timer in the AppDelegate runs pgrep -f watch-tasks; if empty AND >60s since last intervention, nohup-starts src/watch-tasks.sh in a detached shell. Throttle prevents restart-spam if the watcher is crashing on spawn. Fires a macOS notification on each auto-restart so Chi knows it happened. * fix(sutando-app): alert CLI via context-drop.txt instead of auto-restart Chi: 'auto-start doesn't work in this way. Need to send a msg to the terminal CLI.' — correct. A detached nohup restart means the watcher's output routes to /dev/null, not the Claude Code task-notification pipe. So the CLI never sees new tasks via the fast path. Replaced the auto-restart with a context-drop write: the app writes a timestamped instruction to context-drop.txt when the watcher is down 2+ minutes. The proactive-loop pass reads context-drop.txt at Step 1 on every wake — Claude sees the message, restarts the watcher via its own run_in_background Bash (routing stdout back through task-notification), and clears the drop. Alert throttle bumped to 120s to avoid flooding context-drop if the CLI is slow to act. macOS notification stays so Chi sees it too. * fix(sutando-app): notify-only on watcher death; CLI must restart it Chi: 'watcher must be started by cli as background task'. The app's auto-start (nohup) AND context-drop approaches both ineffective — only a CLI-started run_in_background watcher has its stdout routed to Claude Code's task-notification pipe. Everything else produces a zombie watcher that can't fulfill its purpose. Reverted to pure notification: 30s check, if pgrep empty + 120s throttle expired, fire macOS notification 'Task watcher is down — prompt the CLI to restart it'. Chi sees the notification, types 'watcher' in the Claude Code prompt, CLI restarts it via run_in_background. Manual loop, but the watcher output is usable. * feat(sutando-app): tmux send-keys to sutando-core on watcher death Chi's pick of the two mechanisms I proposed: tmux, session name sutando-core. New scripts/start-cli.sh: tmux new-session -A -s sutando-core 'claude'. Chi launches Claude Code via this wrapper (one-time setup, uses -A so re-running attaches). Sutando.app's checkWatcher() now: 1. pgrep -f watch-tasks. If alive, return. 2. Throttle check (120s). 3. tmuxSendKeys(session: 'sutando-core', keys: 'watcher') — sends the word 'watcher' + Enter to the pane. Claude Code sees it as stdin, parses as prompt, restarts the watcher via its own run_in_background Bash so stdout routes correctly. 4. If tmux session not found, fall back to macOS notification only. tmux binary autodetected at /opt/homebrew/bin/tmux (Apple Silicon) or /usr/local/bin/tmux (Intel). * refactor(startup): wrap claude in tmux inside startup.sh instead of separate wrapper Chi's pushback: the scripts/start-cli.sh wrapper from the previous commit was wrong — it skipped all of startup.sh's pre-work (services, env, etc.) and launched bare claude. Correct approach: integrate the tmux wrapping INTO startup.sh so the existing 'bash src/startup.sh' interface stays identical. User sees Claude Code exactly as before; under the hood it's now running inside a tmux session named sutando-core. Uses tmux new-session -A -s sutando-core (create-or-attach, matches existing 'already running' semantics). Falls back to bare exec claude if tmux is missing, with a warning that the watcher-auto-restart path won't work. Also deleted scripts/start-cli.sh — redundant. * feat(sutando-app): skip watcher alert when CLI is working Chi: 'does the app read the real state first? and remind about the watcher only when idle?' Yes, now it does. New cliIsWorking() reads the sutando-core tmux pane PID via tmux list-panes -t sutando-core -F '#{pane_pid}', then walks pgrep -P twice (pane shell -> claude -> claude's Bash/tool children). If the pane shell has ANY non-claude descendant running, claude is actively executing a tool and will handle the watcher restart via its normal proactive-loop Step 9 — no keystroke needed. If only claude itself is running (idle, waiting on stdin), send the 'watcher' keystroke. Integrated into checkWatcher BEFORE the throttle + alert path. Silent log entry when the alert is skipped because CLI is busy. * fix(sutando-app): shared tmux socket -S /tmp/sutando-tmux.sock Dry-run test caught a real bug: tmux's default socket is $TMPDIR/tmux-$(id -u)/default. The shell (where startup.sh runs) has one TMPDIR; the app launched via macOS 'open' has a different sandboxed TMPDIR. Same UID but different socket paths → app's 'tmux has-session' fails even when the session is alive shell-side → watcher-auto-restart falls back to notification instead of sending 'watcher'. Fix: explicit -S /tmp/sutando-tmux.sock on every tmux invocation on both sides — startup.sh's exec tmux new-session AND all three Swift call sites (cliIsWorking list-panes, tmuxSendKeys has-session + send-keys). Re-ran dry-run after the fix: created tmux session with cat > /tmp/sutando-core-test-stdin, killed watcher, 35s later the file contains literal 'watcher\n' and app log reads 'sent watcher to sutando-core tmux'. End-to-end validated. * fix(sutando-app): age-based cliIsWorking filter (skip long-lived plugin children) Chi picked option 1 after the dry-run exposed that the naive 'any grandchild = working' logic would false-positive on any real Claude Code setup. Chi's live session has 6 long-lived claude children (sourcekit-lsp, caffeinate x2, conversation-server, discord plugin, playwright-mcp). Without age filter, cliIsWorking would return true permanently → every watcher-alert would be skipped → mechanism broken. Tmux runs the command directly (no shell layer), so pane_pid IS claude. The signal 'claude has a tool call in flight' is: claude has a DIRECT child whose etime < 60s. Older children are background services. Implementation: processAgeSeconds(pid) parses ps -o etime= format ([DD-]HH:MM:SS | [HH:]MM:SS | MM:SS) to seconds. Removed the grandchildren walk — not needed since tmux launches claude directly. Tested both paths end-to-end: TEST A: pane has fresh sleep child (age < 60s) → cliIsWorking=true → 'watcher dead; CLI is working — skipping alert' in app log → stdin file EMPTY (no keystroke sent). TEST B: pane is bare cat (no children) → cliIsWorking=false → 'watcher dead; tmux send-keys' in log → stdin file contains 'watcher'. Both gates + throttle validated.
) * chore(startup): auto-install tmux via Homebrew when missing Sutando.app's watcher-auto-restart (PR sonichi#444) depends on a tmux-wrapped CLI pane to send `watcher` keystrokes when the task-watcher dies. If tmux is missing on a first-run machine, startup.sh falls back to a bare `exec claude` — functional, but silently drops the auto-restart feature. This adds a 5-line guard: if tmux is missing and Homebrew is available, install tmux before wrapping the CLI. If brew is also missing, keep the existing fallback behavior (warn + bare exec). Tested: `bash -n src/startup.sh` clean. No behavioral change when tmux is already present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(startup): add ~30s hint to tmux install echo MacBook's review nit on PR sonichi#453: the brew install takes ~30s on first run and `| tail -3` hides progress during that window. A single word in the echo signals to the user that the pause is expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ry (sonichi#459) * fix(startup): auto-rebuild Sutando.app when source is newer than binary The startup script only compiled Sutando.app when the binary was missing, so after PRs like sonichi#443 and sonichi#444 added new features, the stale Apr 6 binary kept running — hotkeys and menu-bar states were 12 days behind HEAD. Check main.swift mtime vs the binary. If source is newer, rebuild and pkill the stale instance so the fresh binary takes over on the next launch line. Tighten pgrep to match the binary path specifically, which also fixes a latent false-positive against other "Sutando"-named processes in this repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(health-check): flag Sutando.app as stale when main.swift is newer Natural complement to the startup.sh auto-rebuild — the user can now see "Sutando.app is behind HEAD" without having to re-run startup.sh. Reuses the existing mark_stale_if_outdated helper which already handles git cross-check for mtime-only bumps (touch, git checkout/pull). Verified: content-change + future mtime trips "♻ sutando-app stale running but code is X min newer than process"; revert returns to ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Chi clarified across three messages (2026-04-18):
The design constraint: the task watcher MUST be started by the Claude Code CLI via its own
run_in_background:trueBash — that's the only way its stdout is routed through Claude Code'stask-notificationpipe so tasks reach Claude in real time. Any externally-started watcher (detachednohup, launchd, etc.) has its output going to/dev/null→ Claude never sees the tasks via the fast path → the watcher is functionally dead even though its process is alive.This PR's role: the app NOTIFIES Chi when the watcher is down. Chi sees the macOS notification, types "watcher" in the Claude Code prompt (or any equivalent), and the CLI restarts it via
run_in_background. Manual loop, but the watcher actually works.Changes
TimerinAppDelegate.setupMenuBar→checkWatcher().checkWatcher(): runspgrep -f watch-tasks; if empty AND >120s since last alert, fires a macOS notification "Task watcher is down — prompt the CLI to restart it".Test plan
.appbundle, verified PID 65614.pgrep -f watch-tasks | xargs kill, wait ≤30s, observe the notification fires. Type "watcher" in Claude Code → it restarts viarun_in_background→ watcher's stdout routes back through task-notification.🤖 Generated with Claude Code