v6.0.0 — Phoenix

Phoenix lands the project.yaml data-loss fix, the flock-based singleton-daemon hardening, and Cursor Agent CLI as a sixth first-class backend, plus a TUI rewrite — Project Settings sidebar refactor, Trash filter mode, Definition $EDITOR shellout, Branches overlay, text-select mode, and a full agent-pane terminal-emulator swap from hinshun/vt10x to charmbracelet/x/vt that fixes the long-standing "input lands at top" tear bug. The data-loss fix closes a non-atomic-write race in config.SaveYAML that let SyncNextTaskNumber overwrite project.yaml (and the global ~/.watchfire/projects.yaml) with a zero-valued struct. The singleton fix closes a TOCTOU race in runDaemon that let two watchfired processes bind separate dynamic ports and spawn two menu-bar tray icons.

Added

Cursor Agent CLI as a sixth first-class agent backend. New internal/daemon/agent/backend/cursor.go implementing the full Backend interface alongside Claude Code, Codex, opencode, Gemini, and Copilot. Mirrors the Copilot backend's structure: per-session ~/.watchfire/cursor-home/<project_id>/<session_id>/ directory with the user's real ~/.cursor/ auth/config files symlinked in, composed Watchfire system prompt installed as AGENTS.md, headless cursor-agent --workspace <worktree> --print launch with the yolo / trust flag, JSONL transcript located via LocateTranscript and rendered through FormatTranscript in the same shape every other backend uses. TUI and GUI agent pickers include cursor with no special-casing — see Supported Agents.

TUI Project Settings sidebar refactor. The per-project Settings tab drops the flat 7-row form for a macOS-style sidebar + content-pane layout matching the v5.0 Flare global-settings UX. Sidebar lists seven sections: General, Automation, Notifications, Integrations, Metadata, Secrets, Danger zone. Tab/Shift+Tab walks the sidebar; ↑/↓ (j/k) walks rows inside the active section; / opens a search overlay that matches across labels + section breadcrumbs and Enter jumps to the matched row.

Project Notifications — per-event overrides. New Notifications section in the project Settings sidebar. Master mute (existing) plus a new Override per-event preferences toggle that gates per-event Enabled toggles for task_failed / run_complete / weekly_digest. New Quiet hours override toggle gates two HH:MM text inputs (start / end); same disabled-while-off treatment. Pre-v6 project.yaml files load identically.

Integrations — per-project scoping. New Integrations section in the project Settings sidebar. GitHub auto-PR toggle binds the project to membership in the global ~/.watchfire/integrations.yaml → github.project_scopes list. Slack channel + Discord guild ID text fields persist on the project YAML under a new integrations: block; empty string clears the binding (= inherit global default).

Danger-zone actions — Archive / Regenerate ID / Reset numbering / Prune merged branches / Unregister. New Danger zone section in the project Settings sidebar surfaces five destructive actions, each gated behind a y/N confirm in the status bar. Archived projects stop auto-starting tasks and drop from the dashboard active list. Reset task numbering recomputes next_task_number = highest_existing + 1. Unregister drops the entry from ~/.watchfire/projects.yaml while preserving local .watchfire/ so contact re-adds the project automatically.

TUI Trash filter mode — deleted tasks are visible + restorable. Tasks soft-delete (set deleted_at) but the TUI never surfaced them — x on a task hid it forever. The Tasks tab now carries a filter mode toggle: D (capital) flips the rendered list between the active subset and the soft-deleted subset. Trash row keys: u restores, x arms a y/N permanent-delete confirm (refused if the watchfire/<n> branch reports unmerged work), Enter opens the read-only edit form, D flips back.

TUI Definition tab — e shells out to $EDITOR. The Definition tab was read-only; editing required dropping out of the TUI to run watchfire define. New e (and Enter) binding opens the project definition in $VISUAL → $EDITOR → vim → vi via tea.ExecProcess. On exit, the diff against the pre-edit content decides whether to dispatch ProjectService.UpdateProject.

TUI Branches overlay — Ctrl+B lists, merges, deletes, prunes orphans. The TUI had zero visibility into git branches and their worktrees. New Ctrl+B overlay lists every watchfire/<n> branch with columns Branch, Task, Age, Status (merged / unmerged), and Worktree (present / absent, with merged-orphans rendered as absent*). Action keys: m merge, x delete (refuses unmerged), X force-delete, P prune all merged-orphans, r refresh.

TUI text-select mode — Ctrl+T toggles mouse capture. New global keybinding flips between Bubble Tea's mouse capture and host-native click-and-drag selection. Status bar swaps the normal hint row for a high-contrast ▎TEXT SELECT — drag to select · Ctrl+T to resume mouse banner; the header appends a text-select chip right after the project name so the mode is impossible to miss.

