Skip to content

feat(sutando-app): notify on watcher death (CLI must restart) - #444

Merged
sonichi merged 8 commits into
mainfrom
fix/sutando-app-watcher-auto-restart
Apr 18, 2026
Merged

sonichi merged 8 commits into
mainfrom
fix/sutando-app-watcher-auto-restart

Conversation

@sonichi

@sonichi sonichi commented Apr 18, 2026 •

Copy link
Copy Markdown
Owner

Summary

Chi clarified across three messages (2026-04-18):

  1. "can the app remind the CLI about watcher" (original request)
  2. "auto-start doesn't work in this way. Need to send a msg to the terminal CLI" (after I tried nohup auto-restart)
  3. "context drop doesn't work either. need to send a msg to the terminal CLI directly"
  4. "stop the ineffective watcher. watcher must be started by cli as background task" (final direction)

The design constraint: the task watcher MUST be started by the Claude Code CLI via its own run_in_background:true Bash — that's the only way its stdout is routed through Claude Code's task-notification pipe so tasks reach Claude in real time. Any externally-started watcher (detached nohup, 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

  • New 30-second Timer in AppDelegate.setupMenuBar → checkWatcher().
  • checkWatcher(): runs pgrep -f watch-tasks; if empty AND >120s since last alert, fires a macOS notification "Task watcher is down — prompt the CLI to restart it".
  • Throttle (120s) prevents spam if Chi is slow to prompt.
  • Does NOT auto-restart the watcher (reverted from v1/v2 of this PR). Earlier revisions tried nohup + context-drop; both are ineffective because they produce a watcher whose output Claude can't consume.

Test plan

  • Built + deployed to .app bundle, verified PID 65614.
  • Manual verify: pgrep -f watch-tasks | xargs kill, wait ≤30s, observe the notification fires. Type "watcher" in Claude Code → it restarts via run_in_background → watcher's stdout routes back through task-notification.

🤖 Generated with Claude Code

sonichi added 2 commits April 18, 2026 12:09
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 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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)

@sonichi sonichi changed the title feat(sutando-app): auto-restart task watcher when dead feat(sutando-app): alert CLI via context-drop when watcher is down Apr 18, 2026
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.
@sonichi sonichi changed the title feat(sutando-app): alert CLI via context-drop when watcher is down feat(sutando-app): notify on watcher death (CLI must restart) Apr 18, 2026
sonichi added 3 commits April 18, 2026 12:32
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 sonichi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

sonichi added 2 commits April 18, 2026 12:52
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.
@sonichi
sonichi merged commit 22fa9df into main Apr 18, 2026
1 check passed
@sonichi
sonichi deleted the fix/sutando-app-watcher-auto-restart branch April 18, 2026 20:13
sonichi pushed a commit that referenced this pull request Apr 19, 2026
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>
sonichi added a commit that referenced this pull request Apr 19, 2026
* 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>
sonichi pushed a commit that referenced this pull request Apr 19, 2026
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>
sonichi added a commit that referenced this pull request Apr 19, 2026
…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>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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.
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
)

* 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>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants