Releases: watchfire-io/watchfire
Release list
v8.0.0 - Inferno
[8.0.0] Inferno
Inferno is the parallel-workspaces tentpole — the first feature-forward major since v4 "Beacon", aimed squarely at supervising many projects at once. The Electron GUI goes multi-window: a main-process window registry replaces the mainWindow singleton, opening one independent BrowserWindow per project (single-instance lock, per-window state + session restore, Cmd+N / window cycling, IPC fan-out, and — critically — per-window PTY routing so integrated-terminal bytes and OS notifications never cross windows). Each project window flips to a chat-primary layout (agent terminal as the wide left pane; Tasks/Definition/Insights/Secrets/Trash/Settings as a right reference region). The plain <textarea> markdown surfaces become a real CodeMirror rich editor (formatting toolbar + source ⇄ split ⇄ preview, closing issue #22), and wildfire mode lands in the GUI with a confirm-gated start and a live Execute → Refine → Generate phase indicator. The promoted Dashboard becomes mission control — a live home window with per-project wildfire phase, cross-project needs-attention aggregation with click-through, tray-click routing to the right window, and a stretch always-on-top mini-monitor. Finally, code-output analytics measure what the agents actually shipped: per-task <n>.metrics.yaml captures commits/files/lines/merge-kind, rolled up into project + global Insights, the GUI's KPI cards and churn-by-day chart, mission-control "shipped" lines, and the CSV/Markdown exports + weekly digest. ARCHITECTURE.md was updated for the multi-window model; blast radius stays contained to gui/ and the daemon's metrics/insights plumbing.
Fixed
- An OS notification toast/sound now fires exactly once no matter how many windows are open (v8 "Inferno" Feature 1, spec decision D1). Each renderer subscribes to the daemon's
NotificationServicestream, and on every event it plays its own.wavsound and calls thenotifications:emitIPC, which shows a nativeNotification. With the multi-window registry now opening one window per project, N subscribed renderers meant N toasts + N sounds per single TASK_FAILED / RUN_COMPLETE / WEEKLY_DIGEST event. D1 resolves this with the home window as the sole notifier: the daemon already multiplexes the stream, but only the home window's renderer subscribes — project windows skip it entirely. The choice is enforced in two places (belt-and-suspenders): the notifications store'sstart()early-returns unlessisHomeWindow(), andApp.tsxonly callsstartNotifications()on the home window. A newgui/src/renderer/src/lib/window-scope.tsderives the scope from the URL the main process set when creating the window (?project=<id>⇒ project window, no query ⇒ home window), so it needs no renderer boot-scoping wiring. The alternative — moving the daemon subscription into the main process as the single notifier — was deferred: the main process has no gRPC client today (it talks to the daemon over rawnet), so adding one purely for this is out of scope; the home-window-sole-notifier guard is the simplest robust option. - Integrated-terminal output no longer bleeds across windows (v8 "Inferno" Feature 1). With the multi-window registry,
gui/src/main/pty-manager.tskept a single module-globalwinset viasetWindow(w)and pushed everypty-output/pty-exitto it, so once a second project window existed, a terminal's bytes landed in whichever window wassetWindow'd last — project A'sechocould surface in project B's terminal. EachPtySessionnow carries thewindowIdof the window that spawned it: thepty-createIPC handler resolves the originating window viaBrowserWindow.fromWebContents(event.sender)?.idandcreatePty(cwd, windowId)records it, and a per-sessionsend(windowId, channel, data)(resolvingBrowserWindow.fromIdand guardingisDestroyed()) routesonData/onExitonly to the owning window. The module-globalwin/setWindow(and thesetPtyWindow(...)call inindex.ts) are gone. A newdestroyForWindow(windowId)kills and forgets only that window's sessions and is wired to each window'sclosedevent inwindows.ts, so closing one project window tears down only its terminals while the others keep working;destroyAll()still runs on app quit.
Changed
- Tray clicks now open/focus the relevant project's OWN window instead of a generic first window (v8 "Inferno" Feature 4). The Beacon Pulse system tray (Needs-attention / Working / Idle sections + Notifications submenu) emits a
FocusEventoverDaemonService.SubscribeFocusEventsthat already carries theprojectId/ target / task it points at, but the renderer's focus subscriber (gui/src/renderer/src/stores/focus-store.ts) only ever called the genericfocusWindow()IPC — which brings the most-recently-focused window forward — and then navigated that window in-place. With the multi-window registry opening one window per project, a tray click for project A could surface project B's window. The focus subscriber now delegates window targeting to the main process (which owns the registry): a project event callswindow.watchfire.focusProjectWindow(projectId, target, taskNumber)so thefocus-project-windowIPC opens (or focuses, never duplicates) that project's own window and routes its renderer to the right tab/task via the existingproject-focus→onProjectFocus→requestFocuspath (the same channel mission-control click-through uses, #0103); a global event or aWEEKLY_DIGESTevent callsopenHomeWindow()to surface the home/dashboard window (digests then open the modal there). To keep one tray click from fanning out into N windows each trying to focus, only the home window subscribes to the focus stream —App.tsxnow gatesstartFocus()onisHomeWindow(), mirroring the D1 single-notifier election so the home window is the single tray-event router for the whole app. The genericfocus-windowIPC is left in place but is no longer on the tray path. - ProjectView is now chat-primary: the agent chat/terminal is the wide LEFT pane and Tasks/Definition/Insights/Secrets/Trash/Settings are a tabbed region on the RIGHT (v8 "Inferno" Feature 1, layout flip). This inverts the old "center content + collapsible right Chat panel" so the terminal — the actual work surface — leads, which fits per-project windows (each window is one project's cockpit) and wildfire-in-GUI. Chat-left is the default and there is no side-swap toggle. The v7.3 "focus chat" header toggle (
Maximize2/Minimize2) and the resize divider's double-click now simply hide the right reference region so chat goes full-width (the old "collapse the center column" + separate panel-open toggle are gone), with updated tooltips. The reference region is a resizable right panel reusing the existingwf-right-panel-widthlocalStorage key (its semantics — the right panel's width — are unchanged; only the contents flipped) with a chat-wide first-run default (~560px reference, chat takes the rest);wf-chat-focus-<projectId>still persists focus per project. Branches and Logs stay reachable (still tabs inside the chat pane), and tray focus-requests for Tasks reveal the reference region if it was collapsed. The bottom integrated terminal (Cmd+`) is unchanged. - Main-process lifecycle/update events now fan out to every window, and notification/focus clicks route to the right one (v8 "Inferno" Feature 1). Previously
daemon-ready/daemon-shutdown(gui/src/main/index.ts) and the fourupdate-*events (gui/src/main/auto-updater.ts) targeted a single window, andfocus-window/ thenotifications:emitclick handler (gui/src/main/ipc.ts) targetedBrowserWindow.getAllWindows()[0]— fine when there was one window, wrong once a project window exists. A newbroadcast(channel, ...args)inwindows.tsiterates the window registry (skipping destroyed windows) and now drives all six lifecycle/update events, so each independently-connected renderer learns the daemon came up/went down and every window's update banner reacts.initAutoUpdater()no longer takes a window argument — it broadcasts. Notification-click routing is now project-aware: TASK_FAILED / RUN_COMPLETE clicks callcreateProjectWindow(projectId)(opening the project's own window or focusing it if already open) and then sendnotifications:click; because a freshly-created window's renderer isn't loaded yet, the send is deferred todid-finish-loadwhenwebContents.isLoading(). WEEKLY_DIGEST is global, so it surfaces on the home window.focus-window(the tray-click handler) now targets the most-recently-focused window — tracked in the registry via awin.on('focus')listener and exposed asgetMostRecentlyFocusedWindow(), falling back to the home window — instead of an arbitrary one. - All three GUI markdown surfaces now use the rich
MarkdownEditorinstead of a plain<textarea>. Second half of issue #22 (v8 "Inferno" Feature 2, closes #22): the project definition (views/ProjectView/DefinitionTab.tsx), the Add Project wizard's definition step (views/AddProject/StepDefinition.tsx), and both long-text fields of the task modal —promptandacceptance_criteria(views/ProjectView/TasksTab/TaskModal.tsx) — render the #0108 CodeMirror editor (formatting toolbar + source ⇄ split ⇄ preview). Each swap keeps the surface's existing state and handlers verbatim, so save semantics are untouched: DefinitionTab's debounced autosave + 3s external-change polling, StepDefinition's wizard value plumbing and Skip path, and the task modal's create/update/validation flow all behave as before. Short fields (task title) stay plain inputs. Because the editor keeps the source verbatim, markdown/whitespace round-trips cleanly into the daemon-written YAML block scalars. Sizing matches the previous layout (DefinitionTab fills the tab, StepDefinition ~25...
v7.4.0 — Forge
[7.4.0] Forge
Forge 7.4 closes a class of "wildfire stops even though there's a ready task sitting right there, and drops back to chat" reports. Traced live on the Njord project: the symptom turned out to be three independent daemon bugs plus a runaway log that buried the evidence. Generate phase had produced task #17 (status: ready), the chain picked it up and stamped it started (sessions=1), but no agent ever ran it and the daemon went idle — and the one log line that would have explained why was drowned under hundreds of MB of self-referential watcher spam. This release fixes the runaway log at the source, moves the verbose per-project trail into per-project log files so the global daemon.log stays readable, makes a launch failure unable to strand a ready task, and stops the issue detector from false-positiving on ordinary agent output.
Fixed
- The daemon log no longer feeds an infinite fsnotify loop. The watcher adds
~/.watchfireto its fsnotify set (forprojects.yaml/settings.yaml/integrations.yaml), and since v7.2.1 the daemon writes its own log to~/.watchfire/daemon.log— inside that watched dir.internal/daemon/watcher/watcher.gologged every raw fsnotify event ([watcher] fsnotify: WRITE …) unconditionally, so each log write produced a WRITE event that was itself logged, producing another write, forever. One user's log family pinned at the full cap (≈800 MB acrossdaemon.log+daemon.log.1) as purewatcher.gonoise —grep -avc watcher.goreturned zero other lines — starving the global log of every real diagnostic and burning CPU/file-descriptors (the likely trigger for the PTY-spawn failure below). Two-part fix: (1)processEventsnow skips events whose path is the daemon's own log via a newisSelfLogPath(matchesdaemon.logand rotateddaemon.log.N), so a log write can never re-enter the loop; (2) the per-event[watcher] fsnotify: …and[watcher] debounce fired: …lines are now gated behind a newWATCHFIRE_DEBUGenv var (off by default) — that per-event firehose was the actual volume even absent the loop. - First run after upgrade reclaims an oversized legacy
daemon.loginstead of preserving the bloat. A pre-7.4.0 daemon (especially one that hit the fsnotify loop above) can leave a multi-hundred-MB — or, per the v7.3.0 note, multi-GB —daemon.logplus an equally largedaemon.log.1. Handing that to the rotating writer would just rename the oversized active file to.1and keep it. NewreclaimOversizedDaemonLogs(internal/daemon/cmd/daemonlog.go) runs once at startup before the writer opens: it deletes any numbered backup larger than the cap (legacy 500 MiB backups / runaway artifacts) and truncates an oversized active log down to its last 256 KiB (prefixed with a migration marker) so recent context survives but the disk is freed. It only touches files that exceed the cap — which a 7.4.0+ daemon never produces — so steady-state logs and legitimately-sized backups are left alone. Covered by two tests indaemonlog_test.go(truncate-active-and-drop-backup; leave-normal-files-untouched). - A launch failure can no longer strand a
readytask and silently drop the run to chat. Ininternal/daemon/agent/manager.go::StartAgent, the "mark task started" stamp (taskModel.Start()→AgentSessions++,StartedAt, draft→ready,SaveTask) ran before the agent was confirmed running — ahead of system-prompt install, command build, sandbox spawn, andNewProcess. If any of those failed (e.g. PTY allocation under fd pressure from the loop above), the task was already persisted asstartedwith no agent behind it; the chain loggedfailed to start nextand returned to idle, leaving the task inreadyforever (exactly the Njord #17 signature:started_at+sessions=1, no session log, no commits, branch atmain). The stamp now runs afterNewProcesssucceeds, so a spawn failure leaves the task untouched and retryable. Additionally, the chain'sfailed to start nextpath now emits aTASK_FAILEDnotification (viaemitTaskDoneFailure) when a task was involved, instead of halting with no signal. - The auth / rate-limit issue detector no longer false-positives on ordinary agent output.
internal/daemon/agent/issue.goscans the agent's entire PTY scrollback — including source code, comments, and task prompts the agent prints — yet matched on bare substrings ((?i)rate limit,invalid.*token,token.*expired,too many requests). Any project whose own code or task titles mention rate limiting (Njord is a trading tool with a "polite-fetch layer: rate limiting, backoff" task) could trip a phantomrate_limitedissue, whichmonitorProcesstreats as a hard signal to stop the autonomous chain. Both pattern sets now require the shape of a real provider error — an HTTP 401/429, the literalauthentication_error, the Claude "you've hit your limit" / "usage/rate limit reached|exceeded" banners, or an OAuth / API-key / access-token qualifier — rather than the bare domain phrase. New regression cases ininternal/daemon/agent/issue_test.goassert that"Implemented rate limiting with a token-bucket backoff", a task title containing"rate limiting, backoff", a429code comment,"invalid token provided"in a code line, and a "token may have expired" comment all stay clean, while the genuine provider errors still detect.
Changed
- The verbose per-project daemon trail moved to
~/.watchfire/logs/<project_id>/daemon.log; the globaldaemon.logis now small. Newconfig.ProjectLogf(projectID, …)(internal/config/projectlog.go) — a best-effort, self-rotating appender (16 MiB × 1 backup per project) that never returns an error or panics. The "hardcore" operational lines now route there: the wildfire[chain]decisions and[poll]safety-net hits, agent start/exit/stop and session-log/transcript writes (manager.go), the[merge]flow (taskdone.go), the per-process[agent]PTY trail (process.go), and the server-side[task-watch]/ phase-ended / generate-ended watcher-event handlers (server/server.go,server/task_failed.go). Genuinely global / project-less lines stay indaemon.log: daemon start/stop, update checks, OAuth, the cross-project weekly digest, integration config WARN/ERRORs, and the auto-PRgh-unavailable / non-github / PR-error fallbacks. - Global
daemon.logcap dropped from ~1 GiB to ~64 MiB (internal/daemon/cmd/daemonlog.go: per-file 500 MiB → 32 MiB, one backup). With the fsnotify loop fixed at the source and the per-project trail rerouted, the global log carries only coarse lifecycle lines, so the v7.3.0 1 GiB safety cap is far larger than needed.
Added
WATCHFIRE_DEBUGenv var. When set (any non-empty value), the watcher re-enables the per-event[watcher] fsnotify: …and[watcher] debounce fired: …lines for diagnostics. Off by default.
v7.3.0 — Forge
[7.3.0] Forge
Forge 7.3 is primarily a GUI release — focus-chat mode for the Electron app and the Watchfire version surfaced under the sidebar logo so you no longer have to dig through Settings to know what you're running — plus one daemon-side fix that closes the size-cap deferral v7.2.1 explicitly left open: ~/.watchfire/daemon.log is now bounded at ~1 GB total (500 MB active + one 500 MB backup) after a user's log grew to 300 GB. The version bump lands on version.json, gui/package.json, and gui/package-lock.json together so all shipped components advertise v7.3.0 even where their code is unchanged.
Added
- GUI focus-chat mode — collapse the center column so only the right panel (Chat / Branches / Logs) remains. Today's
ProjectViewis a three-region layout: the center column (Tasks / Definition / Insights / Secrets / Trash / Settings tabs), the right panel (Chat / Branches / Logs tabs, collapsible via the existingPanelRightClosetoggle), and the bottom integrated terminal. While chatting with the agent the center column is visual noise and steals horizontal space, but there was no way to hide it without also losing the agent terminal. NewchatFocusboolean ingui/src/renderer/src/views/ProjectView/ProjectView.tsxcollapses the center column entirely and lets the right panel take the full row via a conditionalflex-1/shrink-0 + style.widthswap on its container; the panel keeps all three tabs visible per the user's spec choice, so Branches and Logs remain reachable in focus mode. Toggle surfaces: (1) a newMaximize2/Minimize2lucide button in the project header, placed immediately before the existing right-panel toggle and tooltip'dFocus chat/Exit focus(button is conditionally rendered — when the right panel is closed there is no chat to focus on, so the button hides itself rather than rendering disabled); (2)onDoubleClickon the right-panel resize divider, which the user explicitly asked for as a secondary affordance. The existingusePanelResizedrag logic usesonMouseDown+ global mousemove listeners and does not interfere withonDoubleClick; in focus mode theonMouseDownhandler is detached so the 1px column reads as a click target rather than a drag handle, and the cursor flips fromcursor-col-resizetocursor-pointer. Two small but important interlocks on the existing right-panel toggle: entering focus mode while the right panel is closed also opens it (no chat to focus otherwise), and closing the right panel while focus is on also exits focus (keeps the two states consistent). State is per-project, persisted tolocalStorageunderwf-chat-focus-<projectId>with the sameuseState+useEffectpattern that already backswf-right-panel-openandwf-right-panel-width; a seconduseEffectre-hydrates the flag from localStorage onprojectIdchange so switching projects picks up each project's own focus state. The bottom terminal panel is independent of focus mode per the user's spec choice — whateverCmd+\`` set, focus mode does not touch. No new dependencies;Maximize2/Minimize2are already in lucide-react and thecn()helper fromlib/utils.ts` covers the conditional className swap. - GUI version under the sidebar logo. The Watchfire version was previously only reachable through Settings → About. New
useState+useEffectingui/src/renderer/src/components/Sidebar.tsxcalls the existingwindow.watchfire.getVersion()IPC (defined ingui/src/preload/index.ts:33, typed ingui/src/renderer/src/env.d.ts:25, backed bygui/src/main/ipc.tsreadingpackage.json) once on mount; the expanded logo block is restructured from a singleflex items-center gap-2row into aflex flex-colstack so a smallv{version}line can sit directly under the wordmark, aligned withpx-4and tightened with-mt-1. Styled withtext-[10px] text-[var(--wf-text-muted)]to match the sidebar's existing muted-metadata treatment. Collapsed sidebar (56 px wide) is intentionally left alone — there is no room forv7.3.0next to the 24 px logo without it looking cramped, and the value is still in Settings → About. Format matchesAboutSection.tsx:13:v{version}, not bare{version}.
Changed
- All shipped components carry the
7.3.0version label.version.json7.2.1 → 7.3.0 (codenameForgepreserved — same release line, no new theme name minted), which propagates to the daemon and CLI binaries through the existingMakefileldflags wiring (-X .../buildinfo.Version=$(VERSION)) the next timemake buildruns.gui/package.jsonandgui/package-lock.json7.2.1 → 7.3.0 so the Electron app'sapp.getVersion(), thewindow.watchfire.getVersion()IPC, and now the new sidebar version line all report 7.3.0.
Fixed
- Daemon log is now size-capped at ~1 GB (500 MB active + 1 backup). Closes the cap deferral v7.2.1 called out explicitly ("No size cap (rotate manually if it grows); a corrupt or
/dev/null-overridden file does not abort daemon startup.") — one user's~/.watchfire/daemon.loggrew to 300 GB on disk before they noticed. Newinternal/daemon/cmd/daemonlog.goholds a self-rotatingio.Writer(rotatingFileWriter) that wraps the file destination inside the existingio.MultiWriter(os.Stderr, …)chain inopenDaemonLog(also moved into the new file for testability; the call site atinternal/daemon/cmd/root.go:73is unchanged). Per-file cap is 500 MiB, one numbered backup is kept (daemon.log.1), no gzip — total disk budget ≈ 1 GiB. OnWrite, ifsize + len(p) > fileCapthe writer closes the active file, drops any stale backups numbered beyond the currentbackupsconstant (defensive cleanup if a future build lowers the count), shifts numbered backups down (zero-iteration loop in the 1-backup case), promotesdaemon.log→daemon.log.1(overwriting any prior.1), and opens a fresh active file in append mode. On startup, if an existingdaemon.logis already ≥ cap (the upgrade-from-oversized case — a user upgrading with a multi-GB log on disk), the constructor rotates immediately so the next write starts in a fresh file under cap. The writer holds its ownsync.Mutexfor defensive concurrency; the stdliblogpackage already serialises calls through its own mutex, so the writer's lock is uncontended in practice but covers any caller that bypasseslog.Output(test code, future direct writers). Errors during rotation propagate back throughWriteto the stdliblogpackage, which surfaces them onos.Stderr(still wired viaio.MultiWriter) and continues; the daemon never crashes because the log can't rotate. No third-party dep added: the codebase uses stdliblogexclusively across 162 call sites ininternal/daemon/, andgopkg.in/natefinch/lumberjack.v2isn't worth the import block for ~80 lines of behaviour, especially since its main feature beyond what we ship (gzipped backups) is the one we explicitly didn't want. Tests ininternal/daemon/cmd/daemonlog_test.gocover five cases: under-cap append (1 KB write into a 4 KB-cap writer leaves onlydaemon.logon disk), cap-crossing rotation (1000 B + 100 B into a 1024 B-cap writer leavesdaemon.log.1holding the first 1000 B and a freshdaemon.logholding the 100 B), upgrade-with-oversized-existing-file (pre-populated 2 KB at a 1 KB-cap writer rotates onNew…so the pre-populated bytes land in.1and the active file is empty), multi-rotation backup-count invariant (six forced rotations at 512 B cap leave onlydaemon.log+daemon.log.1on disk — no.2,.3,.4), and a 50-goroutine concurrent-write smoke (no panic, total bytes on disk ≤ 2 ×fileCap, each goroutine'sWritereturnednil).
v7.2.1 — Forge
[7.2.1] Forge
Forge 7.2.1 closes the second wildfire-chain leak that v7.2.0 only half-fixed. Live monitoring on the watchfire-website project, May 22: with v7.2.0 deployed and the daemon writing its logs to /dev/null (Electron-launched stdio fan-out), wildfire kept hemorrhaging tasks. The chain was no longer dying outright — v7.2.0's tolerant LoadAllTasks was keeping it alive — but Generate phase #N kept emitting blog-post-style task YAMLs with titles shaped title: Write a blog post — "Headline: Subhead", the unquoted scalar's literal : was parsed as a nested mapping, the strict decoder rejected the file with mapping values are not allowed in this context, and the file was silently skipped. Net effect across one ~5-hour run: 6 of 24 generated tasks (0159, 0170, 0171, 0173, 0175, 0176) became invisible to the chain. Two fixes, both prophylactic.
Fixed
- Generate prompts now require single-quoted
title:scalars, with the colon-gotcha called out explicitly.internal/daemon/agent/prompts/wildfire-generate-system.txtandinternal/daemon/agent/prompts/generate-tasks-system.txteach gain anALWAYS single-quote the title: valueblock listing correct forms (title: 'Write a blog post — "Headline: Subhead"',title: 'Add /pricing — costs, plans, FAQ',title: 'Operator''s guide: reading the daemon logs'showing the doubled-single-quote escape), the wrong form (title: Write a blog post — "Headline: Subhead") labelled as silently-dropped, and a one-line nudge that block scalars (|-indented) are the safe form forprompt:andacceptance_criteria:. The Claude-emitted YAML was technically valid English but invalid YAML — the prompt now tells it which subset of YAML to use. - Daemon logs now persist to
~/.watchfire/daemon.log. When Watchfire.app launcheswatchfired, the daemon's stdout/stderr inherit the Electron parent's/dev/nullwiring; every[chain] Decision:,[task-load] skipping,[phase-watch], andAgent for project X exitedlog line was being discarded.internal/daemon/cmd/root.go::openDaemonLogopens~/.watchfire/daemon.login append mode at daemon start and pointslog.SetOutputatio.MultiWriter(os.Stderr, daemonLog)— foreground/dev still sees lines on stderr; Electron-launched runs now leave a forensic trail across restarts. Open is best-effort: a permission failure logs to stderr and continues. No size cap (rotate manually if it grows); a corrupt or/dev/null-overridden file does not abort daemon startup.
v7.2.0 — Forge
[7.2.0] Forge
Forge 7.2 fixes the wildfire chain dying silently after the Generate phase whenever the agent emitted a task YAML with an empty-string timestamp like started_at: "". Caught live during a watchfire-website run: Generate phase #9 created tasks 0143–0146 with started_at: "", LoadAllTasks aborted on the first parse error (cannot parse "" as "2006"), nextTaskFn returned the error, monitorProcess logged it and exited — and the daemon ended up in NO-AGENT state with four ready tasks no one would pick up. No chat session, no notification, no obvious failure signal. The fix has three layers: a tolerant Task YAML decoder, a per-file-skip loader, and tightened generate prompts so future Claude versions don't emit the bad form to begin with.
Fixed
- Wildfire chain no longer silently dies when generate writes
started_at: ""(or any other empty-string timestamp) in a new task file. Three stacked fixes, one root cause. (A)models.Task.UnmarshalYAML. New custom unmarshaler ininternal/models/task.gowalks the mapping node before decoding and rewrites empty-string scalars on time-typed fields (created_at,started_at,completed_at,updated_at,deleted_at) to!!nullso they decode tonil/ zero time instead of erroring. The defaultgopkg.in/yaml.v3time decoder callstime.Parse(time.RFC3339, "")and returnsparsing time "" as "2006-01-02T15:04:05Z07:00": cannot parse "" as "2006"; that error used to propagate up throughLoadTask→LoadAllTasks→taskMgr.ListTasks→ wildfirenextTaskFn→monitorProcess(internal/daemon/agent/manager.go:497-502), which logged it and bailed without starting a new agent. The unmarshaler is locked in by five tests ininternal/models/task_yaml_test.go: the originalstarted_at: ""repro, every-time-field-empty, explicitnull, omitted field, and real-timestamp-unchanged. (B)config.LoadAllTasksskips unparseable files instead of aborting the whole list.internal/config/tasks.go:76-83previously returned the first per-fileLoadTaskerror and the entire wildfire chain inherited it. The loop now logs[task-load] skipping <file>: <err>and continues — a single corrupt YAML can no longer poison the chain. New tests ininternal/config/tasks_test.goexercise both the corrupt-YAML skip path (genuinely malformed YAML between two good files; only good ones come back) and the end-to-end empty-started_atpath (loaded cleanly withStartedAt == nil). (C) Generate prompts tightened.internal/daemon/agent/prompts/wildfire-generate-system.txt,generate-tasks-system.txt, and the sharedwatchfire-prompt.txtnow spell out exactly which fields belong in a new task YAML (version,task_id,task_number,title,prompt,acceptance_criteria,status: ready,position,agent_sessions: 0) and explicitly forbid every timestamp field, calling out thestarted_at: ""failure mode so future Claude versions don't emit it. Belt-and-suspenders: (A) handles it if the agent still emits it, (B) keeps the chain moving even on any other parse failure we haven't predicted, (C) reduces how often the daemon ever has to dip into either fallback.
v7.1.0 — Forge
[7.1.0] Forge
Forge 7.1 fixes the GUI chat terminal regression that landed alongside v7.0.0's bytes_received cursor work, plus an intermittent "timed out waiting for previous agent to stop" toast when switching between Generate / Plan / Run All / Wildfire modes. Typing in chat mode no longer line-steps with [Agent stopped] floods between chunks, Run-All / Wildfire starts (plus phase transitions) now show the daemon-sent initial prompt instead of dropping it on the floor, and same-Process stream blips no longer replay the daemon's buffer onto a stale xterm and stack overlapping Claude Code banners. The TUI streaming path is untouched; the daemon's SubscribeRawOutput protocol is untouched; the fix is GUI-only and lives in four renderer files.
Fixed
- GUI Mode switcher — Generate / Plan / Run All / Wildfire actually start (no more silent fall-back to chat, no more "timed out waiting for previous agent to stop" toast) (#0103 / #0104). Two layered bugs, one root cause: a race between the in-flight
startAgentRPC and a concurrentstartAgent('chat')fired by ChatTab's auto-restart effect. The daemon'smanager.StartAgent(internal/daemon/agent/manager.go:165-189) atomically kills the previous agent and spawns the new one; between "previous removed fromm.agents" and "new agent inserted",GetAgentStatuslegitimately returns{IsRunning:false}for the project. AfetchStatuslanding in that window (either fromuseAgentTerminal'sonEndcallback when the dying agent's gRPC stream closed, or from ChatTab's 2 s status poll) flippeduseAgentStore.statuses[projectId].isRunningto false; ChatTab's auto-restart effect (ChatTab.tsx:52-58) sawagentStatus && !isRunning && !autoStarted.currentand immediately fired a secondstartAgent('chat'). The daemon serialised the two RPCs throughm.mu.Lock(): the first one (Generate / Plan / Run All / Wildfire) spawned its agent and returned; the second one (chat) saw the just-spawned agent inm.agents, killed it, and spawned chat in its place. Net effect: every special-mode click ended in chat, the daemon log only ever showedAgent started for project X (chat mode), and the racing second poll occasionally tipped the first call's 10 s cleanup polling into theConnectError: [unknown] timed out waiting for previous agent to stoptoast. Fixes: (1)ModesControl.handleStart(gui/src/renderer/src/views/ProjectView/ModesControl.tsx:34-42) no longer pre-callsstopAgent— the daemon'sStartAgentalready does an atomic kill+restart, and the redundant client-side stop was the original double-stop race (#0103). (2)useAgentStore(gui/src/renderer/src/stores/agent-store.ts) gains a per-projectstartAgentInFlight: Record<string, boolean>flag set onstartAgententry and cleared in afinally;fetchStatusshort-circuits while the flag is true, so the kill+restart window never propagates a phantomisRunning:falseto subscribers (#0104). Together: only onestartAgentever runs at a time, ChatTab can't race-restart the just-spawned special-mode agent, and the daemon's polling timeout no longer fires because there's no concurrent caller for the polling to fight with. Stop button (handleStop) is unchanged because it's a genuine stop, not a switch — and afterstopAgentreturns,startAgentInFlightis still false, so the chat auto-restart desired-UX behaviour ("end a special mode → chat resumes") works exactly as before. - GUI Chat terminal — typing no longer line-steps, special-mode initial prompts render, no more
[Agent stopped]floods, no stacked Claude-Code banners on reconnect (#0101 / #0102). Three stacked regressions, one chain — and one further regression introduced by the first attempt at fixing them. The fix mirrors the TUI's contract (internal/tui/terminal.go::Clearis called onAgentStartedMsg,subscribeRawOutputCmdalways passesbytesReceived=0): reset the emulator on a real generation change, never on a transient blip. (A) PhantomisRunning=falseon every transient gRPC blip (gui/src/renderer/src/stores/agent-store.ts).fetchStatus's catch block fabricated{isRunning: false}for any RPC error, butagent_service.go::GetAgentStatus(internal/daemon/server/agent_service.go:247-256) actually returns a clean{IsRunning: false}through the happy path when no agent is running — so the catch only ever fired on real transport errors (network blips, gRPC-Web framing churn while the daemon was busy streaming raw output to the same client). Every blip flipped a healthy chat agent toisRunning=false. The catch is now a quiet no-op that preserves the last-known status. (B) ChatTab auto-restart on everyisRunningflicker (gui/src/renderer/src/views/ProjectView/RightPanel/ChatTab.tsx, unchanged). With (A) fixed, the existingagentStatus && !isRunning && !autoStarted.current → handleStart()logic only fires on real "agent gone" signals; the destructivestartAgent('chat')cascade that killed the running agent + broadcast[Agent stopped]is gone at the source. (C) StalebytesReceivedRefafter a daemonProcessrestart (gui/src/renderer/src/hooks/useAgentTerminal.ts). Eachmanager.StartAgentkills the oldProcessand spawns a new one with its ownrawTotalBytescounter starting at 0; the previous code only reset the cursor on project change / component unmount, so reconnecting with a stale 50k-byte cursor against a fresh Process landed past the new buffer's end andSubscribeRawFromreturned an empty snapshot — eating the new agent's initial prompt (the "Implement Task #0001…" / Wildfire refine/generate user-prompts the user reported as missing). The subscribe effect now readsagentStatus.startedAtas a Zustand selector and includes it in the effect's deps, so the daemon'sProcess-generation transitions actually re-run the effect; on a real generation change,term.reset()clears xterm's emulator ANDbytesReceivedRefresets to 0, so the daemon's full new-Process buffer replays onto a fresh emulator with no overlap.agentStatusEqual(gui/src/renderer/src/lib/agent-utils.ts) gains astartedAtcomparison so a kill+restart in the same mode propagates to subscribers instead of being silently coalesced as "no change". (D) Reconnect-after-onEnd was replaying the daemon's buffer onto stale xterm state (the stacked-banner garbage in the user's screenshots). The first attempt at fixing (C) zeroedbytesReceivedRefinside theonEndcallback unconditionally, which forced the daemon to send its fullrawBufon the next subscribe — and those bytes carry absolute cursor-positioning escapes from Claude Code's UI redraw that landed at xterm's current cursor position, overlapping with whatever was already on screen ("ateClaudelCodegv2.1.140hat..." = "ate" from a previous prompt overwritten by "Claude Code v2.1.140" at the wrong row). The corrected behaviour:onEndleaves the cursor alone and only callsuseAgentStore.getState().fetchStatus(projectId)before scheduling a short 200 ms reconnect, so by the time the effect re-runs the store has the lateststartedAt. Same-Process blip →reconnectKeybranch → cursor preserved, noterm.reset(), no replay. New-Process restart →startedAtchanged → generation-change branch →term.reset()+ cursor=0 → fresh emulator + full buffer. The\r\n[Agent stopped]\r\nmarker write is removed entirely — when the agent truly stops, the existing ChatTab overlay shows it; when it's a phase transition or restart, the generation-change reset brings the new session in cleanly. The v7.0.0 viewport-no-snap behaviour (#0100) is preserved: the reset only fires on a realstarted_atchange, so a single long session keeps its scrollback and never snaps to byte 0 on a poll boundary.
v7.0.0 — Forge
[7.0.0] Forge
Forge brings manual task reordering across the full stack — a new TaskService.ReorderTasks RPC backs Shift+↑/↓ in the TUI and @dnd-kit-powered drag-and-drop in the GUI, replacing the silent task-number-descending sort with the spec'd (position ASC, task_number ASC) order, and a new-task-defaults-to-bottom rule so manual orderings survive task creation. The GUI chat terminal stops snapping to byte 0 mid-scroll thanks to a daemon-side cursor on SubscribeRawOutput and an idempotent client-side subscription effect. The Open-in-IDE menu now finds CLIs installed outside the GUI's stripped-down PATH (code at /usr/local/bin/code, Homebrew shims at /opt/homebrew/bin) so VS Code, Cursor, Windsurf, Zed, Sublime, and the JetBrains shims launch from the GUI even when started from Finder or the Dock.
Added
- TUI manual task reorder via
Shift+↑/↓(#0098).Shift+↑andShift+↓on a focused active row swap the selected task with its in-bounds same-status neighbour and fireTaskService.ReorderTasks(the v7 RPC landed in #0097) with the project's full new active task-number ordering. The chord lands intaskListKeys.MoveUp/MoveDown(internal/tui/keys.go) bound exclusively toshift+up/shift+down—K/Jletter chords were dropped after auditingkeys.goand finding lowercasej/kalready in use for navigation in the task list, log viewer, definition view, settings, exporter, integrations overlay, branches overlay, fleet/project insights overlay, and global settings; uppercase variants would have read as "vim chord" but in practice fire on any user holding Shift while repeatingj. The terminal handler's pre-existingshift+up/shift+downline-scroll case (internal/tui/keyhandler.go:519) sits insidehandleTerminalKeywhich only runs whenfocusedPanel == 1, so the left-panel dispatch inhandleTaskListKeycannot collide. NewModel.moveSelectedTask(direction int)ininternal/tui/actions.goderives a Draft → Ready → Done flat list of active tasks viaactiveTasksInDisplayOrder(filtersGetDeletedAt() != nil, preserves the canonical position-then-task_number order already produced by the server's #0096 sort), locates the focused row, callsreorderActiveTasksto swap against the same-status neighbour, and returns(nil, false)for cross-section moves and top/bottom boundaries — both are silent no-ops with no toast or flash. Trash mode, header rows (whereSelectedTask()returns nil), soft-deleted rows, and a nilm.connall short-circuit toreturn nilso the key is inert in modes where reorder has no defined behaviour. Optimistic flow (internal/tui/actions.go+internal/tui/msghandler.go): on the first move of a gesture,m.preReorderTaskssnapshotsm.tasks; the active list is replaced withmergeActiveWithDeleted(newOrder, m.tasks)so the soft-deleted tail stays lossless for trash-mode round-trips;taskList.SetTasks(m.tasks)+ newTaskList.SelectTaskByNumber(num int32)(ininternal/tui/tasklist.go) keep the cursor glued to the moved row across the rebuild so the user can holdShift+↓and have the highlight chase the task;m.inFlightReorder = truearms the race guard;reorderTasksCmd(new ininternal/tui/commands.go) fires the gRPC call and returnsReorderCompletedMsg{Tasks, Focused}on success orReorderFailedMsg{Err, Focused}on failure (both defined ininternal/tui/messages.go). On completion the msg handler accepts the server's response as the new authoritativem.tasks, re-selects the focused row, and clearsinFlightReorder+preReorderTasks. On failure it restoresm.tasksfrompreReorderTasks, re-selects the focused row, setsm.err = "Reorder failed — reverted"(routed through the existing error-bar toast pipeline withclearErrorAfter(3 * time.Second)), and clears the in-flight state. Race fix for poll-driven refreshes (internal/tui/msghandler.go::handleMessageonTasksLoadedMsg): wheninFlightReorder == trueand the incoming active task-number set matchesm.tasks's active set (new helpersameActiveTaskSet), the refresh is dropped — accepting it would snap the moved row back to the server's pre-RPC slot before the RPC response lands. When the sets diverge (a concurrent create / delete happened during the round-trip), the optimistic order is structurally invalid and the server view wins. Help overlay (internal/tui/help.go) gainsShift+↑"Move selected task up" andShift+↓"Move selected task down" entries under the Task List section. Tests ininternal/tui/reorder_test.go(15 cases) cover: same-section up + down swaps yielding the expected new ordering, top + bottom boundary no-ops, cross-section no-op (Draft↔Ready boundary), the Draft → Ready → Done grouping invariant ofactiveTasksInDisplayOrder, the deleted-task filter,mergeActiveWithDeletedpreserving the deleted tail, header-row dispatch returning nil, offline (m.conn == nil) dispatch returning nil,ReorderFailedMsgrevertingm.tasks+ clearing flags + populatingm.err,ReorderCompletedMsgaccepting the server response + re-selecting the focused row, the stale-refresh race drop, the diverged-set acceptance leg, and Shift+↑ dispatch throughhandleTaskListKeynot falling through to the generic navigation handler.make buildandmake testgreen; all 32 TUI tests pass (17 pre-existing + 15 new). - GUI drag-to-reorder for active tasks (#0099). The Tasks tab now reuses the same
@dnd-kitpieces already proven out bygui/src/renderer/src/components/Sidebar.tsx—DndContext+SortableContext+useSortable+arrayMovefrom@dnd-kit/sortable— so there's no new dependency and no second drag-and-drop pattern to maintain. Each active status group ("In Development" / "Todo") ingui/src/renderer/src/views/ProjectView/TasksTab/TaskGroup.tsxnow owns its ownDndContextwithPointerSensor({ activationConstraint: { distance: 8 } })andclosestCentercollision; "Failed" and "Done" groups render the legacy non-sortableTaskItemso collapsed historical groups stay click-to-open with zero drag affordance. Rows ingui/src/renderer/src/views/ProjectView/TasksTab/TaskItem.tsxget a newsortable?: booleanprop — when true,useSortable({ id: String(task.taskNumber) })is bound to the row container (transform + transition + 0.5 opacity while dragging) and aGripVerticalicon at the left of the row is the only target wired up withattributes+listeners(withonClickstopPropagation +touch-noneso trackpad gestures don't get hijacked). The row body itself keeps its existingonClick={() => setEditOpen(true)}modal-open behaviour, andPointerSensor's 8 px activation distance means a stray pixel of pointer drift on a click no longer turns into a reorder.useSortableis called unconditionally withdisabled: !sortableto keep React hook order stable on the Done/Failed leg. Drag-end inTaskGroup.handleDragEndbuilds the flat reorder payload locally —[...arrayMove(groupTasks, oldIndex, newIndex).map(t=>t.taskNumber), ...allActiveTasks.filter(notInGroup).map(t=>t.taskNumber)]— so the newTaskService.ReorderTasksRPC (#0097) receives the whole project's active order with the dragged group's new sequence first. Cross-group drags are a structural impossibility (each group'sSortableContextis its own DndContext, no shared draggables), and the sameoldIndex === -1 || newIndex === -1early-return covers the vanished-row case where a task transitionsready → donemid-drag and disappears from the group'sitemsarray between dragstart and dragend.gui/src/renderer/src/views/ProjectView/TasksTab/TasksTab.tsxpassessortable,onReorder={reorderTasks(projectId, …)}, andallTasks={activeTasks}to both sortable groups; the failed/done groups still pass nothing extra so the legacy render path is byte-identical. Store side (gui/src/renderer/src/stores/tasks-store.ts): the existingreorderTasks(projectId, taskNumbers)action — which previously didawait client.reorderTasks(...)+fetchTasks(two round-trips, visible flicker between the optimistic order the user saw mid-drag and the eventual server response) — is rewritten to snapshotprevious = get().tasks[projectId], apply the optimistic order in-memory via abyNumbermap (named tasks first in the requested order, any not in the list keep their relative position at the tail), then callclient.reorderTasks(...)and set the response'stasksas the new authoritative state. On any RPC rejection the snapshot is restored and the error is re-thrown so the caller can show a toast —TaskGroup.handleDragEndcatches and callstoast(\Reorder failed: ${err}`, 'error')through the existinguseToast()pipeline. Tests:gui/src/renderer/src/views/ProjectView/TasksTab/TaskReorder.test.mjsmirrorsTaskModal.test.mjs's pure-helper convention —arrayMove,buildReorderPayload,optimisticReorder, andrevertOnErrorare mirrored inline sonode --testruns without a TS toolchain. Eleven cases cover: bottom-to-top within ready, single-slot swap, draft-only reorder, drop-on-self → null,over: null` (vanished mid-drag) → null, cross-group active id → null, done tasks excluded from the payload, optimistic happy + partial-list paths, snapshot revert, and the failure-path harness (rpc reject → revert + toast string). Existing 104 GUI tests still pass alongside the 11 new ones. TaskService.ReorderTasksserver handler + manager method (#0097). The proto declaredTaskService.ReorderTasks(ReorderTasksRequest) returns (TaskList)(proto/watchfire.proto:515) but no server handler existed, so any call hitUnimplementedTaskServiceServer.ReorderTasksand returnedcodes.Unimplemented— blocking v7's drag-to-reorder UI in #0098 (TUI) and #0099 (GUI). Newtask.Manager.ReorderTasks(projectPath string, taskNumbers []int) ([]*models.Task, error)mirrors the shape of `project.Manager.ReorderP...
v6.0.0 — Phoenix
[6.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. The Cursor backend plugs into the existing Backend registry with no framework changes.
Added
- Cursor Agent CLI as a sixth first-class agent backend (#0088, closes upstream issue #34). New
internal/daemon/agent/backend/cursor.goimplementing the fullBackendinterface 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 (missing files skipped), composed Watchfire system prompt installed asAGENTS.md, headlesscursor-agent --workspace <worktree> --printlaunch with the yolo / trust flag, JSONL transcript located viaLocateTranscriptand rendered throughFormatTranscriptin the same## Role\n\n...shape every other backend uses.internal/daemon/metrics/parser.go::GetParserhandlescursor/cursor-agent.DefaultSettings.Agentsincludes"cursor": {Path: ""}. TUI (internal/tui/globalsettings.go) and GUI (Settings/AgentPathsSection.tsx,Settings/searchIndex.ts,AddProject/StepAgent.tsx) includecursorin search keywords and the agent picker. The per-task → per-project → global →claude-codeselection chain treats"cursor"as a valid value with no special-casing. - TUI Project Settings sidebar refactor (#0090 + #0091). The per-project Settings tab (
internal/tui/settings.go) 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+Tabwalks the sidebar;↑/↓(j/k) walks rows inside the active section;/opens a search overlay that matches across labels + section breadcrumbs andEnterjumps to the matched row. General surfaces Name + Color + Agent (existing) plus new Sandbox cycle (auto/sandbox-exec/off—Project.Sandboxwas already in the model, never surfaced) and Status cycle (active/archived, drives the v6 archive flow). Automation regroups the three existing auto-* toggles. Metadata is a read-only diagnostic block (project_id, path, default_branch resolved from.git/HEAD, created_at, updated_at, next_task_number, status);ycopies the focused value to the clipboard viapbcopy/xclip/wl-copy. Secrets shows path / size / mtime for.watchfire/secrets/instructions.mdandeshells out to$EDITORdirectly on the file (no temp-file round-trip — secrets edit in place). Theeditor.Findhelper is shared with the Definition tab. - Project Notifications — per-event overrides (#0091). New
Notificationssection in the project Settings sidebar. Master mute (existing) plus a newOverride per-event preferencestoggle that gates per-event Enabled toggles fortask_failed/run_complete/weekly_digest. Per-event rows render disabled (greyed) while override is off so the inherited values stay visible without being editable. NewQuiet hours overridetoggle gates two HH:MM text inputs (start/end); same disabled-while-off treatment. Model:ProjectNotificationsgainsOverrideEvents bool+Events map[string]ProjectEventPref+QuietHoursOverride *QuietHoursConfig, allomitempty. Pre-v6project.yamlfiles load identically (round-trip test ininternal/config/projects_test.go::TestProjectYAMLRoundTripPreV6confirms no fields materialise). Wire shape: fullProjectNotificationsblock round-trips through a new optionalnotificationsfield onUpdateProjectRequest; the legacynotifications_mutedfield stays usable for callers that only flip the master mute. - Integrations — per-project scoping (#0091). New
Integrationssection in the project Settings sidebar. GitHub auto-PR toggle binds the project to membership in the global~/.watchfire/integrations.yaml→github.project_scopeslist via the newProjectService.SetGitHubAutoPRScopeRPC (no project YAML change — global integrations.yaml stays the source of truth). Slack channel + Discord guild ID text fields persist on the project YAML under a newintegrations:block (models.ProjectIntegrations); empty string clears the binding (= inherit global default). NewProjectService.SetProjectIntegrationBindings(project_id, slack_channel, discord_guild_id) returns ProjectRPC writes both bindings atomically. TheProjectproto gains aProjectIntegrationssub-message; thegithub_auto_prboolean is fanned in at marshal time from the global config so the UI sees a single coherent picture. - Danger-zone actions — Archive / Regenerate ID / Reset numbering / Prune merged branches / Unregister (#0091). New
Danger zonesection in the project Settings sidebar surfaces five destructive actions. Each row goes through a y/N confirm in the status bar (confirmSettings*modes wired throughinternal/tui/keyhandler.go::handleConfirmKey) before firing. Archive project flipsStatusbetweenactive/archivedvia the existingUpdateProjectRPC; archived projects stop auto-starting tasks and are dropped from the dashboard active list (EnsureProjectRegisteredalready reactivates on contact, preserved). Regenerate project ID mints a new UUID through the newProjectService.RegenerateProjectIdRPC, rewrites.watchfire/project.yaml+ the global index entry atomically (v6.0 atomic-writeSaveYAML), preserves position, and logs the prior ID for diagnostics. Reset task numbering through the newProjectService.ResetTaskNumberingRPC (wraps a newconfig.HighestTaskNumberhelper) setsnext_task_number = highest_existing + 1, or1when no tasks exist. Prune merged branches reuses the existingBranchService.PruneBranches. Unregister project drops the entry from~/.watchfire/projects.yamlvia the newProjectService.UnregisterProjectRPC; local.watchfire/is preserved soEnsureProjectRegisteredre-adds the project on next contact. - TUI Trash filter mode — deleted tasks are visible + restorable (#0093). Tasks soft-delete (set
deleted_at) but the TUI never surfaced them —xon a task hid it forever as far as the TUI was concerned, while the GUI'sgui/src/renderer/src/views/ProjectView/TrashTab.tsxhad full restore + permanent-delete affordances. The Tasks tab (internal/tui/tasklist.go) now carries a filter mode toggle:D(capital, kept clear of the existingdtask-diff binding) flips the rendered list between the active subset and the soft-deleted subset. Filter, not new tab — deleted tasks are a Tasks subset, not a peer concept, and a 4th left-tab would crowd the bar.loadTasksCmd(internal/tui/commands.go) now requestsIncludeDeleted: trueso both subsets are available without a second round trip;TaskList.rebuildfilters by mode. Trash view renders deleted tasks under a singleDeleted (N)section in the same descending-by-task-number order; an empty trash showsNo deleted tasks. Press D to return.. The Tasks tab label flips toTasks · Trash(internal/tui/header.go) and a high-contrast red status-bar banner replaces the normal hint row withTrash mode — N deleted task(s) · u restore · x delete · D back. Trash row keys:ucallsTaskService.RestoreTask(already on the proto, kept),xarms a y/N permanent-delete confirm (confirmPermanentDeletemode, distinct from the existing soft-delete confirm),Enteropens the read-only edit form,Dflips back. Active-mode mutating keys (a/s/S/w/!/r/t/d) are no-ops in trash mode — the banner is the user's affordance reminder. Entering trash mode clears any active text-search filter (/) since the search predicate matched against the live list. NewTaskService.PermanentDeleteTask(TaskId) returns Emptyproto RPC +taskService.PermanentDeleteTaskhandler ininternal/daemon/server/task_service.goshells out to a newtask.Manager.PermanentDelete(projectPath, taskNumber, branchMerged)callback-driven helper that verifiesdeleted_at != nil(refuses if not), refuses if the caller-injectedbranchMergedreports thewatchfire/<n>branch unmerged (branchSafeToDeletewalksgit branch --listthengit branch --merged <target>; missing branch = safe, unmerged commits = blocked), then removes the task YAML via the existingconfig.DeleteTaskFileand the optional<n>.metrics.yamlsibling viaos.Remove. Watcher debouncing already swallows the YAML delete event so the daemon catches up on the next reload tick. Help overlay (Ctrl+H) gainsD toggle trash viewandu Restore (trash mode)rows. Tests ininternal/tui/tasklist_test.gocover active-mode hides deleted, trash-mode renders only deleted, empty-trash hint, mode flip-back, search-filter cleared on entry, and key handler routesu/x/Dto the trash-mode actions; tests ininternal/daemon/task/manager_test.gocover the DeleteTask→RestoreTask round trip viaListTasks, the unmerged-branch refusal (YAML stays on disk), the active-task refusal (PermanentDeleteonly operates on soft-deleted rows), and the YAML+metrics happy-path cl...
v5.0.0 - Flare
[5.0.0] Flare
Flare closes the inbound loop Beacon left half-open and hardens the run-all path. The two "Known issues" filed against Beacon — the missing GitHub PR-merge handler and the missing Slack HTTP transport — both ship; the inbound surface gains OAuth, multi-host parity (GitHub Enterprise / GitLab / Bitbucket), per-IP rate limiting, Slack interactive components, and Discord guild auto-registration; the run-all silent-halt bug, the chat-tab repaint loop, and the buried failure_reason are all fixed; and the global settings UI is reorganized into searchable category sub-pages.
Added
- GitHub PR-merge handler — closes the v4.0 Beacon auto-PR loop (#0075). New
internal/daemon/echo/handler_github.goregistered atPOST /echo/github?project=<id>parsesX-GitHub-Event/X-Hub-Signature-256/X-GitHub-Delivery, resolves the per-project HMAC secret from the keyring, runsverify.VerifyGitHub, deduplicates against the LRU+TTL idempotency cache, narrows onevent == "pull_request" && action == "closed" && pull_request.merged == true, then matches the Watchfire task bypull_request.head.ref == watchfire/<n>and callstask.MarkDoneIfNotAlready+ emits a PulseRUN_COMPLETEnotification titled<project> — PR #<number> merged. Closes the v4.0 Beacon "Known issue" #1. - Slack slash-command HTTP transport — closes the v4.0 Beacon Slack-parity gap (#0076). New
internal/daemon/echo/handler_slack_commands.gotranslates the URL-encoded slash-command form body (command,text,team_id,channel_id,user_id,trigger_id) into a call against the shared transport-agnosticcommands.Route(...)router, then rendersCommandResponseas Slack response JSON ({response_type: "in_channel" | "ephemeral", text, blocks})./watchfire status / retry / cancelnow works in Slack at parity with the Discord interactions endpoint that shipped in Beacon. Closes the v4.0 Beacon "Known issue" #2. - OAuth bot tokens for Slack and Discord (#0077). Replaces the v4.0 paste-a-signing-secret model with a proper OAuth install flow. Slack:
xoxb-...bot token from the workspace OAuth callback, used forchat.postMessageso slash responses can include rich attachments and DM the originator on private failures. Discord:Authorization: Bot <token>for inbound auth and command registration. New "Connect Slack" / "Connect Discord" buttons in the Integrations settings UI launch the flow in the user's default browser; success surfaces aConnected as <bot username>pill. The legacy signing-secret + public-key path stays additive for users mid-cutover. - GitHub Enterprise / GitLab / Bitbucket inbound parity (#0078). Per-project
github_hostfield onmodels.InboundConfiglets the existing GitHub HMAC-SHA256 verifier target arbitrary GitHub Enterprise hostnames (the v7.0 outbound auto-PR path picks up the same field). Newinternal/daemon/echo/handler_gitlab.goverifiesX-Gitlab-Token(per-project shared secret), narrows onMerge Request Hookevents withaction: merge. Newinternal/daemon/echo/handler_bitbucket.goverifiesX-Hub-Signature(HMAC-SHA256), narrows onpullrequest:fulfilledevents. Settings UI surfaces a "Git host" picker on inbound config. - Per-IP rate limiting on the inbound HTTP server (#0079). Per-IP token bucket via
golang.org/x/time/rate, default 30 req/min/IP across every/echo/*route, configurable throughmodels.InboundConfig.RateLimitPerMin(0disables). Idempotent deliveries already in the LRU cache do NOT count against the bucket. On 429, the daemon logs a single WARN per IP per minute to avoid log flooding under a sustained flood. - Slack interactive components — buttons + cancel-reason modal (#0080). The v7.0 Slack outbound TASK_FAILED Block Kit template gains three action buttons:
Retry,Cancel,View in Watchfire. New inbound endpointPOST /echo/slack/interactivityhandles theblock_actionsandview_submissionpayloads with the same v0 HMAC verification + 5-minute drift window as the slash-commands endpoint. Button presses route throughcommands.Routeso aRetryclick is the exact equivalent of/watchfire retry.Cancelopens a Slack modal that asks "Why are you cancelling?"; the supplied reason lands intask.failure_reason. - Discord slash-command auto-registration on guild join (#0081). The daemon now enumerates the guilds the bot is in at startup and POSTs the three slash-command schemas to each via the existing
internal/cli/integrations_discord.go::registerForGuildhelper; it also subscribes toGUILD_CREATEGateway events so a freshly-added guild gets commands within 30 seconds (no CLI step). The Settings UI lists every guild with a ✓ / ✗ registration pill. The manualwatchfire integrations register-discord <guild>CLI stays as a fallback. Discord's commands API is upsert-style, so re-running is safe. - Settings UI: macOS-style category sub-pages with search (#0082). Both GUI (
gui/src/renderer/src/views/Settings/GlobalSettings.tsx) and TUI (internal/tui/settings.go) replace the single long scrolling page with a two-pane layout — left sidebar of eight categories (Appearance, Defaults, Agent Paths, Notifications, Integrations, Inbound, Updates, About), right pane shows only the selected category. New search input filters categories AND surfaces individual matching controls with category breadcrumbs; clicking a result navigates to the category and pulses the matching field for ~1.5s. GUI:Cmd/Ctrl+Ffocuses search,Escclears,Up/Down/Enternavigate. TUI:/opens a search overlay with the same field-jumping behaviour. Deep-link routes (#integrationsetc.) still work.
Fixed
- Run-all silently halted on auto-merge failure (#0083). When
internal/daemon/agent/taskdone.go::HandleTaskDone's silent merge failed (dirtymain, merge conflict, post-merge hook failure), the chain stopped — but silently: the task YAML still showedstatus: done+success: true, no notification fired, and the user was left wondering why their queue stalled.onTaskDoneFnnow returns a structuredTaskDoneResult{Outcome, Reason}(withTaskDoneOK/TaskDoneMergeFailed/TaskDoneCancelled) instead of a bare bool;monitorProcessbranches onresult.Outcome == TaskDoneMergeFailedand emits a TASK_FAILED-shaped notification before the chain decision;runSilentMergepopulates the task's newmerge_failure_reasonfield (yaml: merge_failure_reason,omitempty, exposed via proto + GUI/TUI). The chain-stop semantics are unchanged — the user still has to clean upmainmanually — but the silence is gone. - GUI chat-tab repainted multiple times on project switch (#0084). Verified the symptom under the new
RightPanel/ChatTab.tsxarchitecture (the v5.0 spec had referenced the now-deletedChatPanel.tsx), then locked in single-mount + single-start guards: the auto-startuseEffectdeps tightened to[!!agentStatus, isRunning, projectId]so a staleagentStatusreference from the previous project no longer fireshandleStarton a transient render edge; theautoStarted.current = falsereset onprojectIdchange runs before the auto-start check; regression test ingui/src/renderer/src/views/ProjectView/RightPanel/ChatTab.test.tsxsimulates rapid project switching and assertshandleStartfires exactly once per navigation. - Failed-task UI hid the reason behind two clicks (#0085).
TaskStatusBadgenow carries atitle=tooltip for agent-reported failures (it already had one for merge failures only), populated by a new exported pure helpercomputeBadgeTooltipthat prefersMerge failed: …overFailed: …when both reasons are set and truncates to 500 runes.TaskItempassesfailureReason={task.failureReason}into the badge alongsidemergeFailureReason.TaskModal's tab decision is now lazy inuseState(() => …)AND kept in sync via the existing effect, sodonetasks land on the Inspect tab on first paint without a flicker through the form-tab state. The TUI task list (internal/tui/tasklist.go) renders an inline preview of both reasons (merge-failure precedence) under the[✗]glyph.
Tests
- Inbound framework coverage gap closed (#0070). Filled out
internal/daemon/echo/'s test surface — every signature verifier (GitHub HMAC-SHA256, Slack v0, Discord Ed25519) covers golden-path + every rejection mode (missing header, malformed signature, drift overshoot, replay window);idempotency.go's LRU+TTL behaves correctly under concurrent access, eviction, and TTL refresh;commands.Routeround-tripsstatus/retry <task>/cancel <task>against a mocked task manager.
Migration
- All Flare features are additive — projects upgrade with no behaviour change.
- Inbound: existing signing-secret + public-key configs continue to work; OAuth is opt-in via the new "Connect Slack" / "Connect Discord" buttons. The new
RateLimitPerMinfield defaults to 30; set to 0 to disable. - Multi-host inbound: leave
github_hostempty for github.com; set per-project for GitHub Enterprise. GitLab and Bitbucket handlers are inactive until their per-project secret is configured. - Discord auto-registration runs on next daemon start — existing guilds get re-upserted (idempotent). The CLI
watchfire integrations register-discord <guild>stays available as a fallback. - Run-all halt fix:
onTaskDoneFn's signature changed fromfunc(...) booltofunc(...) TaskDoneResult. Internal callback only — no external API impact, but third-party forks pinning to the old signature will need to update.
v4.0.0 - Beacon
[4.0.0] Beacon
Beacon is the consolidated dashboard / notifications / insights / integrations release — glanceable dashboard, proactive OS notifications, retrospective insights, outbound + inbound integrations.
Added
- Dashboard aggregate status bar — single muted status line
N working · N needs attention · N idle · N done todaybetween the dashboard header and the project grid; counts derived from existing zustand stores so it updates live with no new gRPC. - Dashboard filter chips — pill chips (
All,Working,Needs attention,Idle,Has ready tasks) with live counts; selection persists inlocalStorage[wf-dashboard-filter]. Predicates shared viagui/src/renderer/src/lib/dashboard-filters.ts. - Elapsed-time badge on running ProjectCards — ticking
Ns / Nm / Nh Mmnext to the agent badge, sourced from a newAgentStatus.started_atproto field stamped inRunningAgent.StartedAt. Flips tovar(--wf-warning)past 30 minutes. - Last-activity timestamp on dashboard cards —
Active now / 5m ago / 4h ago / 2mo agosegment derived from the most recent taskupdated_at. Hand-rolled relative-time formatter ingui/src/renderer/src/lib/relative-time.ts. - Live PTY last-line preview on dashboard cards — latest non-blank terminal line in monospace muted text, throttled to 4 Hz. Singleton subscription manager in
gui/src/renderer/src/stores/agent-preview-store.tsref-counts the underlyingAgentService.SubscribeScreenstream. - Needs-attention treatment for failed tasks — red-tinted card border + header
AlertTrianglechip +N failedsegment in the counts row + red progress segment when any task hasstatus === 'done' && success === false. - Current-task surfacing on running ProjectCards — replaces the misleading
Next:line withWorking: <current task title>(withFlameicon) when the agent is actively running. No proto change — uses the existingAgentStatus.task_title. - Shell-count chip on running ProjectCards — terminal icon + count from
useTerminalStorefiltered by alive sessions for the project; pulses when any session emitted output in the last 2s. Click expands the bottom panel. - Dashboard grid/list layout toggle —
LayoutGrid/Rows3toggle in the header; list mode renders one ~46px row per project. Selection persists inlocalStorage[wf-dashboard-layout]. Per-project rendering ingui/src/renderer/src/views/Dashboard/ProjectRow.tsx. - Notification bus — new
internal/daemon/notifypackage with a typedBus, channel fan-out (slow-consumer drop), stableMakeID(sha256(kind|project_id|task_number|emitted_at_unix)[:8]), and JSONL append to~/.watchfire/logs/<project_id>/notifications.logfor headless fallback. - TASK_FAILED OS notification — fires from
internal/daemon/server/task_failed.go::emitTaskFailedondone && !success. Title<project> — task #NNNN failed, body is the task title + optional failure reason. - RUN_COMPLETE OS notification — fires at the falling edge of every autonomous run (single-task, start-all, wildfire) bounded by a new
RunningAgent.RunStartedAt. BodyN tasks done · M failedover the run window. - Bundled notification sounds —
assets/sounds/task-{done,failed}.wav(mono 22050 Hz, ~25 KB each). PureshouldPlaySound(kind, prefs)decision ingui/src/renderer/src/stores/notifications-sound.ts. OS toast goes silent precisely when the renderer plays its own audio. - Dynamic system tray menu —
internal/daemon/tray/tray.gorebuilds on every project / task / agent / settings change; sections forNeeds attention/Working/IdleplusNotifications (N today) ▸submenu reading the JSONL fallback. Click-through routes via the newDaemonService.SubscribeFocusEventsstream. - Notification preferences UI — TUI (
internal/tui/globalsettings.go) and GUI (gui/src/renderer/src/views/Settings/NotificationsSection.tsx) expose master / per-event / sounds / volume / quiet-hours / per-project mute. Schema underdefaults.notificationsin~/.watchfire/settings.yaml. Gating helpermodels.ShouldNotify. - Inline diff viewer — new
internal/daemon/diffpackage resolves diffs pre-merge (<merge-base>...HEADonwatchfire/<n>) and post-merge (locates the merge commit viagit log --grep). StructuredFileDiffSet; cap at 10000 lines; cache at~/.watchfire/diff-cache/<project_id>/<task_number>.json. GUI Inspect tab + TUI overlay (bound tod). - Per-task metrics capture —
<n>.metrics.yamlsiblings carrying duration, exit reason, agent, tokens, cost. Newinternal/daemon/metricspackage with parsers for Claude Code, Codex, opencode, Gemini, Copilot (stub). Capture from a non-blocking goroutine onhandleTaskChanged. Newwatchfire metrics backfillCLI. - Per-project Insights view —
internal/daemon/insights/project.goaggregates one project's tasks per window. New GUI Insights tab + TUI overlay (bound toi) with KPI strip, stacked-bar tasks-per-day, agent donut, duration histogram.localStorage[wf-insights-window]persists the 7d / 30d / 90d / All selector. - Cross-project Insights rollup —
internal/daemon/insights/global.goaggregates the whole fleet per window; cached at~/.watchfire/insights-cache/_global.json. Dashboard rollup card under the Beacon status bar; TUI fleet overlay bound toCtrl+f. - Report export (CSV + Markdown) — shared
InsightsService.ExportReportRPC withoneofscope (project_id/global/single_task). Markdown templates ininternal/daemon/insights/templates/; CSV uses# section: <name>headers. Single<ExportPill>component on the dashboard + ProjectView headers; TUI bindsCtrl+e. - Weekly digest notification —
digestRunnerschedules with a re-armabletime.Timerfrommodels.DigestSchedule.NextFire(DST-stable, with 24-hour catch-up on daemon start). Markdown rendered to~/.watchfire/digests/<YYYY-MM-DD>.mdregardless of toast suppression. NewWEEKLY_DIGESTnotification kind +FOCUS_TARGET_DIGEST. - Outbound delivery framework + webhook adapter — new
internal/daemon/relaypackage with anAdapterinterface and aDispatchersubscribing tonotify.Bus. Per-adapter retry ([500ms, 2s, 8s]) + circuit breaker (3 failures / 5-minute window). GenericWebhookAdapterPOSTs the canonical payload withX-Watchfire-Signature: sha256=<hex>HMAC. Secrets via OS keyring (internal/config/keyring.go) with file-store fallback. - Slack adapter (Block Kit messages) —
internal/daemon/relay/slack.gorenders threetext/templateBlock Kit envelopes (TASK_FAILED / RUN_COMPLETE / WEEKLY_DIGEST) with header / section / context / actions blocks. Project-color →:large_<color>_square:shortcode map inslack_color.go. - Discord adapter (rich embeds) —
internal/daemon/relay/discord.gorenders three embed envelopes with project-color tinting. SharedhexToInt/rfc3339template helpers. Defensive 4000-rune description trim with single-WARN log on overflow. Newwatchfire integrationsCLI parent withlistandtestsubcommands. - GitHub auto-PR creation — opt-in per project via
github.auto_pr.enabled: true. End-of-task lifecycle ininternal/daemon/git/pr.go::OpenPR:gh auth status→ parse<owner>/<repo>→git push --force-with-lease→ render PR body viapr_body.md.tmpl→gh api -X POST /repos/:owner/:repo/pulls. Sentinel errors distinguish silent fallback (one WARN per project lifetime) from per-attempt failures. - Integrations settings UI (GUI + TUI) — new
IntegrationsServicegRPC service withList/Save/Delete/TestRPCs;Savecarries aoneofpayload, secrets are write-only on the wire. GUIIntegrationsSection.tsxwith per-type detail panels; TUI overlay reachable viaCtrl+I. - Inbound HTTP server framework —
internal/daemon/echo/server.gobindsListenAddr(default127.0.0.1:8765), 5 s graceful shutdown drain, 1 MiB body cap + panic recovery middleware, unauthenticated/echo/health.RegisterProvider(method, path, handler)for plug-in handlers. Bind failure logs ERROR but doesn't crash the daemon. - Signature verification —
internal/daemon/echo/verify.goshipsVerifyGitHub(HMAC-SHA256 againstsha256=<hex>),VerifySlack(HMAC-SHA256 overv0:<timestamp>:<body>with 5-minute drift),VerifyDiscord(Ed25519 overtimestamp || body, same drift) — all constant-time. - Idempotency cache —
internal/daemon/echo/idempotency.gois an LRU+TTL cache (1000 entries / 24h,container/list-backed,sync.Mutex-protected);Seen(key)refreshes TTL on hit. - Per-task lifecycle helpers + command router —
internal/daemon/echo/commands.go::Route(ctx, cmd, subcmd, rest, CommandContext) CommandResponsepowers slash-command transports. Three commands (status/retry <task>/cancel <task>);CommandResponse{text, blocks, ephemeral, in_channel}is transport-agnostic. - Discord interactions endpoint —
internal/daemon/echo/handler_discord.goexposesPOST /echo/discord/interactionswith end-to-end Ed25519 verification + replay window + idempotency. PING → PONG; APPLICATION_COMMAND → dispatch tocommands.Route, render viadiscord_render.go::RenderInteraction. Slash-command registration viawatchfire integrations register-discord <guild_id>(idempotent). - Inbound settings UI (GUI + TUI) —
gui/src/renderer/src/views/Settings/InboundSection.tsxshows a Listening pill polled at 5 s, editableListenAddr+PublicURLwith restart button, Copy-as-<provider>-URL buttons, four write-only secret inputs, per-provider last-delivery timestamps. TUI mirrors via a new "Inbound" tab inside the Integrations overlay.
Changed
- Dashboard auto-sorts projects by activity — replaces raw
positionorder with bucketing into needs-attention → working → has-ready-tasks → idle (input-array index as final tiebreaker for stability). Predicate helpers in...