Skip to main content

Session mission control

Run several Claude sessions at once โ€” two VSCode windows, CLI + desktop, a mobile Remote Control session โ€” and two problems appear: you can't see them all, and you waste attention hopping between windows to click "yes, continue." This runbook is the operator's guide to the two halves that solve that:

  • Read side โ€” see every session: the /sessions command, a self-refreshing HTML dashboard, and a SwiftBar menu-bar plugin.
  • Attention side โ€” get told which session needs you: the notify-attention hook, plus the permission allowlist that removes most prompts up front.

It builds directly on the write-side hooks from session safety โ€” it reads the registry they already write and never mutates it.

The registry it readsโ€‹

Each live session is one gitignored JSON file at <main-repo>/.claude/session/sessions/<session_id>.json, written by session-register (SessionStart), session-guard (each edit), and session-drift-check (each prompt). The directory is resolved via git --git-common-dir, so all of a repo's worktrees write into the main checkout's registry โ€” a session in a /tmp worktree stays isolated for editing but is still visible here:

session_id, surface, cwd, repo_root, branch,
started_at / started_epoch, last_heartbeat / last_heartbeat_epoch,
baseline_head, baseline_origin_master, last_fetch_epoch,
files_touched: [...],
status: running | idle | waiting | ended, # set by drift/stop/notify/end hooks
wait_state: { kind, reason, since_epoch }, # present only while blocked (kind = permission|input)
end_reason # present only when ended

Status โ€” is it stopped, and why?โ€‹

The reader derives one state per session from status + wait_state + the heartbeat:

StateMeaningSet by
runningactively working (mid-turn)session-drift-check on each prompt
needs permissionstopped on a permission prompt โ€” waiting on younotify-attention (Notification event)
needs inputstopped on an idle prompt โ€” waiting on younotify-attention (Notification event)
idlefinished its turn, awaiting your next messagesession-stop (Stop event)
endedsession closed (with reason: clear/logout/exit)session-end (SessionEnd event)
staleno heartbeat within the TTL(heartbeat aged out)

Honest limits. Claude Code emits no distinct event for an Esc-interrupt or for "the whole task is complete" โ€” both look identical to idle (the turn ended, awaiting you). So "idle" is "Claude stopped its turn and is waiting on you", whether it finished, was interrupted, or just paused. Everything else in the table is distinguishable.

  • session label โ€” the view leads with the session's own title (Claude's generated aiTitle, e.g. "Close BeWith org-brain POC multi-workstream plan" โ€” the same text VSCode shows), read live from the session transcript at ~/.claude/projects/<cwd-as-dashes>/<id>.jsonl. Falls back to the latest prompt, then the branch. This is far more useful than the branch, which is often just master or a worktree.
  • surface โ€” where the session runs (vscode / cursor / cli / desktop / a terminal name), detected at SessionStart in priority order: an explicit CLAUDE_SURFACE โ†’ the CLAUDE_CODE_ENTRYPOINT โ†’ the macOS host-app bundle id (__CFBundleIdentifier) โ†’ TERM_PROGRAM. Claude Desktop sets no TERM_PROGRAM (it used to read unknown); it's now identified by its entrypoint / Anthropic bundle id โ€” best-effort, since Desktop's exact env can't be verified from a VS Code/CLI session. Only a truly unrecognised launcher falls through to unknown.
  • live vs stale โ€” live while the heartbeat is within SESSION_TTL_SECS (default 900s); it advances on SessionStart, every prompt, every edit, and on Stop โ€” so a session in a long conversation stays live.
  • collision โ€” two live (non-ended) sessions on the same branch+dir. session-guard blocks the later one; the view flags it so you see it coming.
  • reaping โ€” stale/ended entries move to sessions/archive/ (never deleted; only paths, never content) on the next SessionStart.

Read side โ€” seeing the sessionsโ€‹

/sessions (chat)โ€‹

In any session, run /sessions. It invokes the sessions-view skill, which runs the reader and prints a table ordered by what needs you first: waiting โ†’ collisions โ†’ live โ†’ stale. Read-only.

HTML dashboardโ€‹

A self-contained file:// dashboard, zero backend:

pnpm sessions # = node scripts/sessions-view.mjs โ€” writes + opens the HTML (macOS)
pnpm sessions --watch # regenerates every 10s; the page auto-refreshes โ†’ a live wall display
node scripts/sessions-view.mjs --json # machine-readable, what the skill consumes
node scripts/sessions-view.mjs --no-open # write only, don't launch a browser

