Supervisor pivot: keep one healthy remote-control server per folder #28

Merged
claude-agent merged 32 commits from feat/supervisor-pivot into master 2026-09-09 06:39:59 +01:00
Collaborator

Turns the PWA from a session launcher into a supervisor that keeps exactly one claude remote-control server (an anchor) alive per declared folder, restarting on crash/reboot and raising an attention banner or web-push when a folder needs a human.

Highlights

  • supervisor.py: desired-state persistence, trust seeding, session-scoped tmux discovery, single-lock reconcile loop, status-aware Kuma heartbeat with stall watchdog. All writes atomic; a malformed supervised.json keeps the last-good state instead of collapsing to [].
  • /api/supervised* CRUD + restart + adopt-from; POST /api/sessions (the old launcher) and dormant/history/shell code removed.
  • Frontend rewrite: Supervised folders section, Other sessions section (still endable / adoptable), add-folder form, needs_attention banner, per-anchor terminal, branch + capacity tags on the card, tap-through into the Claude app via the parsed ?environment=env_… deep link.
  • Web-push (VAPID) — the reconcile loop fires one notification per fresh transition into needs_attention / unhealthy, tag-collapsed so a stuck /login only lights up once.
  • FastAPI lifespan migration; base tmux session bootstrap on startup (was implicitly relying on the old launcher path); orphan sup-* window sweep.

Regression fixes on top of the initial pivot

  • list-panes -s (session-wide, not active-window-only).
  • Ownership is the tmux #{window_id} match, not is_adoptable() — the claude proc isn't a BFS child of pane_pid on the very first tick after spawn, and treating that as "not ours" spawned duplicates.
  • new-window -P -F "#{window_id}" for atomic wid capture; send-keys targets the wid so a same-named orphan cannot intercept.
  • "starting" is not a failure — record window, wait a tick, don't respawn.
  • _READY_RE extended to match the real CLI 2.1.263 banner (Connected · <name> · <branch> / Continue coding in the Claude mobile app / Capacity: N/M).
  • restarts counts respawns only, not the initial spawn.
  • Seed uses the folder basename, not the live session name.

Tests: 210 pass; adds unit + API + reconcile + push + Playwright E2E coverage for the whole flow.

Docs: README + AGENTS + BookStack Overview/Operations (pages 81, 82) rewritten for the supervisor model. Manual step: first-time folders may still need /login accepted once via the in-app terminal.

Verified live on the box against three folders (RadioShake, IronPulse, NASDocker) — no window disruption across multiple service restarts, all three land at status: ok, capacity + branch surface on the card.

https://claude.ai/code/session_01RXuwdoZzGeWqZGih5iehQH

Turns the PWA from a session launcher into a supervisor that keeps exactly one `claude remote-control` server (an anchor) alive per declared folder, restarting on crash/reboot and raising an attention banner or web-push when a folder needs a human. **Highlights** - `supervisor.py`: desired-state persistence, trust seeding, session-scoped tmux discovery, single-lock reconcile loop, status-aware Kuma heartbeat with stall watchdog. All writes atomic; a malformed `supervised.json` keeps the last-good state instead of collapsing to `[]`. - `/api/supervised*` CRUD + restart + adopt-from; `POST /api/sessions` (the old launcher) and dormant/history/shell code removed. - Frontend rewrite: **Supervised folders** section, **Other sessions** section (still endable / adoptable), add-folder form, needs_attention banner, per-anchor terminal, branch + capacity tags on the card, tap-through into the Claude app via the parsed `?environment=env_…` deep link. - Web-push (VAPID) — the reconcile loop fires one notification per fresh transition into `needs_attention` / `unhealthy`, tag-collapsed so a stuck `/login` only lights up once. - FastAPI lifespan migration; base tmux session bootstrap on startup (was implicitly relying on the old launcher path); orphan `sup-*` window sweep. **Regression fixes on top of the initial pivot** - `list-panes -s` (session-wide, not active-window-only). - Ownership is the tmux `#{window_id}` match, not `is_adoptable()` — the claude proc isn't a BFS child of pane_pid on the very first tick after spawn, and treating that as "not ours" spawned duplicates. - `new-window -P -F "#{window_id}"` for atomic wid capture; send-keys targets the wid so a same-named orphan cannot intercept. - "starting" is not a failure — record window, wait a tick, don't respawn. - `_READY_RE` extended to match the real CLI 2.1.263 banner (`Connected · <name> · <branch>` / `Continue coding in the Claude mobile app` / `Capacity: N/M`). - `restarts` counts respawns only, not the initial spawn. - Seed uses the folder basename, not the live session name. **Tests**: 210 pass; adds unit + API + reconcile + push + Playwright E2E coverage for the whole flow. **Docs**: README + AGENTS + BookStack Overview/Operations (pages 81, 82) rewritten for the supervisor model. Manual step: first-time folders may still need `/login` accepted once via the in-app terminal. **Verified live** on the box against three folders (RadioShake, IronPulse, NASDocker) — no window disruption across multiple service restarts, all three land at `status: ok`, capacity + branch surface on the card. https://claude.ai/code/session_01RXuwdoZzGeWqZGih5iehQH
The service worker cache version was never bumped for the pivot's
static changes, so installed PWAs kept serving the pre-pivot app.js
and index.html.