TUI agent pane — true scrollback via charmbracelet/x/vt. The agent pane previously consumed daemon-rendered ANSI snapshots dropped into a bubbles/viewport — vt10x's grid had no scrollback, so PgUp / Shift+arrows / wheel-up either no-op'd or corrupted the pane. The TUI now subscribes to SubscribeRawOutput (the same raw-byte stream the GUI feeds to xterm.js) and runs the bytes through a TUI-side emulator built on github.com/charmbracelet/x/vt. Up to 5000 scrollback lines drop in below the visible grid; wheel-up / Shift+↑ / PgUp walk into real history.

Fixed

TUI agent terminal emulator — fixes "input lands at top" tear bug + claude terminal-query hang. vt10x's incomplete xterm coverage rendered claude code's chat with input visibly stuck on the top row. Swapped the TUI emulator to charmbracelet/x/vt, which renders claude's UI faithfully. A drain goroutine reads the emulator's terminal-query responses (DA1, DSR, focus, mouse) and forwards them back to the daemon's PTY so claude unblocks.

Daemon SubscribeRawOutput — atomic subscribe-and-snapshot closes the double-delivery race. The previous implementation registered the channel and called GetRawBuffer in two separate critical sections; broadcastRaw interleaving between them would append bytes to rawBuf AND send them to the new channel, producing duplicates the new TUI vt emulator double-applied. New Process.SubscribeRawWithSnapshot(id) acquires both locks together so new subscribers see every byte exactly once across snapshot + live.

TUI: drop CSI mouse-event byte-leak fragments before forwarding to the chat agent's stdin. Bubble Tea reads stdin in 256-byte chunks; rapid mouse-wheel scrolling regularly cuts an SGR mouse sequence (\x1b[<button;col;row M) mid-flight and the trailing <64;105;35M lands as a KeyRunes that gets forwarded into the chat agent's PTY (visible as [<64;105;35M[<64;105;35M… in chat). New guard drops KeyRunes with Alt == true and any rune string matching the CSI mouse-residue regex.

TUI right-panel scroll — wheel routes by cursor + Shift+arrow line scroll. Wheel events used to follow the focused panel rather than the cursor — opposite of every native macOS terminal + browser. Wheel routing now resolves the target panel from msg.X vs layout.dividerCol and the wheel branch is split out of the click-press case so it never mutates focusedPanel. Shift+↑/↓ (line scroll) and Shift+PgUp/PgDn (page scroll) are now intercepted before forward-to-agent.

Atomic YAML writes — closes the project.yaml data-loss race. internal/config/loader.go::SaveYAML now writes to a sibling tmp file, fsyncs, then os.Renames into place. POSIX rename is atomic on the same filesystem, so concurrent readers see either the old file or the new file, never a truncated one. The fix covers every YAML file the daemon writes — project.yaml, the global projects.yaml, task files, settings, agents, daemon state, integrations.

LoadProject rejects zero-valued reads. internal/config/projects.go::LoadProject now returns corrupt project.yaml at <path>: … when the unmarshalled struct has Version == 0 or empty ProjectID. Any future writer that introduces a similar race surfaces as a load error instead of silently rolling forward.

Double-daemon spawn — flock-based singleton hardening. Reproduced 2026-05-05: two watchfired processes running simultaneously, each owning a separate gRPC server on a different dynamic port and a separate macOS menu-bar tray icon. Root cause was a TOCTOU race in runDaemon between the legacy daemon.yaml PID check and the dynamic port bind. New internal/config/lock.go exposes AcquireDaemonLock() returning ErrDaemonLockHeld on contention; the daemon acquires ~/.watchfire/daemon.lock via real syscall.Flock(LOCK_EX|LOCK_NB) before any tray/server init and holds it for the full process lifetime.

Changed

models.ShouldNotify consults per-project overrides before global. The signature changed to ShouldNotify(kind, cfg, project ProjectNotifications, now) so the function has full access to the per-project block. The gate now resolves per-event toggles in this order: project override (when OverrideEvents == true AND a row exists in Events) → global cfg.Events. Quiet hours: project QuietHoursOverride (when non-nil) replaces — does not union — the global window. The Muted master kill-switch retains v4.0 Beacon semantics: any project setting it to true short-circuits before any other gate.

Migration

All Phoenix changes are internal; no schema or API changes. Existing project.yaml files load unchanged.

Daemon singleton: ~/.watchfire/daemon.lock is created on first daemon start in v6.0+ and never deleted — it is a flock target, not a stale-PID file. Do not remove it manually unless every watchfired process is stopped.

Cursor backend: cursor-agent must be on PATH (or its absolute path set in ~/.watchfire/agents.yaml::cursor.Path). Existing projects keep their current default_agent; opt in via watchfire configure or by editing default_agent: cursor in .watchfire/project.yaml.