Leave a --watch terminal running and pin the browser tab for an always-on view.

Machine-wide viewโ€‹

The registry is per-main-repo (all of a repo's worktrees share it). By default the reader resolves the current repo's main checkout โ€” run it from inside a worktree and it still finds the unified registry. To watch several repos at once, name each root:

node scripts/sessions-view.mjs --root /Users/you/workspace/repo-a --root /Users/you/workspace/repo-b
# or, once:
export SESSIONS_REGISTRY_ROOTS=/Users/you/workspace/repo-a:/Users/you/workspace/repo-b
pnpm sessions --watch

An always-present menu-bar icon (๐ŸŸข running / ๐ŸŸ  collision / ๐Ÿ”ด someone needs you, + count โ€” with your Claude usage % and Claude status folded in) with a dropdown table of every session, via a SwiftBar plugin. Optional โ€” nobody is required to use it; it's a convenience over /sessions and the HTML dashboard. One command sets it all up (installs SwiftBar if missing, places the plugin, points SwiftBar at it, launches โ€” no first-run folder picker to fight):

pnpm sessions:menubar # = bash scripts/install-sessions-menubar.sh

It auto-discovers repos under ~/workspace/* (override with SESSIONS_REGISTRY_ROOTS or BEWITH_SESSIONS_GLOB). The 5s in the plugin filename = refresh every 5 seconds. The plugin is a thin wrapper over node scripts/sessions-view.mjs --swiftbar, so it shows exactly what /sessions shows. Wire it into the per-developer sandbox CLI by calling that script from install-developer.

Survives a reboot. Two layers start at login independently: the folded-in data feeds (the io.bewith.usage / io.bewith.status LaunchAgents) self-start via launchd RunAtLoad, and the installer registers SwiftBar itself as a Login Item so the icon comes back too. If after a reboot the icon is missing (e.g. the Login Item registration was denied the one-time Automation prompt), open it manually with open -a SwiftBar, or enable SwiftBar โ–ธ Preferences โ–ธ "Launch at Login". Re-running pnpm sessions:menubar re-registers everything idempotently.

Clicking a session focuses the VSCode window for that repo (open -a "Visual Studio Code" <repo>) โ€” non-destructive, so a stray click does nothing harmful. The โ–ธ submenu has Re-attach in terminal (claude --resume), the cwd, and the wait reason.

What clicking can't do: focus the exact VSCode tab of that session. Claude Code exposes no "reveal session X" deep-link and the hook never receives a window handle, so the closest is "focus the repo's window" (above) or "re-attach in a terminal" (a new process). A signed standalone macOS menu-bar app wouldn't change this โ€” it's a Claude Code surface-area limit, not a SwiftBar one. The standalone app is tracked as a deferred follow-up; it would read the same registry + --json contract.

What the dropdown can't do: render the HTML. SwiftBar menus are text (single-line items, no wrap/scroll), so the dropdown is an aligned text table; the full, wrapping, scrolling view is the HTML dashboard ("Open HTML dashboard" in the menu).

Attention side โ€” being told who needs youโ€‹

The notify-attention hookโ€‹

Wired on the Claude Code Notification event (permission prompts + idle). On each notification, notify-attention.sh:

  1. Writes wait_state into the session's registry entry โ†’ the read side shows โณ "waiting on me".
  2. Pushes one alert through the first configured channel and falls back down the list: ntfy.sh topic โ†’ Slack webhook โ†’ macOS banner + bell. The message carries surface ยท branch ยท cwd ยท reason + claude --resume <session_id>.
  3. Debounces per session+type (NOTIFY_DEBOUNCE_SECS, default 45s) so idle re-prompts don't spam you. wait_state is still refreshed.

Fail-open: any error โ†’ it does nothing and never blocks.

VSCode extension caveat. The Notification event does not fire inside the VSCode extension โ€” permission prompts there surface as VSCode's own in-window toast, not as a hook. So notify-attention (the permission/idle push) only delivers in CLI sessions. In VSCode the Stop hook (session-stop.sh) is the only attention signal: it fires a turn-complete "your turn" banner when Claude finishes and you're looking at another window. "Another window" includes another VSCode tab you've since typed in โ€” the gate suppresses only when your editor is frontmost and this is the tab you most recently prompted. (lsappinfo sees the frontmost app, not the tab, so it uses the most-recent prompt across live same-surface sessions as the proxy; it biases toward notifying when unsure.) Toggle with NOTIFY_TURN_COMPLETE (default on).

What stays an in-window toast only: a mid-turn permission prompt in VSCode. No hook fires for it, so it can't become an OS banner or a phone push โ€” widen the allowlist (below) or use native Remote Control to view it on mobile.

Phone push & native mobile โ€” two separate pathsโ€‹

To get alerts on your phone, there are two independent mechanisms:

  • ntfy / Slack โ€” account-independent (ours). Set NTFY_TOPIC (or SLACK_WEBHOOK_URL) and the hooks push there; works on any phone regardless of which account it's logged into. notify-attention pushes the permission/idle alert, and the turn-complete "your turn" also pushes when NOTIFY_TURN_PUSH=true (off by default). Mint a personal, unguessable topic with scripts/sessions-notify-config.sh gen-topic (or โš™๏ธ menu โ†’ Generate my ntfy topic), then subscribe to the printed topic in the ntfy app. Treat the topic like a password.
    • ntfy: zero account; the topic is the channel; a long random topic is your only privacy.
    • Slack: each developer sets their own incoming-webhook URL (SLACK_WEBHOOK_URL) pointing at a private channel they own โ€” a webhook is channel-bound and can't DM, so there's no shared-channel option here.
  • Claude native Remote Control / mobile push โ€” account-coupled (built-in). /remote-control (or /rc) registers a session to claude.ai/code + the Claude mobile app; the /config toggle "Enable Remote Control for all sessions" makes it automatic for every session, and "Push when Claude decides" enables mobile pushes. Requires the same Claude account on desktop and phone โ€” a different account on mobile receives nothing. You can view permission prompts on mobile but must approve them locally.

Configure it โ€” the โš™๏ธ Settings menu (no file editing)โ€‹

Easiest: the menu bar is the config screen. Open the SwiftBar dropdown โ†’ โš™๏ธ Settings:

  • Conversation language: English / Hebrew โ€” the language Claude converses in (artifacts always stay English). Writes ~/.bewith/config.json via scripts/bewith-config.sh; same as /lang.
  • Role: dev / product + Default language โ€” the capability-discovery role (/role) and the language fallback.
  • Claude usage % in menu bar โ€” show the Claude account-usage percentage in the menu-bar title (show in menu bar) or only inside the dropdown (dropdown only). The usage section in the dropdown shows either way; this only controls the title suffix.
  • ๐Ÿ–ฅ Open settings page โ€” a read-only HTML view of every setting (value, source, and the command to change it).
  • Notifications: on/off โ€” the master switch.
  • Local banner: auto / always / off โ€” the macOS banner that pops top-right on this Mac (a local banner, as opposed to a remote ntfy/Slack push). auto = only when no remote channel fired; always = also alongside ntfy/Slack; off = never. If terminal-notifier is installed (the menu-bar installer adds it), the banner is clickable โ†’ it focuses the session's VSCode window; otherwise it's a plain non-clickable banner.
  • Notifier: NOTIFY_NOTIFIER โ€” auto (default) uses terminal-notifier when installed (clickable + custom icon via NOTIFY_ICON/NOTIFY_CONTENT_IMAGE), else osascript. On macOS 26+ terminal-notifier needs notification permission (System Settings โ†’ Notifications โ†’ terminal-notifier โ†’ Allow) or it shows nothing yet exits 0; grant it โ€” and set its Alert Style to Alerts so a "needs you" banner persists โ€” or set NOTIFY_NOTIFIER=osascript.
  • Sound: on/off + a sound picker (Glass / Ping / Hero / Funk / Submarine).
  • ๐Ÿ”” Test notification โ€” fires one now so you hear the current sound.
  • ๐ŸŽ™ Custom sound โ€” opens the config file; set NOTIFY_SOUND_FILE to a clip you recorded (Voice Memos / QuickTime โ†’ .aiff/.m4a/.wav), e.g. your own "i need you my master". A custom file is played with afplay and overrides the system sound.
  • Phone push on "your turn": on/off โ€” also push the turn-complete alert to your phone via ntfy/Slack (needs a topic/webhook). Off by default (NOTIFY_TURN_PUSH).
  • ๐Ÿ”‘ Generate my ntfy topic โ€” mints a per-dev NTFY_TOPIC (identity prefix + random suffix) and writes it; subscribe to the printed topic in the ntfy app.

These write the machine-level config at ~/.bewith/notify.env (managed by scripts/sessions-notify-config.sh), which applies to every session on the machine.

Configure it โ€” by file (machine-level or per-repo)โ€‹

Config is sourced machine-level first (~/.bewith/notify.env), then a per-repo override (<repo>/.claude/hooks/notify-attention.env, copy from the .example), then env vars. Gitignored โ€” an ntfy topic / Slack webhook are secrets.

VarDefaultMeaning
NOTIFY_ENABLEDtrueMaster switch โ€” any notification at all.
NOTIFY_BANNERautoLocal macOS banner: auto (fallback only) / always / off. (Old name NOTIFY_OSASCRIPT still honored.)
NOTIFY_SOUNDtruePlay a sound with the banner.
NOTIFY_SOUND_NAMEGlassmacOS system sound name.
NOTIFY_SOUND_FILE(empty)Custom audio file (afplay) โ€” overrides the system sound; record your own.
NTFY_TOPIC(empty)ntfy topic โ†’ phone push. Mint one with sessions-notify-config.sh gen-topic.
NTFY_SERVER / NTFY_PRIORITYhttps://ntfy.sh / defaultSelf-hosted server / priority (high cuts through DND).
SLACK_WEBHOOK_URL(empty)Your own per-dev incoming-webhook URL โ†’ its bound channel (no DMs).
NOTIFY_TURN_COMPLETEtrueThe turn-complete "your turn" banner (the only VSCode attention signal).
NOTIFY_TURN_PUSHfalseAlso push that "your turn" to ntfy/Slack, not just a local banner.
NOTIFY_DEBOUNCE_SECS45Suppress repeat pushes per session within this window.

With no config it still works: a local macOS banner + sound. Set a topic to also get it on your phone.

The hard limit โ€” and why the allowlist matters moreโ€‹

A permission dialog cannot be approved from mobile or web Remote Control. (Verified against docs.claude.com.) Remote Control lets you view a session, type a follow-up, and continue โ€” but a permission approval must happen where the session runs.

So the notification only tells you who needs you. What actually cuts the prompts is the permission allowlist:

  • This repo ships a curated, safe permissions.allow in .claude/settings.json; for consumer repos the same allowlist is opt-in โ€” apply it (with a diff + confirmation) by running bewith platform permissions (user scope); bewith platform install no longer applies it automatically. Once applied, Claude Code merges allow-rules across scopes, so it covers every repo without a per-repo copy.
  • To also stop confirming every file edit, opt into edit-mode by adding "defaultMode": "acceptEdits" to your .claude/settings.json. It's opt-in, not the default, because it removes the per-edit pause.
  • For per-machine tuning from your real transcripts, the fewer-permission-prompts skill scans common calls and proposes additions.

Net: the allowlist removes ~most prompts, defaultMode: acceptEdits removes the edit pauses, and notify-attention centralizes the few that remain so you stop window-hopping to find them.

Consumer repos (plugin parity)โ€‹

A repo that consumes the platform only through the bewith plugin gets all of this: .claude-plugin/hooks/hooks.json declares the session-safety set (session-register / session-guard / session-drift-check), the UserPromptSubmit event, and notify-attention โ€” at parity with this repo's settings.json. (This closed a prior gap where the plugin omitted the session-safety hooks.) The reader and SwiftBar plugin work the same way against any repo's registry.

Verify it worksโ€‹

# Reader against the live registry
node scripts/sessions-view.mjs --json # one row per session, with live/collision/wait_state
pnpm sessions # opens the HTML dashboard

# Notification hook (writes wait_state, no real push)
SID=$(ls .claude/session/sessions/*.json | head -1 | xargs basename | sed 's/.json//')
printf '{"session_id":"%s","cwd":"%s","notification_type":"permission_prompt","message":"needs permission"}' "$SID" "$PWD" \
| NOTIFY_OSASCRIPT=false bash .claude/hooks/notify-attention.sh
node scripts/sessions-view.mjs --json | grep -q wait_state && echo "wait_state set โœ“"

A real end-to-end check: open two sessions on different branches โ†’ /sessions lists both; trigger a permission prompt โ†’ with NTFY_TOPIC set, a push arrives carrying the claude --resume hint; send a prompt to the waiting session โ†’ its โณ flag clears on the next view.

See alsoโ€‹