Claude-Session: https://claude.ai/code/session_01RXuwdoZzGeWqZGih5iehQH
`claude remote-control` (CLI 2.1.263) rejects --model with "Unknown
argument: --model" — the model is chosen per-session inside the Claude
app once it connects to this anchor.

- supervisor.spawn_command(name, claude_bin): no more model arg
- SupervisedAdd / SupervisedPatch: drop model field
- POST /api/supervised, PATCH /api/supervised/{sid}, adopt-from,
  _seed_supervised(): stop writing model
- Frontend: remove model <select> from add-folder form, drop model
  from POST body, drop model span from supervised card render,
  delete loadModels() dead code
- Tests: spawn_command call sites; new assertion that --model is
  never emitted; state fixtures unchanged (loader tolerates a
  legacy `model` key on disk)
- SW cache bumped v32 → v33

Old supervised.json entries with a `model` key still load — the field
is just ignored on the next save.

Claude-Session: https://claude.ai/code/session_01RXuwdoZzGeWqZGih5iehQH
Four bugs, all producing the same visible symptom (a supervised folder
staying "starting" while the tmux session filled with orphan
sup-<name> windows on every reconcile tick):

1. discover_anchors ran `tmux list-panes -t <session>` without `-s`,
   which scopes to the session's CURRENT window only. As soon as focus
   moved (e.g. after spawning a second folder), the first folder's
   anchor vanished from discovery and reconcile spawned a duplicate.
   (Fix already applied by the E2E subagent; kept.)

2. reconcile_one's "already tracked?" branch gated on is_adoptable() of
   the live window. On the first tick after a fresh spawn, `claude`
   isn't yet a BFS child of pane_pid (bash still reading the send-keys
   line), so cmdline=[] → is_adoptable=False → fall through to
   spawn-fresh → duplicate window. Fix: wid match IS ownership; only
   readiness of the pane text matters.

3. Spawn-fresh path resolved the new window id by re-listing windows
   and matching on name — ambiguous whenever a same-named orphan
   existed, sending send-keys to the wrong pane (or failing with
   "can't find window") so the new pane stayed empty. Use
   `new-window -P -F "#{window_id}"` for atomic wid capture and
   target send-keys by wid.

4. Both branches treated "starting" as a failure. Zero-settle capture
   right after send-keys was always "starting", so _fail incremented
   consecutive_failures and scheduled a respawn on the very next tick.
   Fix: "starting" is transient — record window and wait.

