Skip to content

Releases: watchfire-io/watchfire

v8.0.0 - Inferno

Choose a tag to compare

@github-actions github-actions released this 30 Jun 12:18

[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 NotificationService stream, and on every event it plays its own .wav sound and calls the notifications:emit IPC, which shows a native Notification. 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's start() early-returns unless isHomeWindow(), and App.tsx only calls startNotifications() on the home window. A new gui/src/renderer/src/lib/window-scope.ts derives 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 raw net), 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.ts kept a single module-global win set via setWindow(w) and pushed every pty-output / pty-exit to it, so once a second project window existed, a terminal's bytes landed in whichever window was setWindow'd last — project A's echo could surface in project B's terminal. Each PtySession now carries the windowId of the window that spawned it: the pty-create IPC handler resolves the originating window via BrowserWindow.fromWebContents(event.sender)?.id and createPty(cwd, windowId) records it, and a per-session send(windowId, channel, data) (resolving BrowserWindow.fromId and guarding isDestroyed()) routes onData / onExit only to the owning window. The module-global win / setWindow (and the setPtyWindow(...) call in index.ts) are gone. A new destroyForWindow(windowId) kills and forgets only that window's sessions and is wired to each window's closed event in windows.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 FocusEvent over DaemonService.SubscribeFocusEvents that already carries the projectId / target / task it points at, but the renderer's focus subscriber (gui/src/renderer/src/stores/focus-store.ts) only ever called the generic focusWindow() 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 calls window.watchfire.focusProjectWindow(projectId, target, taskNumber) so the focus-project-window IPC opens (or focuses, never duplicates) that project's own window and routes its renderer to the right tab/task via the existing project-focusonProjectFocusrequestFocus path (the same channel mission-control click-through uses, #0103); a global event or a WEEKLY_DIGEST event calls openHomeWindow() 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.tsx now gates startFocus() on isHomeWindow(), mirroring the D1 single-notifier election so the home window is the single tray-event router for the whole app. The generic focus-window IPC 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 existing wf-right-panel-width localStorage 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 four update-* events (gui/src/main/auto-updater.ts) targeted a single window, and focus-window / the notifications:emit click handler (gui/src/main/ipc.ts) targeted BrowserWindow.getAllWindows()[0] — fine when there was one window, wrong once a project window exists. A new broadcast(channel, ...args) in windows.ts iterates 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 call createProjectWindow(projectId) (opening the project's own window or focusing it if already open) and then send notifications:click; because a freshly-created window's renderer isn't loaded yet, the send is deferred to did-finish-load when webContents.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 a win.on('focus') listener and exposed as getMostRecentlyFocusedWindow(), falling back to the home window — instead of an arbitrary one.
  • All three GUI markdown surfaces now use the rich MarkdownEditor instead 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 — prompt and acceptance_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...
Read more

v7.4.0 — Forge

Choose a tag to compare

@github-actions github-actions released this 16 Jun 15:18