Also:
- _READY_RE didn't match CLI 2.1.263's actual banner
  ("·✔︎· Connected · <name> · …" then "Continue coding in the Claude
  mobile app or https://claude.ai/code…") so a healthy anchor was
  never promoted to `ok`. Regex extended.
- reconcile_all sweeps `sup-*` windows whose wid isn't owned by any
  folder (leftovers from the old bug or a delete that raced the loop).
- app.py `_ensure_tmux_session()` runs `has-session || new-session`
  before the loop starts — the launcher path that used to create the
  base session was removed in the pivot, so after a reboot the loop
  had nothing to `new-window -t` into.
- SW cache v33 → v34.

Verified live on the box: RadioShake spawns one window, promotes to
`ok`, restarts stays at 0.

Claude-Session: https://claude.ai/code/session_01RXuwdoZzGeWqZGih5iehQH
Follow-up to cd92e93 (four bugs; live-only fix). Locks the fixes in
tests and cleans up three smaller papercuts spotted while writing
them.

Code:
- `restarts` counter no longer includes the initial spawn. It is now
  strictly a respawn count, incremented iff `started_at` was already
  set — matching the semantics the card labels imply.
- `_seed_supervised()` uses the folder basename for `name`, not the
  live session's `name`. A live session may be named after a task
  ("task-165-canary") that's a bad identity for an anchor which
  keeps living past that task.
- Fresh spawn passes `-x 120 -y 40` to `new-window` so the CLI's
  ready banner doesn't wrap at 80 cols — makes `_READY_RE` less
  fragile against a rearranged banner.

Tests (7 new, all green):
- test_discover_uses_session_scoped_list_panes: strengthened to
  require `-s`, not just "not -a".
- test_spawn_uses_atomic_wid_capture: `-P -F "#{window_id}"` +
  send-keys targets the returned wid, never the name.
- test_first_spawn_not_counted_as_failure: blank pane on first tick
  leaves consecutive_failures at 0.
- test_already_tracked_owns_window_even_when_cmdline_empty: wid
  match IS ownership; empty cmdline (BFS hasn't found claude yet)
  does not send the loop down the spawn-fresh path.
- test_restarts_zero_on_first_spawn / test_restarts_increments_on_
  respawn_after_death.
- test_orphan_sup_windows_swept: reconcile_all kills sup-* windows
  whose wid nobody owns.
- test_seed_uses_folder_basename_not_session_name.
- test_ensure_tmux_session_creates_when_missing / _idempotent_when
  _present: the has-session||new-session bootstrap.

Full suite: 195 pass (188 + 7).

Claude-Session: https://claude.ai/code/session_01RXuwdoZzGeWqZGih5iehQH
feat: FastAPI lifespan, banner parsing, web-push notifications
Some checks failed
CI / lint-and-test (pull_request) Failing after 55s
0dfd75c914
Everything remaining from the "improvements" list except the PR-to-
master step:

FastAPI lifespan:
- Replace @app.on_event("startup") with an @asynccontextmanager
  lifespan that also cancels background tasks on shutdown. Silences
  the deprecation warning and stops leaving orphaned supervisor
  sleeps that raised CancelledError in the terminal_ws suite.

Ready-banner parsing on the card:
- supervisor.parse_banner() pulls capacity, branch, and the
  https://claude.ai/code?environment=… deep link out of the CLI
  2.1.263 banner. Populated on `ok`, cleared elsewhere.
- Card renders `capacity N/M`, a branch tag, and (when present)
  wraps the folder name in an anchor tag pointing at the environment
  URL — one tap jumps into the Claude app for that anchor.
- _READY_RE also matches Capacity / Reconnected after / New sessions
  will be created — these lines are only printed by a healthy server
  and cover a long-running anchor whose original banner has scrolled
  off the top of the capture buffer.
- capture-pane bumped from -S -200 to -S -2000 for the same reason
  (tmux caps its own history_limit so the returned bytes stay
  bounded).
- The "already tracked" AND the "claim orphan" branches now both
  parse the banner, so the card shows the extras on the first tick
  after adoption instead of the second.

Web Push (VAPID):
- push.py: VAPID keypair generated once and stored under
  ~/.claude-pwa/vapid.json, subscription list at push_subs.json,
  atomic writes, malformed-file tolerant. push_notify() dispatches
  best-effort and prunes 404/410 endpoints permanently.
- New endpoints: GET /api/push/vapid-key (public), POST/DELETE
  /api/push/subscribe (auth required).
- Supervisor loop calls _detect_and_push_transitions() after each
  tick and pushes ONE notification per fresh transition into
  needs_attention or unhealthy (repeat suppression via an in-memory
  {sid: status} cache; a return to ok/paused re-arms it).
- Static SW handles `push` (showNotification with tag-based collapse)
  and `notificationclick` (focus/open PWA at the given URL).
- Frontend: a 🔔 header button appears iff the browser supports Push
  and no subscription exists; tap → permission prompt → subscribe →
  POST to backend. Hides itself once subscribed or denied.
- Added pywebpush + cryptography to requirements.txt.
- 10 new push tests; webpush() is patched so no real HTTP goes out.

SW cache bumped v34 → v35.

Verified live on the box: 3 supervised folders survived the service
restart with no window disruption, all `status: ok`, capacity/branch
now surfaced.

Claude-Session: https://claude.ai/code/session_01RXuwdoZzGeWqZGih5iehQH
claude-agent referenced this pull request from a commit 2026-09-09 06:40:01 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
hiten/claude-code-pwa!28
No description provided.