[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 ~/.watchfire to its fsnotify set (for projects.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.go logged 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 across daemon.log + daemon.log.1) as pure watcher.go noise — grep -avc watcher.go returned 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) processEvents now skips events whose path is the daemon's own log via a new isSelfLogPath (matches daemon.log and rotated daemon.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 new WATCHFIRE_DEBUG env 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.log instead 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.log plus an equally large daemon.log.1. Handing that to the rotating writer would just rename the oversized active file to .1 and keep it. New reclaimOversizedDaemonLogs (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 in daemonlog_test.go (truncate-active-and-drop-backup; leave-normal-files-untouched).
  • A launch failure can no longer strand a ready task and silently drop the run to chat. In internal/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, and NewProcess. If any of those failed (e.g. PTY allocation under fd pressure from the loop above), the task was already persisted as started with no agent behind it; the chain logged failed to start next and returned to idle, leaving the task in ready forever (exactly the Njord #17 signature: started_at + sessions=1, no session log, no commits, branch at main). The stamp now runs after NewProcess succeeds, so a spawn failure leaves the task untouched and retryable. Additionally, the chain's failed to start next path now emits a TASK_FAILED notification (via emitTaskDoneFailure) 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.go scans 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 phantom rate_limited issue, which monitorProcess treats 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 literal authentication_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 in internal/daemon/agent/issue_test.go assert that "Implemented rate limiting with a token-bucket backoff", a task title containing "rate limiting, backoff", a 429 code 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 global daemon.log is now small. New config.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 in daemon.log: daemon start/stop, update checks, OAuth, the cross-project weekly digest, integration config WARN/ERRORs, and the auto-PR gh-unavailable / non-github / PR-error fallbacks.
  • Global daemon.log cap 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_DEBUG env 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

Choose a tag to compare

@github-actions github-actions released this 29 May 14:06

[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 ProjectView is a three-region layout: the center column (Tasks / Definition / Insights / Secrets / Trash / Settings tabs), the right panel (Chat / Branches / Logs tabs, collapsible via the existing PanelRightClose toggle), 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. New chatFocus boolean in gui/src/renderer/src/views/ProjectView/ProjectView.tsx collapses the center column entirely and lets the right panel take the full row via a conditional flex-1 / shrink-0 + style.width swap 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 new Maximize2/Minimize2 lucide button in the project header, placed immediately before the existing right-panel toggle and tooltip'd Focus 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) onDoubleClick on the right-panel resize divider, which the user explicitly asked for as a secondary affordance. The existing usePanelResize drag logic uses onMouseDown + global mousemove listeners and does not interfere with onDoubleClick; in focus mode the onMouseDown handler is detached so the 1px column reads as a click target rather than a drag handle, and the cursor flips from cursor-col-resize to cursor-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 to localStorage under wf-chat-focus-<projectId> with the same useState + useEffect pattern that already backs wf-right-panel-open and wf-right-panel-width; a second useEffect re-hydrates the flag from localStorage on projectId change 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 — whatever Cmd+\`` 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 + useEffect in gui/src/renderer/src/components/Sidebar.tsx calls the existing window.watchfire.getVersion() IPC (defined in gui/src/preload/index.ts:33, typed in gui/src/renderer/src/env.d.ts:25, backed by gui/src/main/ipc.ts reading package.json) once on mount; the expanded logo block is restructured from a single flex items-center gap-2 row into a flex flex-col stack so a small v{version} line can sit directly under the wordmark, aligned with px-4 and tightened with -mt-1. Styled with text-[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 for v7.3.0 next to the 24 px logo without it looking cramped, and the value is still in Settings → About. Format matches AboutSection.tsx:13: v{version}, not bare {version}.

Changed

  • All shipped components carry the 7.3.0 version label. version.json 7.2.1 → 7.3.0 (codename Forge preserved — same release line, no new theme name minted), which propagates to the daemon and CLI binaries through the existing Makefile ldflags wiring (-X .../buildinfo.Version=$(VERSION)) the next time make build runs. gui/package.json and gui/package-lock.json 7.2.1 → 7.3.0 so the Electron app's app.getVersion(), the window.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.log grew to 300 GB on disk before they noticed. New internal/daemon/cmd/daemonlog.go holds a self-rotating io.Writer (rotatingFileWriter) that wraps the file destination inside the existing io.MultiWriter(os.Stderr, …) chain in openDaemonLog (also moved into the new file for testability; the call site at internal/daemon/cmd/root.go:73 is unchanged). Per-file cap is 500 MiB, one numbered backup is kept (daemon.log.1), no gzip — total disk budget ≈ 1 GiB. On Write, if size + len(p) > fileCap the writer closes the active file, drops any stale backups numbered beyond the current backups constant (defensive cleanup if a future build lowers the count), shifts numbered backups down (zero-iteration loop in the 1-backup case), promotes daemon.logdaemon.log.1 (overwriting any prior .1), and opens a fresh active file in append mode. On startup, if an existing daemon.log is 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 own sync.Mutex for defensive concurrency; the stdlib log package already serialises calls through its own mutex, so the writer's lock is uncontended in practice but covers any caller that bypasses log.Output (test code, future direct writers). Errors during rotation propagate back through Write to the stdlib log package, which surfaces them on os.Stderr (still wired via io.MultiWriter) and continues; the daemon never crashes because the log can't rotate. No third-party dep added: the codebase uses stdlib log exclusively across 162 call sites in internal/daemon/, and gopkg.in/natefinch/lumberjack.v2 isn'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 in internal/daemon/cmd/daemonlog_test.go cover five cases: under-cap append (1 KB write into a 4 KB-cap writer leaves only daemon.log on disk), cap-crossing rotation (1000 B + 100 B into a 1024 B-cap writer leaves daemon.log.1 holding the first 1000 B and a fresh daemon.log holding the 100 B), upgrade-with-oversized-existing-file (pre-populated 2 KB at a 1 KB-cap writer rotates on New… so the pre-populated bytes land in .1 and the active file is empty), multi-rotation backup-count invariant (six forced rotations at 512 B cap leave only daemon.log + daemon.log.1 on disk — no .2, .3, .4), and a 50-goroutine concurrent-write smoke (no panic, total bytes on disk ≤ 2 × fileCap, each goroutine's Write returned nil).

v7.2.1 — Forge

Choose a tag to compare

@github-actions github-actions released this 26 May 14:56

[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.txt and internal/daemon/agent/prompts/generate-tasks-system.txt each gain an ALWAYS single-quote the title: value block 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 for prompt: and acceptance_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 launches watchfired, the daemon's stdout/stderr inherit the Electron parent's /dev/null wiring; every [chain] Decision:, [task-load] skipping, [phase-watch], and Agent for project X exited log line was being discarded. internal/daemon/cmd/root.go::openDaemonLog opens ~/.watchfire/daemon.log in append mode at daemon start and points log.SetOutput at io.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

Choose a tag to compare

@github-actions github-actions released this 19 May 15:45

[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 in internal/models/task.go walks 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 !!null so they decode to nil / zero time instead of erroring. The default gopkg.in/yaml.v3 time decoder calls time.Parse(time.RFC3339, "") and returns parsing time "" as "2006-01-02T15:04:05Z07:00": cannot parse "" as "2006"; that error used to propagate up through LoadTaskLoadAllTaskstaskMgr.ListTasks → wildfire nextTaskFnmonitorProcess (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 in internal/models/task_yaml_test.go: the original started_at: "" repro, every-time-field-empty, explicit null, omitted field, and real-timestamp-unchanged. (B) config.LoadAllTasks skips unparseable files instead of aborting the whole list. internal/config/tasks.go:76-83 previously returned the first per-file LoadTask error 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 in internal/config/tasks_test.go exercise 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_at path (loaded cleanly with StartedAt == nil). (C) Generate prompts tightened. internal/daemon/agent/prompts/wildfire-generate-system.txt, generate-tasks-system.txt, and the shared watchfire-prompt.txt now 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 the started_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

Choose a tag to compare

@github-actions github-actions released this 13 May 16:25

[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 startAgent RPC and a concurrent startAgent('chat') fired by ChatTab's auto-restart effect. The daemon's manager.StartAgent (internal/daemon/agent/manager.go:165-189) atomically kills the previous agent and spawns the new one; between "previous removed from m.agents" and "new agent inserted", GetAgentStatus legitimately returns {IsRunning:false} for the project. A fetchStatus landing in that window (either from useAgentTerminal's onEnd callback when the dying agent's gRPC stream closed, or from ChatTab's 2 s status poll) flipped useAgentStore.statuses[projectId].isRunning to false; ChatTab's auto-restart effect (ChatTab.tsx:52-58) saw agentStatus && !isRunning && !autoStarted.current and immediately fired a second startAgent('chat'). The daemon serialised the two RPCs through m.mu.Lock(): the first one (Generate / Plan / Run All / Wildfire) spawned its agent and returned; the second one (chat) saw the just-spawned agent in m.agents, killed it, and spawned chat in its place. Net effect: every special-mode click ended in chat, the daemon log only ever showed Agent started for project X (chat mode), and the racing second poll occasionally tipped the first call's 10 s cleanup polling into the ConnectError: [unknown] timed out waiting for previous agent to stop toast. Fixes: (1) ModesControl.handleStart (gui/src/renderer/src/views/ProjectView/ModesControl.tsx:34-42) no longer pre-calls stopAgent — the daemon's StartAgent already 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-project startAgentInFlight: Record<string, boolean> flag set on startAgent entry and cleared in a finally; fetchStatus short-circuits while the flag is true, so the kill+restart window never propagates a phantom isRunning:false to subscribers (#0104). Together: only one startAgent ever 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 after stopAgent returns, startAgentInFlight is 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::Clear is called on AgentStartedMsg, subscribeRawOutputCmd always passes bytesReceived=0): reset the emulator on a real generation change, never on a transient blip. (A) Phantom isRunning=false on every transient gRPC blip (gui/src/renderer/src/stores/agent-store.ts). fetchStatus's catch block fabricated {isRunning: false} for any RPC error, but agent_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 to isRunning=false. The catch is now a quiet no-op that preserves the last-known status. (B) ChatTab auto-restart on every isRunning flicker (gui/src/renderer/src/views/ProjectView/RightPanel/ChatTab.tsx, unchanged). With (A) fixed, the existing agentStatus && !isRunning && !autoStarted.current → handleStart() logic only fires on real "agent gone" signals; the destructive startAgent('chat') cascade that killed the running agent + broadcast [Agent stopped] is gone at the source. (C) Stale bytesReceivedRef after a daemon Process restart (gui/src/renderer/src/hooks/useAgentTerminal.ts). Each manager.StartAgent kills the old Process and spawns a new one with its own rawTotalBytes counter 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 and SubscribeRawFrom returned 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 reads agentStatus.startedAt as a Zustand selector and includes it in the effect's deps, so the daemon's Process-generation transitions actually re-run the effect; on a real generation change, term.reset() clears xterm's emulator AND bytesReceivedRef resets 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 a startedAt comparison 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) zeroed bytesReceivedRef inside the onEnd callback unconditionally, which forced the daemon to send its full rawBuf on 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: onEnd leaves the cursor alone and only calls useAgentStore.getState().fetchStatus(projectId) before scheduling a short 200 ms reconnect, so by the time the effect re-runs the store has the latest startedAt. Same-Process blip → reconnectKey branch → cursor preserved, no term.reset(), no replay. New-Process restart → startedAt changed → generation-change branch → term.reset() + cursor=0 → fresh emulator + full buffer. The \r\n[Agent stopped]\r\n marker 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 real started_at change, so a single long session keeps its scrollback and never snaps to byte 0 on a poll boundary.

v7.0.0 — Forge

Choose a tag to compare

@github-actions github-actions released this 12 May 14:46

[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+↑ and Shift+↓ on a focused active row swap the selected task with its in-bounds same-status neighbour and fire TaskService.ReorderTasks (the v7 RPC landed in #0097) with the project's full new active task-number ordering. The chord lands in taskListKeys.MoveUp / MoveDown (internal/tui/keys.go) bound exclusively to shift+up / shift+downK / J letter chords were dropped after auditing keys.go and finding lowercase j / k already 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 repeating j. The terminal handler's pre-existing shift+up / shift+down line-scroll case (internal/tui/keyhandler.go:519) sits inside handleTerminalKey which only runs when focusedPanel == 1, so the left-panel dispatch in handleTaskListKey cannot collide. New Model.moveSelectedTask(direction int) in internal/tui/actions.go derives a Draft → Ready → Done flat list of active tasks via activeTasksInDisplayOrder (filters GetDeletedAt() != nil, preserves the canonical position-then-task_number order already produced by the server's #0096 sort), locates the focused row, calls reorderActiveTasks to 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 (where SelectedTask() returns nil), soft-deleted rows, and a nil m.conn all short-circuit to return nil so 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.preReorderTasks snapshots m.tasks; the active list is replaced with mergeActiveWithDeleted(newOrder, m.tasks) so the soft-deleted tail stays lossless for trash-mode round-trips; taskList.SetTasks(m.tasks) + new TaskList.SelectTaskByNumber(num int32) (in internal/tui/tasklist.go) keep the cursor glued to the moved row across the rebuild so the user can hold Shift+↓ and have the highlight chase the task; m.inFlightReorder = true arms the race guard; reorderTasksCmd (new in internal/tui/commands.go) fires the gRPC call and returns ReorderCompletedMsg{Tasks, Focused} on success or ReorderFailedMsg{Err, Focused} on failure (both defined in internal/tui/messages.go). On completion the msg handler accepts the server's response as the new authoritative m.tasks, re-selects the focused row, and clears inFlightReorder + preReorderTasks. On failure it restores m.tasks from preReorderTasks, re-selects the focused row, sets m.err = "Reorder failed — reverted" (routed through the existing error-bar toast pipeline with clearErrorAfter(3 * time.Second)), and clears the in-flight state. Race fix for poll-driven refreshes (internal/tui/msghandler.go::handleMessage on TasksLoadedMsg): when inFlightReorder == true and the incoming active task-number set matches m.tasks's active set (new helper sameActiveTaskSet), 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) gains Shift+↑ "Move selected task up" and Shift+↓ "Move selected task down" entries under the Task List section. Tests in internal/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 of activeTasksInDisplayOrder, the deleted-task filter, mergeActiveWithDeleted preserving the deleted tail, header-row dispatch returning nil, offline (m.conn == nil) dispatch returning nil, ReorderFailedMsg reverting m.tasks + clearing flags + populating m.err, ReorderCompletedMsg accepting the server response + re-selecting the focused row, the stale-refresh race drop, the diverged-set acceptance leg, and Shift+↑ dispatch through handleTaskListKey not falling through to the generic navigation handler. make build and make test green; 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-kit pieces already proven out by gui/src/renderer/src/components/Sidebar.tsxDndContext + SortableContext + useSortable + arrayMove from @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") in gui/src/renderer/src/views/ProjectView/TasksTab/TaskGroup.tsx now owns its own DndContext with PointerSensor({ activationConstraint: { distance: 8 } }) and closestCenter collision; "Failed" and "Done" groups render the legacy non-sortable TaskItem so collapsed historical groups stay click-to-open with zero drag affordance. Rows in gui/src/renderer/src/views/ProjectView/TasksTab/TaskItem.tsx get a new sortable?: boolean prop — when true, useSortable({ id: String(task.taskNumber) }) is bound to the row container (transform + transition + 0.5 opacity while dragging) and a GripVertical icon at the left of the row is the only target wired up with attributes+listeners (with onClick stopPropagation + touch-none so trackpad gestures don't get hijacked). The row body itself keeps its existing onClick={() => setEditOpen(true)} modal-open behaviour, and PointerSensor's 8 px activation distance means a stray pixel of pointer drift on a click no longer turns into a reorder. useSortable is called unconditionally with disabled: !sortable to keep React hook order stable on the Done/Failed leg. Drag-end in TaskGroup.handleDragEnd builds the flat reorder payload locally — [...arrayMove(groupTasks, oldIndex, newIndex).map(t=>t.taskNumber), ...allActiveTasks.filter(notInGroup).map(t=>t.taskNumber)] — so the new TaskService.ReorderTasks RPC (#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's SortableContext is its own DndContext, no shared draggables), and the same oldIndex === -1 || newIndex === -1 early-return covers the vanished-row case where a task transitions ready → done mid-drag and disappears from the group's items array between dragstart and dragend. gui/src/renderer/src/views/ProjectView/TasksTab/TasksTab.tsx passes sortable, onReorder={reorderTasks(projectId, …)}, and allTasks={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 existing reorderTasks(projectId, taskNumbers) action — which previously did await 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 snapshot previous = get().tasks[projectId], apply the optimistic order in-memory via a byNumber map (named tasks first in the requested order, any not in the list keep their relative position at the tail), then call client.reorderTasks(...) and set the response's tasks as 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.handleDragEnd catches and calls toast(\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, and revertOnErrorare 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.ReorderTasks server handler + manager method (#0097). The proto declared TaskService.ReorderTasks(ReorderTasksRequest) returns (TaskList) (proto/watchfire.proto:515) but no server handler existed, so any call hit UnimplementedTaskServiceServer.ReorderTasks and returned codes.Unimplemented — blocking v7's drag-to-reorder UI in #0098 (TUI) and #0099 (GUI). New task.Manager.ReorderTasks(projectPath string, taskNumbers []int) ([]*models.Task, error) mirrors the shape of `project.Manager.ReorderP...
Read more

v6.0.0 — Phoenix

Choose a tag to compare

@github-actions github-actions released this 08 May 22:38

[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.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 (missing files skipped), 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 ## Role\n\n... shape every other backend uses. internal/daemon/metrics/parser.go::GetParser handles cursor / cursor-agent. DefaultSettings.Agents includes "cursor": {Path: ""}. TUI (internal/tui/globalsettings.go) and GUI (Settings/AgentPathsSection.tsx, Settings/searchIndex.ts, AddProject/StepAgent.tsx) include cursor in search keywords and the agent picker. The per-task → per-project → global → claude-code selection 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+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. General surfaces Name + Color + Agent (existing) plus new Sandbox cycle (auto / sandbox-exec / offProject.Sandbox was 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); y copies the focused value to the clipboard via pbcopy / xclip / wl-copy. Secrets shows path / size / mtime for .watchfire/secrets/instructions.md and e shells out to $EDITOR directly on the file (no temp-file round-trip — secrets edit in place). The editor.Find helper is shared with the Definition tab.
  • Project Notifications — per-event overrides (#0091). 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. Per-event rows render disabled (greyed) while override is off so the inherited values stay visible without being editable. New Quiet hours override toggle gates two HH:MM text inputs (start / end); same disabled-while-off treatment. Model: ProjectNotifications gains OverrideEvents bool + Events map[string]ProjectEventPref + QuietHoursOverride *QuietHoursConfig, all omitempty. Pre-v6 project.yaml files load identically (round-trip test in internal/config/projects_test.go::TestProjectYAMLRoundTripPreV6 confirms no fields materialise). Wire shape: full ProjectNotifications block round-trips through a new optional notifications field on UpdateProjectRequest; the legacy notifications_muted field stays usable for callers that only flip the master mute.
  • Integrations — per-project scoping (#0091). New Integrations section in the project Settings sidebar. GitHub auto-PR toggle binds the project to membership in the global ~/.watchfire/integrations.yamlgithub.project_scopes list via the new ProjectService.SetGitHubAutoPRScope RPC (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 new integrations: block (models.ProjectIntegrations); empty string clears the binding (= inherit global default). New ProjectService.SetProjectIntegrationBindings(project_id, slack_channel, discord_guild_id) returns Project RPC writes both bindings atomically. The Project proto gains a ProjectIntegrations sub-message; the github_auto_pr boolean 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 zone section in the project Settings sidebar surfaces five destructive actions. Each row goes through a y/N confirm in the status bar (confirmSettings* modes wired through internal/tui/keyhandler.go::handleConfirmKey) before firing. Archive project flips Status between active / archived via the existing UpdateProject RPC; archived projects stop auto-starting tasks and are dropped from the dashboard active list (EnsureProjectRegistered already reactivates on contact, preserved). Regenerate project ID mints a new UUID through the new ProjectService.RegenerateProjectId RPC, rewrites .watchfire/project.yaml + the global index entry atomically (v6.0 atomic-write SaveYAML), preserves position, and logs the prior ID for diagnostics. Reset task numbering through the new ProjectService.ResetTaskNumbering RPC (wraps a new config.HighestTaskNumber helper) sets next_task_number = highest_existing + 1, or 1 when no tasks exist. Prune merged branches reuses the existing BranchService.PruneBranches. Unregister project drops the entry from ~/.watchfire/projects.yaml via the new ProjectService.UnregisterProject RPC; local .watchfire/ is preserved so EnsureProjectRegistered re-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 — x on a task hid it forever as far as the TUI was concerned, while the GUI's gui/src/renderer/src/views/ProjectView/TrashTab.tsx had full restore + permanent-delete affordances. The Tasks tab (internal/tui/tasklist.go) now carries a filter mode toggle: D (capital, kept clear of the existing d task-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 requests IncludeDeleted: true so both subsets are available without a second round trip; TaskList.rebuild filters by mode. Trash view renders deleted tasks under a single Deleted (N) section in the same descending-by-task-number order; an empty trash shows No deleted tasks. Press D to return.. The Tasks tab label flips to Tasks · Trash (internal/tui/header.go) and a high-contrast red status-bar banner replaces the normal hint row with Trash mode — N deleted task(s) · u restore · x delete · D back. Trash row keys: u calls TaskService.RestoreTask (already on the proto, kept), x arms a y/N permanent-delete confirm (confirmPermanentDelete mode, distinct from the existing soft-delete confirm), Enter opens the read-only edit form, D flips 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. New TaskService.PermanentDeleteTask(TaskId) returns Empty proto RPC + taskService.PermanentDeleteTask handler in internal/daemon/server/task_service.go shells out to a new task.Manager.PermanentDelete(projectPath, taskNumber, branchMerged) callback-driven helper that verifies deleted_at != nil (refuses if not), refuses if the caller-injected branchMerged reports the watchfire/<n> branch unmerged (branchSafeToDelete walks git branch --list then git branch --merged <target>; missing branch = safe, unmerged commits = blocked), then removes the task YAML via the existing config.DeleteTaskFile and the optional <n>.metrics.yaml sibling via os.Remove. Watcher debouncing already swallows the YAML delete event so the daemon catches up on the next reload tick. Help overlay (Ctrl+H) gains D toggle trash view and u Restore (trash mode) rows. Tests in internal/tui/tasklist_test.go cover active-mode hides deleted, trash-mode renders only deleted, empty-trash hint, mode flip-back, search-filter cleared on entry, and key handler routes u/x/D to the trash-mode actions; tests in internal/daemon/task/manager_test.go cover the DeleteTask→RestoreTask round trip via ListTasks, the unmerged-branch refusal (YAML stays on disk), the active-task refusal (PermanentDelete only operates on soft-deleted rows), and the YAML+metrics happy-path cl...
Read more

v5.0.0 - Flare

Choose a tag to compare

@github-actions github-actions released this 04 May 22:29

[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.go registered at POST /echo/github?project=<id> parses X-GitHub-Event / X-Hub-Signature-256 / X-GitHub-Delivery, resolves the per-project HMAC secret from the keyring, runs verify.VerifyGitHub, deduplicates against the LRU+TTL idempotency cache, narrows on event == "pull_request" && action == "closed" && pull_request.merged == true, then matches the Watchfire task by pull_request.head.ref == watchfire/<n> and calls task.MarkDoneIfNotAlready + emits a Pulse RUN_COMPLETE notification 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.go translates the URL-encoded slash-command form body (command, text, team_id, channel_id, user_id, trigger_id) into a call against the shared transport-agnostic commands.Route(...) router, then renders CommandResponse as Slack response JSON ({response_type: "in_channel" | "ephemeral", text, blocks}). /watchfire status / retry / cancel now 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 for chat.postMessage so 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 a Connected 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_host field on models.InboundConfig lets the existing GitHub HMAC-SHA256 verifier target arbitrary GitHub Enterprise hostnames (the v7.0 outbound auto-PR path picks up the same field). New internal/daemon/echo/handler_gitlab.go verifies X-Gitlab-Token (per-project shared secret), narrows on Merge Request Hook events with action: merge. New internal/daemon/echo/handler_bitbucket.go verifies X-Hub-Signature (HMAC-SHA256), narrows on pullrequest:fulfilled events. 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 through models.InboundConfig.RateLimitPerMin (0 disables). 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 endpoint POST /echo/slack/interactivity handles the block_actions and view_submission payloads with the same v0 HMAC verification + 5-minute drift window as the slash-commands endpoint. Button presses route through commands.Route so a Retry click is the exact equivalent of /watchfire retry. Cancel opens a Slack modal that asks "Why are you cancelling?"; the supplied reason lands in task.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::registerForGuild helper; it also subscribes to GUILD_CREATE Gateway 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 manual watchfire 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+F focuses search, Esc clears, Up/Down/Enter navigate. TUI: / opens a search overlay with the same field-jumping behaviour. Deep-link routes (#integrations etc.) still work.

Fixed

  • Run-all silently halted on auto-merge failure (#0083). When internal/daemon/agent/taskdone.go::HandleTaskDone's silent merge failed (dirty main, merge conflict, post-merge hook failure), the chain stopped — but silently: the task YAML still showed status: done + success: true, no notification fired, and the user was left wondering why their queue stalled. onTaskDoneFn now returns a structured TaskDoneResult{Outcome, Reason} (with TaskDoneOK / TaskDoneMergeFailed / TaskDoneCancelled) instead of a bare bool; monitorProcess branches on result.Outcome == TaskDoneMergeFailed and emits a TASK_FAILED-shaped notification before the chain decision; runSilentMerge populates the task's new merge_failure_reason field (yaml: merge_failure_reason,omitempty, exposed via proto + GUI/TUI). The chain-stop semantics are unchanged — the user still has to clean up main manually — but the silence is gone.
  • GUI chat-tab repainted multiple times on project switch (#0084). Verified the symptom under the new RightPanel/ChatTab.tsx architecture (the v5.0 spec had referenced the now-deleted ChatPanel.tsx), then locked in single-mount + single-start guards: the auto-start useEffect deps tightened to [!!agentStatus, isRunning, projectId] so a stale agentStatus reference from the previous project no longer fires handleStart on a transient render edge; the autoStarted.current = false reset on projectId change runs before the auto-start check; regression test in gui/src/renderer/src/views/ProjectView/RightPanel/ChatTab.test.tsx simulates rapid project switching and asserts handleStart fires exactly once per navigation.
  • Failed-task UI hid the reason behind two clicks (#0085). TaskStatusBadge now carries a title= tooltip for agent-reported failures (it already had one for merge failures only), populated by a new exported pure helper computeBadgeTooltip that prefers Merge failed: … over Failed: … when both reasons are set and truncates to 500 runes. TaskItem passes failureReason={task.failureReason} into the badge alongside mergeFailureReason. TaskModal's tab decision is now lazy in useState(() => …) AND kept in sync via the existing effect, so done tasks 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.Route round-trips status / 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 RateLimitPerMin field defaults to 30; set to 0 to disable.
  • Multi-host inbound: leave github_host empty 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 from func(...) bool to func(...) 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

Choose a tag to compare

@github-actions github-actions released this 02 May 23:58

[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 today between 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 in localStorage[wf-dashboard-filter]. Predicates shared via gui/src/renderer/src/lib/dashboard-filters.ts.
  • Elapsed-time badge on running ProjectCards — ticking Ns / Nm / Nh Mm next to the agent badge, sourced from a new AgentStatus.started_at proto field stamped in RunningAgent.StartedAt. Flips to var(--wf-warning) past 30 minutes.
  • Last-activity timestamp on dashboard cardsActive now / 5m ago / 4h ago / 2mo ago segment derived from the most recent task updated_at. Hand-rolled relative-time formatter in gui/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.ts ref-counts the underlying AgentService.SubscribeScreen stream.
  • Needs-attention treatment for failed tasks — red-tinted card border + header AlertTriangle chip + N failed segment in the counts row + red progress segment when any task has status === 'done' && success === false.
  • Current-task surfacing on running ProjectCards — replaces the misleading Next: line with Working: <current task title> (with Flame icon) when the agent is actively running. No proto change — uses the existing AgentStatus.task_title.
  • Shell-count chip on running ProjectCards — terminal icon + count from useTerminalStore filtered 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 toggleLayoutGrid / Rows3 toggle in the header; list mode renders one ~46px row per project. Selection persists in localStorage[wf-dashboard-layout]. Per-project rendering in gui/src/renderer/src/views/Dashboard/ProjectRow.tsx.
  • Notification bus — new internal/daemon/notify package with a typed Bus, channel fan-out (slow-consumer drop), stable MakeID (sha256(kind|project_id|task_number|emitted_at_unix)[:8]), and JSONL append to ~/.watchfire/logs/<project_id>/notifications.log for headless fallback.
  • TASK_FAILED OS notification — fires from internal/daemon/server/task_failed.go::emitTaskFailed on done && !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. Body N tasks done · M failed over the run window.
  • Bundled notification soundsassets/sounds/task-{done,failed}.wav (mono 22050 Hz, ~25 KB each). Pure shouldPlaySound(kind, prefs) decision in gui/src/renderer/src/stores/notifications-sound.ts. OS toast goes silent precisely when the renderer plays its own audio.
  • Dynamic system tray menuinternal/daemon/tray/tray.go rebuilds on every project / task / agent / settings change; sections for Needs attention / Working / Idle plus Notifications (N today) ▸ submenu reading the JSONL fallback. Click-through routes via the new DaemonService.SubscribeFocusEvents stream.
  • 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 under defaults.notifications in ~/.watchfire/settings.yaml. Gating helper models.ShouldNotify.
  • Inline diff viewer — new internal/daemon/diff package resolves diffs pre-merge (<merge-base>...HEAD on watchfire/<n>) and post-merge (locates the merge commit via git log --grep). Structured FileDiffSet; cap at 10000 lines; cache at ~/.watchfire/diff-cache/<project_id>/<task_number>.json. GUI Inspect tab + TUI overlay (bound to d).
  • Per-task metrics capture<n>.metrics.yaml siblings carrying duration, exit reason, agent, tokens, cost. New internal/daemon/metrics package with parsers for Claude Code, Codex, opencode, Gemini, Copilot (stub). Capture from a non-blocking goroutine on handleTaskChanged. New watchfire metrics backfill CLI.
  • Per-project Insights viewinternal/daemon/insights/project.go aggregates one project's tasks per window. New GUI Insights tab + TUI overlay (bound to i) 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 rollupinternal/daemon/insights/global.go aggregates the whole fleet per window; cached at ~/.watchfire/insights-cache/_global.json. Dashboard rollup card under the Beacon status bar; TUI fleet overlay bound to Ctrl+f.
  • Report export (CSV + Markdown) — shared InsightsService.ExportReport RPC with oneof scope (project_id / global / single_task). Markdown templates in internal/daemon/insights/templates/; CSV uses # section: <name> headers. Single <ExportPill> component on the dashboard + ProjectView headers; TUI binds Ctrl+e.
  • Weekly digest notificationdigestRunner schedules with a re-armable time.Timer from models.DigestSchedule.NextFire (DST-stable, with 24-hour catch-up on daemon start). Markdown rendered to ~/.watchfire/digests/<YYYY-MM-DD>.md regardless of toast suppression. New WEEKLY_DIGEST notification kind + FOCUS_TARGET_DIGEST.
  • Outbound delivery framework + webhook adapter — new internal/daemon/relay package with an Adapter interface and a Dispatcher subscribing to notify.Bus. Per-adapter retry ([500ms, 2s, 8s]) + circuit breaker (3 failures / 5-minute window). Generic WebhookAdapter POSTs the canonical payload with X-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.go renders three text/template Block Kit envelopes (TASK_FAILED / RUN_COMPLETE / WEEKLY_DIGEST) with header / section / context / actions blocks. Project-color → :large_<color>_square: shortcode map in slack_color.go.
  • Discord adapter (rich embeds)internal/daemon/relay/discord.go renders three embed envelopes with project-color tinting. Shared hexToInt / rfc3339 template helpers. Defensive 4000-rune description trim with single-WARN log on overflow. New watchfire integrations CLI parent with list and test subcommands.
  • GitHub auto-PR creation — opt-in per project via github.auto_pr.enabled: true. End-of-task lifecycle in internal/daemon/git/pr.go::OpenPR: gh auth status → parse <owner>/<repo>git push --force-with-lease → render PR body via pr_body.md.tmplgh 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 IntegrationsService gRPC service with List / Save / Delete / Test RPCs; Save carries a oneof payload, secrets are write-only on the wire. GUI IntegrationsSection.tsx with per-type detail panels; TUI overlay reachable via Ctrl+I.
  • Inbound HTTP server frameworkinternal/daemon/echo/server.go binds ListenAddr (default 127.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 verificationinternal/daemon/echo/verify.go ships VerifyGitHub (HMAC-SHA256 against sha256=<hex>), VerifySlack (HMAC-SHA256 over v0:<timestamp>:<body> with 5-minute drift), VerifyDiscord (Ed25519 over timestamp || body, same drift) — all constant-time.
  • Idempotency cacheinternal/daemon/echo/idempotency.go is an LRU+TTL cache (1000 entries / 24h, container/list-backed, sync.Mutex-protected); Seen(key) refreshes TTL on hit.
  • Per-task lifecycle helpers + command routerinternal/daemon/echo/commands.go::Route(ctx, cmd, subcmd, rest, CommandContext) CommandResponse powers slash-command transports. Three commands (status / retry <task> / cancel <task>); CommandResponse{text, blocks, ephemeral, in_channel} is transport-agnostic.
  • Discord interactions endpointinternal/daemon/echo/handler_discord.go exposes POST /echo/discord/interactions with end-to-end Ed25519 verification + replay window + idempotency. PING → PONG; APPLICATION_COMMAND → dispatch to commands.Route, render via discord_render.go::RenderInteraction. Slash-command registration via watchfire integrations register-discord <guild_id> (idempotent).
  • Inbound settings UI (GUI + TUI)gui/src/renderer/src/views/Settings/InboundSection.tsx shows a Listening pill polled at 5 s, editable ListenAddr + PublicURL with 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 position order with bucketing into needs-attention → working → has-ready-tasks → idle (input-array index as final tiebreaker for stability). Predicate helpers in...
Read more