Skip to content

Latest commit

 

History

History
2845 lines (2229 loc) · 157 KB

File metadata and controls

2845 lines (2229 loc) · 157 KB

Watchfire Architecture

Summary

Watchfire orchestrates coding agent sessions (starting with Claude Code) based on task files. It manages multiple projects in parallel, with one active task per project. A daemon (watchfired) manages all logic: spawning agents in sandboxed PTYs (macOS/Linux) or unsandboxed (Windows), terminal emulation, git worktree workflows, and file watching. Thin clients (CLI/TUI and GUI) connect via gRPC to display task status and live terminal output. Supported platforms: macOS, Linux, and Windows.

Components

Component Binary Role Tech
Daemon watchfired Orchestration, PTY management, terminal emulation, git workflows, gRPC server, system tray Go
CLI/TUI watchfire CLI commands + TUI mode. Project-scoped thin client Go + Bubbletea
GUI Watchfire.app Multi-window, multi-project thin client (home window + per-project windows) Electron
MCP Server watchfire mcp serve stdio MCP façade over the daemon — lets external coding agents (Claude Code, Codex, Gemini CLI, …) drive Watchfire as a task factory Go + MCP Go SDK

Non-Negotiable Tech Choices

  • Language: Go for daemon and CLI/TUI. No exceptions.
  • PTY: github.com/creack/pty
  • Terminal emulation: github.com/hinshun/vt10x — parses escape codes, maintains screen buffer
  • gRPC: google.golang.org/grpc with protobuf
  • gRPC-Web: github.com/improbable-eng/grpc-web — for Electron GUI support
  • TUI framework: github.com/charmbracelet/bubbletea
  • System tray: github.com/getlantern/systray
  • File watching: github.com/fsnotify/fsnotify
  • MCP: github.com/modelcontextprotocol/go-sdk — official Model Context Protocol Go SDK (stdio transport)
  • Sandbox: macOS sandbox-exec (Seatbelt), Linux Landlock (kernel 5.13+) / bubblewrap (fallback), Windows unsandboxed

Daemon (watchfired)

Overview

The daemon is the backend brain of Watchfire. It manages multiple projects simultaneously, watches for file changes, spawns coding agents in sandboxed PTYs with terminal emulation, handles git worktree workflows, and serves state to thin clients over gRPC.

Lifecycle

Aspect Behavior
Development Run watchfired --foreground for hot reload (tray still active)
Production Runs in background, started automatically by CLI/TUI/GUI if not running
Persistence Stays running when thin clients close
Shutdown Ctrl+C (foreground), CLI command, or system tray quit. All thin clients close when daemon quits

Network

Aspect Decision
Protocol gRPC + gRPC-Web (multiplexed on same port)
Port Dynamic allocation (start with port 0, OS assigns free port)
Discovery Connection info written to ~/.watchfire/daemon.yaml for client discovery (only after port is confirmed ready)
Clients CLI/TUI use native gRPC, Electron GUI uses gRPC-Web

Multi-Project Management

Aspect Behavior
Projects index ~/.watchfire/projects.yaml lists all registered projects
Registration Projects added via CLI (watchfire init) or GUI
Concurrency One active task per project, multiple projects in parallel
Client tracking Tracks which clients are watching which projects
Task cancellation Task stops only when ALL clients for that project disconnect

File Watching

Aspect Behavior
Mechanism fsnotify with debouncing
Robustness Handles create-then-rename pattern (common with AI tools)
Global watched ~/.watchfire/projects.yaml
Per-project watched .watchfire/project.yaml, .watchfire/tasks/*.yaml
Re-watch on chain When agents chain (wildfire/start-all), project is re-watched to pick up directories created during earlier phases
Polling fallback Task-mode agents poll task YAML every 5s as safety net for missed watcher events (kqueue overflow, late directory creation)
Reaction File changes trigger real-time updates to connected clients

Git Worktree Management

Aspect Behavior
Creation Creates worktree in .watchfire/worktrees/<task_number>/ when task starts
Branch naming watchfire/<task_number> (e.g., watchfire/0001)
Location Agent runs inside worktree, not main working tree
Completion On task completion, daemon merges worktree to target branch, deletes worktree
Stale branches If a branch already exists when creating a worktree, deletes it and recreates from current HEAD
Merge conflict On merge failure, runs git merge --abort to restore clean working directory
Chain stop Merge failure stops wildfire/start-all chaining (prevents cascading failures)
Restart limit If same task restarts 3+ times without completing, chaining stops and agent enters chat mode
Pruning Periodically detects and cleans orphaned worktrees

Coding Agent Abstraction

Watchfire dispatches agent-specific behaviour through a Backend interface defined in internal/daemon/agent/backend/backend.go. Backends register themselves in a process-wide registry from init(); the daemon looks them up by name when spawning a session.

Aspect Behavior
Interface Backend in internal/daemon/agent/backend/
Registry Process-wide Register/Get/List keyed by Name() (e.g. "claude-code", "codex", "opencode", "gemini", "copilot", "cursor"); duplicate registration panics at startup
Shipped backends claude-code (Claude Code), codex (OpenAI Codex), opencode (opencode), gemini (Gemini CLI), copilot (GitHub Copilot CLI), cursor (Cursor Agent CLI)
Agent resolution Four-step chain resolved in agent/manager.go:resolveBackend: per-task (task.agent) → per-project (project.default_agent) → global default (settings.defaults.default_agent) → claude-code fallback. Empty string at any level defers to the next. Global default may be unset (empty string), meaning "Ask per project" — watchfire init always prompts for agent selection. Chat / wildfire-refine / wildfire-generate sessions aren't scoped to a single task, so they skip the task step and start from the project default.
Prompt pipeline internal/daemon/agent/prompts/ composes one canonical, agent-agnostic prompt. InstallSystemPrompt(workDir, composedPrompt) delivers it — Claude Code uses the --append-system-prompt flag (no-op install), Codex writes AGENTS.md into a per-session CODEX_HOME directory, opencode writes AGENTS.md into a per-session OPENCODE_CONFIG_DIR (with the user's real ~/.config/opencode entries symlinked in for auth)
Transcript ownership Each backend owns LocateTranscript(workDir, started, sessionHint) and FormatTranscript(jsonlPath) — the daemon copies and renders whatever the backend returns
Sandbox contributions SandboxExtras() returns writable subpaths/literals, cache patterns, and env vars to strip; the sandbox layer merges these with the base policy

The Backend interface methods:

Method Purpose
Name() Stable identifier persisted in project.yaml/settings.yaml
DisplayName() Human-readable label for UIs
ResolveExecutable(s *models.Settings) Absolute path to the agent binary (user-configured path → PATH → well-known install locations)
BuildCommand(opts) Assemble the PTY invocation (path, args, env, whether initial prompt is pasted post-start or embedded in args)
SandboxExtras() Paths/env the sandbox profile must allow/strip for this backend
InstallSystemPrompt(workDir, composedPrompt) Deliver the composed prompt (CLI flag no-op, or file write such as AGENTS.md). Called after the worktree exists but before BuildCommand
LocateTranscript(workDir, started, sessionHint) Find the JSONL transcript the backend produced for this session
FormatTranscript(jsonlPath) Render the JSONL into the plain-text transcript shown in the log viewer

CODEX_HOME per-session isolation: Codex's transcript and auth layout sits under $CODEX_HOME (default ~/.codex). To deliver the Watchfire system prompt without mutating the user's real home, InstallSystemPrompt creates a per-session directory, writes AGENTS.md into it, and BuildCommand exports CODEX_HOME=<that dir> in the child env. Future agents that discover config via a HOME-like env var can use the same trick.

OPENCODE_CONFIG_DIR + OPENCODE_DATA_DIR per-session isolation: opencode applies the same trick with two env vars. OPENCODE_CONFIG_DIR points at a per-session directory where Watchfire writes AGENTS.md (the composed system prompt) and an opencode.json enabling yolo permission mode; the user's real ~/.config/opencode entries are symlinked in so existing logins continue to work. OPENCODE_DATA_DIR points at a sibling directory where opencode writes its per-message JSON files — we own this path, so transcript discovery becomes deterministic. Because opencode stores messages as one JSON file per turn rather than a single JSONL, LocateTranscript collates the per-message files into a synthesized transcript.jsonl that the existing copy + format pipeline consumes unchanged.

Per-task agent override: Each task carries an optional agent field in its YAML (.watchfire/tasks/<n>.yaml, models.Task.Agent in internal/models/task.go) that pins the task to a specific backend, overriding the project default for that one task only. agent/manager.go:resolveBackend consumes it as the first step of the four-step chain above; an empty string (or the field omitted entirely) defers to the project default, so existing tasks keep behaving exactly as before. The TUI surfaces the field as a cycling selector in the task form (internal/tui/taskform.go) and renders a compact agent badge next to the task title in the list view (internal/tui/tasklist.go) whenever the override differs from the project default; the GUI mirrors this with a picker in gui/src/renderer/src/views/ProjectView/TasksTab/TaskModal.tsx (populated from the daemon's SettingsService.ListAgents RPC) and a matching badge in TaskItem.tsx. Both selectors include a leading "Project default ()" entry that maps to the empty string so the effective agent is always visible. Wildfire's execute phase runs a specific task and honours its override; the refine and generate phases are project-scoped and always fall through to the project default.

Adding a new backend

  1. Implement Backend in internal/daemon/agent/backend/<name>.go (name, display name, executable resolution, command builder, system-prompt installer, transcript locate/format).
  2. Register it in that file's init() via backend.Register(&<Name>Backend{}).
  3. Contribute sandbox extras from SandboxExtras() — writable paths (config dir, auth dir), cache patterns, env vars to strip (e.g. nested-session detection variables).
  4. Implement transcript discovery (match the backend's on-disk session layout) and a formatter that maps the backend's JSONL schema onto User/Assistant/tool entries.

Agent Sandbox & Permissions

Aspect Behavior
Sandbox Cross-platform: macOS Seatbelt, Linux Landlock/bubblewrap
Backend selection auto (default): platform picks best. Configurable per-project or globally. CLI flag --sandbox/--no-sandbox
macOS backend sandbox-exec -f <profile> (Seatbelt)
Linux backend Landlock (kernel 5.13+, zero deps) → bubblewrap fallback → unsandboxed
Profile storage Generated at runtime from SandboxPolicy struct
Agent permissions Agent runs in "yolo mode" — full permissions within sandbox
Claude Code flag --dangerously-skip-permissions
Security model Agent has free reign inside sandbox; sandbox limits blast radius
Write-allowed paths Base policy: project dir, temp dirs, package manager caches (~/.npm, ~/.yarn, ~/.pnpm-store, ~/.cache), dev tool caches (~/.cargo, ~/go, ~/.rustup). macOS also: ~/Library/Caches/*, ~/Library/Application Support. Backend-contributed extras (e.g. ~/.claude for Claude Code, per-session CODEX_HOME for Codex) are merged in via Backend.SandboxExtras() — they are not hardcoded in the sandbox layer
Denied paths (read+write) ~/.ssh, ~/.aws, ~/.gnupg, ~/.netrc, ~/.npmrc. macOS also: ~/Desktop, ~/Documents, ~/Downloads, ~/Music, ~/Movies, ~/Pictures. .env files, .git/hooks (Seatbelt only — regex patterns)
Settings Global: settings.yamldefaults.default_sandbox. Per-project: project.yamlsandbox. CLI: --sandbox <backend> / --no-sandbox
Path preflight (v10, #17) agent.CheckProjectPath(homeDir, path) (internal/daemon/agent/sandbox_preflight.go) refuses project paths under a denied root with a typed, actionable *PathDenial message — per-platform deniedProjectRoots() is derived from the same slices the profiles render (protectedUserDirs, credentialDenyDirs) so preflight and policy cannot drift. Enforced at project registration (project.Manager.CreateProject — covers gRPC CreateProject/GUI wizard and watchfire init, which also fail-fasts before prompting) and at agent.Manager.StartAgent for pre-existing projects, where the refusal is recorded as a sandbox_denied preflight issue that rides AgentStatus.issue while no agent runs (no Process exists to stream it). Symlinks are resolved best-effort; policy itself is unchanged

PTY & Terminal Emulation

Sandbox (platform-specific)
       ↓ contains
Coding Agent (e.g., Claude Code with --dangerously-skip-permissions)
       ↓ runs inside
     PTY (github.com/creack/pty)
       ↓ raw output with escape codes
   vt10x (github.com/hinshun/vt10x)
       ↓ parsed into
   Screen Buffer (rows × cols grid of cells with attributes)
       ↓ streamed via
     gRPC to clients
Aspect Behavior
PTY creation github.com/creack/pty spawns agent process
Terminal emulation github.com/hinshun/vt10x parses escape codes, maintains virtual screen
Screen buffer format 2D grid of cells (char, fg, bg, bold, italic, underline, inverse) + cursor position
Resize flow Client sends resize request → daemon resizes PTY → vt10x updates → agent receives SIGWINCH
Streaming Screen buffer sent to clients on change (debounced)

Agent Spawning

Aspect Behavior
Sandbox Cross-platform: auto-detected or user-selected backend
PTY Agent runs in PTY via github.com/creack/pty
Terminal emulation Output parsed by github.com/hinshun/vt10x
Working directory Task mode: worktree (.watchfire/worktrees/<task_number>/). Chat mode: project root.
Modes With prompt (task mode) or without (chat mode)
Yolo mode --dangerously-skip-permissions for Claude Code
System prompt --append-system-prompt "..." with embedded text
Prompt source Embedded in binary, not user-visible
Resize PTY resized on client request, agent receives SIGWINCH

Example spawn command (Claude Code):

# macOS (Seatbelt)
sandbox-exec -f <profile> claude --dangerously-skip-permissions --append-system-prompt "..." [--prompt "..."]

# Linux (Landlock — daemon re-invokes itself)
watchfired --sandbox-exec <config.json>
# → applies Landlock restrictions → exec() claude ...

# Linux (bubblewrap)
bwrap --ro-bind / / --bind <project> <project> --tmpfs ~/.ssh ... -- claude ...

Task Lifecycle (Reactive Model)

1. Client calls StartTask(task_id)
2. Daemon creates git worktree for task
3. Daemon spawns coding agent in sandboxed PTY (inside worktree)
4. Daemon streams screen buffer to subscribed clients
5. Agent updates task file when done (status: completed/failed)
6. Daemon detects via fsnotify OR polling fallback (5s interval)
7. Daemon kills agent (if still running)
8. Daemon processes git rules (merge, delete worktree)
   - If merge conflicts → abort merge, stop chain
9. Daemon starts next task (if queued and merge succeeded)
Scenario Behavior
Agent updates task file Daemon reacts, processes git, moves to next task
Agent crashes (PTY exits) Daemon detects, stops task
Watcher misses event Polling fallback detects task done within 5s, stops agent
Chat start during a run Refused with ErrAgentBusy — see below

Chat never displaces a working agent (v10). StartAgent normally replaces a running agent (deliberate mode switches rely on this), but a chat-mode start is the exception: it is refused with agent.ErrAgentBusy whenever a non-chat agent is running, and also while a run-all/wildfire chain is mid-transition (Manager.chaining[projectID], set between "finished agent removed from the map" and "next chained agent registered" — a window of a second or more while the next worktree is created), and also while a deliberate mode switch is mid-replace (Manager.replacing[projectID], v10.0.4, set by StartAgent's own kill+restart path between "running agent killed" and "requested agent registered"). Rationale: the GUI/TUI auto-start chat whenever they observe isRunning=false, and that observation can land exactly in either window; before the chain guard the resulting StartAgent(chat) hit the replace path, marked the freshly chained task agent userStopped, and silently ended the run with ready tasks still queued ("run-complete" after N tasks despite a non-empty queue); before the replace guard it took the empty slot from the user's own switch (Telegram /wildfire over a running chat), which then failed with a misleading "timed out waiting for previous agent to stop" and left a fresh chat behind. Chat may still replace chat, all non-chat starts keep replace semantics — a deliberate mode switch always takes effect — and a user who wants chat during a run must stop the agent explicitly first. Clients treat the refusal as expected on their opportunistic auto-start paths (GUI suppresses the toast; TUI shows a transient status-bar note and re-fetches status).

Phase Completion Signals

The daemon detects phase completion via signal files created by the agent. The daemon deletes these files after processing.

Phase Signal File Agent Action Daemon Response
Task / Execute Task YAML status: done Set status to done Stop agent, merge worktree, start next
Refine .watchfire/refine_done.yaml Create empty file Stop agent, start next phase
Generate .watchfire/generate_done.yaml Create empty file Stop agent, check for new tasks or end wildfire
Generate Definition .watchfire/definition_done.yaml Create empty file Stop agent (single-shot command)
Generate Tasks .watchfire/tasks_done.yaml Create empty file Stop agent (single-shot command)

System Tray

The menu buckets projects by status (internal/daemon/tray/menu.go):

Section Content
⚠ Needs attention Projects with failed tasks (failed count as subtitle); click focuses the Tasks tab
● Working Projects with a running agent — chat included (subtitle: task title, or "chat session"). "Working" simply means "an agent is running"; the same rule drives the GUI's isAgentWorking (dashboard dots, activity sort, mini monitor)
○ Idle Everything else, capped with an overflow row
Notifications Recent notifications.log entries + latest weekly digest
Update / Quit Update-available row when applicable; quit shuts the daemon down

The tray icon switches to its active variant while anything is working. Rebuilds are event-driven (agent lifecycle onChange + watcher fs events), coalesced to ≤ 4 Hz.

Tooltip: "Watchfire — {n} projects, {m} active agents"

Desktop Notifications

The daemon sends desktop notifications on agent completion/error via the internal/daemon/notify package. Platform-abstracted:

Platform Backend
macOS Native UNUserNotificationCenter via CGo (sets NSApp icon so notifications show Watchfire logo)
Linux github.com/gen2brain/beeep
Other No-op (logs to stderr)

The tray package calls notify.Send(title, message) — the icon is embedded in the notify package.

Crash Recovery

Scenario Behavior
Daemon crashes mid-task On restart, user must manually restart task. Agent reads worktree to understand state.
Agent crashes Daemon detects PTY exit, stops task

Task Metrics & Code-Output Analytics

On every task completion the daemon writes a per-task metrics sidecar next to the task file — <project>/.watchfire/tasks/<n>.metrics.yaml (internal/daemon/metrics/capture.go, struct models.TaskMetrics). Base fields: task_number, project_id, agent, duration_ms, exit_reason (completed | failed | stopped | timeout), captured_at, plus per-backend token/cost (tokens_in, tokens_out, cost_usd, all nullable).

Code-output fields (v8 Inferno). The task-done merge path (internal/daemon/agent/taskdone.go) computes what the agent actually shipped before the worktree is cleaned up, and records it onto the same sidecar:

Field Source
commits git rev-list --count <merge-base>..watchfire/<n>
files_changed, lines_added, lines_removed the internal/daemon/diff package (the same stats used for the Inspect viewer and the auto-PR body)
net_lines lines_added − lines_removed
merged true when the local silent merge succeeds; false for auto-PR (push pending, not merged to default)
merge_kind silent or auto_pr — the two task-completion paths (see CLAUDE.md task-completion lifecycle)

Capture is best-effort and backward compatible: metrics files written before v8 have no code fields and read back as zeros.

Insights rollup. internal/daemon/insights aggregates the sidecars into ProjectInsights / GlobalInsights. Beyond the task-throughput totals (tasks done/failed, duration, tokens, cost), v8 adds shipped-code totals — TotalCommits, TotalFilesChanged, TotalLinesAdded, TotalLinesRemoved, NetLines, TasksMerged, TasksViaPR, and a MetricsMissingCode coverage counter (so the UI can honestly say "based on N of M tasks"). Per-day buckets gain lines_added / lines_removed (a code-churn sparkline alongside tasks-by-day), per-agent rows gain commits/lines (output per agent, not just task count), and the global top-projects list gains commits/lines/net/merges (top by churn).

Reports & digest. The CSV/Markdown export (internal/daemon/insights/csv.go, internal/daemon/insights/templates/*.tmpl, the GUI useExportReport() hook, the Ctrl+e TUI picker) gains the code-output columns/section, and the weekly digest gains a code-output summary (commits, ±lines, net, merged / via-PR).


CLI/TUI (watchfire)

Overview

The CLI/TUI is the primary interface for developers. A single binary (watchfire) operates in two modes: CLI commands for scripting/automation, and TUI mode for interactive work. It's project-scoped — run from within a project directory.

Modes

Mode Entry Description
TUI watchfire (no args) Interactive split-view: task list + agent terminal
CLI watchfire <command> Scriptable commands, returns to shell

Daemon Interaction

Scenario Behavior
Daemon not running CLI/TUI starts daemon automatically before proceeding
Daemon shuts down CLI/TUI closes
Multiple instances Can run multiple watchfire instances in different projects simultaneously
Project not in index CLI auto-registers the project in ~/.watchfire/projects.yaml on any project-scoped command

Commands

Global

Command Alias Description
watchfire Start TUI
watchfire version --version, -v Show version (all components)
watchfire help -h Show help
watchfire update Update all components (daemon, CLI/TUI, GUI)
watchfire init Initialize project in current directory

Project Lifecycle

Command Alias Description
watchfire define def Edit project definition in $EDITOR
watchfire configure config Configure project settings (interactive)

Task

Command Alias Description
watchfire task list task ls List tasks (excludes soft-deleted)
watchfire task list --deleted List soft-deleted tasks
watchfire task add Add new task (interactive prompts)
watchfire task quick v10 Torch quick-add: opens $EDITOR with a bullet-list template — one task per top-level bullet, created through the validated CreateTasksBatch path. --stdin reads the list from stdin; tasks default to ready (--draft to opt out); # comment lines are stripped
watchfire task <taskid> Edit task (interactive)
watchfire task delete <taskid> task rm <taskid> Soft delete task (sets deleted_at)
watchfire task restore <taskid> Restore soft-deleted task

Execution

Command Alias Description
watchfire run [taskid|all] No arg = chat mode. taskid = run task. all = run all ready tasks in sequence.
watchfire chat Start interactive chat session with project context
watchfire plan Generate tasks from project definition
watchfire generate gen Generate project definition using agent
watchfire definition retrofit v10 Torch: run a retrofit-definition session that folds completed tasks back into the definition. --archive offers to archive the folded tasks afterwards (confirm-gated; --yes skips the prompt)
watchfire wildfire fire Autonomous three-phase loop until no new tasks or Ctrl+C

Daemon

Command Alias Description
watchfire daemon start Start the daemon (no-op if already running)
watchfire daemon status Show daemon host, port, PID, uptime, and active agents
watchfire daemon stop Stop the daemon via SIGTERM

MCP

Command Alias Description
watchfire mcp serve Run the stdio MCP server (spawned by MCP clients, not by users; auto-starts daemon). --read-only serves observation tools only
watchfire mcp install [client] Register the MCP server with a client: claude-code, codex, gemini, opencode, copilot. No arg = interactive picker. --print emits generic JSON config

Telegram (v10 Torch)

Command Alias Description
watchfire integrations add telegram Store the bot token (keyring) + enable the bridge — the only integration kind addable from the CLI
watchfire telegram pair Begin pairing: prints the one-time code + t.me deep link, polls until paired/expired
watchfire telegram status Show bridge status and the paired-chats list

watchfire init Flow

1. Check for git → if missing, initialize git repo
2. Create .watchfire/ directory structure
3. Create initial project.yaml (generated UUID, name from folder)
4. Append .watchfire/ to .gitignore (create if missing)
5. Commit .gitignore change
6. Prompt: "Project definition (optional):" → user enters text
7. Prompt: "Project settings:"
   - Auto-merge after task completion? (y/n)
   - Auto-delete worktrees after merge? (y/n)
   - Auto-start agent when task set to ready? (y/n)
   - Default branch? (default: main)
8. Save project.yaml
9. Register project in ~/.watchfire/projects.yaml

TUI Layout

┌──────────────────────────────────────────────────────────────────────┐
│ ● project-name  Tasks | Definition | Settings    Chat | Logs  ● Idle│
├────────────────────────────────────────┬─────────────────────────────┤
│  Task List                             │  Agent Terminal / Logs      │
│                                        │                             │
│  Draft (2)                             │  > Starting session...      │
│    #0001 Setup project structure       │                             │
│    #0004 Add authentication            │  Claude Code v2.1.31        │
│                                        │  ~/source/my-project        │
│  Ready (1)                             │                             │
│  ● #0002 Implement login flow    [▶]   │  > Working on task...       │
│                                        │                             │
│  Done (3)                              │                             │
│    #0003 Create database schema  ✓     │                             │
│    #0005 Add unit tests          ✓     │                             │
│    #0006 Fix bug in auth         ✗     │                             │
│                                        │                             │
├────────────────────────────────────────┴─────────────────────────────┤
│ Ctrl+q quit  Ctrl+h help  Tab switch panel           a add  s start │
└──────────────────────────────────────────────────────────────────────┘

Header bar:

Element Position Description
Project dot Far left Colored dot using project.color
Project name After dot Project name from project.yaml
Left tabs Center-left Tasks, Definition, Secrets, Settings — active tab highlighted
Right tabs Center-right Chat, Logs — active tab highlighted
Agent badge Far right ● Idle, ● Working, ● Wildfire, ⚠ Issue

Status bar:

Element Position Description
Key hints Left Context-sensitive shortcuts for current focus/state
Connection status Right Connected or ⚠ Disconnected (with reconnect indicator)

Panel divider: Draggable with mouse to resize left/right split.

Left panel tabs:

Tab Content
Tasks Task list grouped by status (Draft, Ready, Done)
Definitions Markdown editor for project definition
Settings Git config, automation toggles

Right panel tabs:

Tab Content
Chat Agent terminal (live stream from daemon)
Logs Past session logs per task

TUI Navigation

Input Action
Tab Switch between left/right panels
j/k or ↓/↑ Move up/down in lists
h/l or ←/→ Switch tabs
1/2/3 Switch left panel tabs (Tasks/Definition/Settings)
Enter Select/edit item
Esc Close overlay / cancel / go back
Ctrl+s Save in overlay forms
Mouse click Select item, switch panels/tabs
Mouse scroll Scroll lists and terminal
Ctrl+q Quit
Ctrl+h Help overlay

Task actions (when task list focused):

Key Action
a Add new task
e / Enter Edit selected task
s Start task (move to Ready + start agent)
r Move task to Ready
t Move task to Draft
d Mark done
x Delete (soft)

Note: Single-letter shortcuts only work when task list panel is focused. When agent terminal is focused, all input goes to the agent.

TUI Features

  • Mouse enabled (click, scroll)
  • Vim-like + arrow key navigation
  • Resizable panels
  • Scroll support in all panels
  • Real-time updates from daemon
  • Type into agent terminal when Chat panel focused

TUI Architecture

Pattern: Bubbletea Elm architecture (Model → Update → View).

Concept Role
Model Application state: tasks, agent status, terminal buffer, focus, overlays
Update Processes messages (key events, mouse events, gRPC responses, window resize) → returns new model + commands
View Renders model to string using lipgloss styling → Bubbletea writes to terminal

Component hierarchy:

App (root model)
├── Header              — project dot, name, tab selectors, agent badge
├── LeftPanel
│   ├── TaskList        — grouped task list with status sections
│   ├── DefinitionView  — read-only viewport of project definition
│   └── SettingsForm    — editable project settings fields
├── RightPanel
│   ├── Terminal        — raw PTY output viewport
│   └── LogViewer       — log list + log content viewport
├── StatusBar           — key hints, connection status
└── Overlays
    ├── HelpOverlay     — keybinding reference
    ├── TaskAddForm     — title, prompt, criteria, status fields
    ├── TaskEditForm    — edit existing task fields
    └── ConfirmDialog   — inline y/n confirmation in status bar

Message routing: Global keybindings (Ctrl+q, Tab, Ctrl+h) are handled at root level first. If an overlay is active, it captures all remaining input. Otherwise, input routes to the focused panel.

Custom message types:

Message Source Purpose
DaemonConnectedMsg Init command Daemon gRPC connection established
DaemonDisconnectedMsg Connection monitor Lost connection to daemon
ProjectLoadedMsg GetProject RPC Project data loaded
TasksLoadedMsg ListTasks RPC Task list refreshed
AgentStatusMsg GetAgentStatus RPC Agent status polled
ScreenUpdateMsg SubscribeScreen stream Pre-rendered ANSI screen from agent
AgentIssueMsg SubscribeAgentIssues stream Auth/rate limit issue detected or cleared
TaskSavedMsg CreateTask/UpdateTask RPC Task save confirmed
ProjectSavedMsg UpdateProject RPC Settings/definition save confirmed
LogsLoadedMsg ListLogs RPC Log list loaded
LogContentMsg GetLog RPC Single log content loaded
ErrorMsg Any RPC gRPC error to display

Command pattern: All gRPC calls are wrapped as tea.Cmd functions. Streaming RPCs (SubscribeScreen, SubscribeAgentIssues) run in goroutines that call Program.Send() to push messages back into the update loop.

TUI State Management

State Source RPC Cache Strategy
Project config GetProject Load once on startup, refresh on ProjectSavedMsg
Task list ListTasks Load on startup, refresh after any task mutation
Agent status GetAgentStatus Poll every 2s while agent running
Terminal output SubscribeScreen Pre-rendered ANSI screen snapshots (viewport displays latest)
Agent issues SubscribeAgentIssues Latest issue held in model, cleared when issue resolves
Logs list ListLogs Load on demand when Logs tab selected
Log content GetLog Load on demand when log selected

Note: The TUI does NOT use the Subscribe event stream. That stream is designed for the GUI's multi-project view. The TUI is project-scoped and uses targeted RPCs: SubscribeScreen for pre-rendered ANSI content, SubscribeAgentIssues for issue notifications, and polling GetAgentStatus for agent state.

TUI gRPC Usage

Full mapping of TUI actions to gRPC RPCs:

TUI Action RPC Service
Load project GetProject ProjectService
Save definition UpdateProject ProjectService
Save settings UpdateProject ProjectService
List tasks ListTasks TaskService
Add task CreateTask TaskService
Edit task UpdateTask TaskService
Change task status UpdateTask TaskService
Delete task DeleteTask TaskService
Restore task RestoreTask TaskService
Start agent StartAgent AgentService
Stop agent StopAgent AgentService
Poll agent status GetAgentStatus AgentService
Stream terminal SubscribeScreen AgentService
Send keystrokes SendInput AgentService
Resize terminal Resize AgentService
Stream issues SubscribeAgentIssues AgentService
Clear issue ResumeAgent AgentService
List logs ListLogs LogService
View log GetLog LogService
Check daemon GetStatus DaemonService

Note: The TUI uses SubscribeScreen (pre-rendered ANSI from vt10x), not SubscribeRawOutput (raw PTY bytes). Since Bubbletea's Elm architecture requires deterministic rendering via the View() method, raw bytes cannot be cleanly integrated — they would need a separate terminal emulator. Instead, the daemon's vt10x terminal emulates the PTY output and sends pre-rendered ANSI SGR text, which the TUI displays in a lipgloss viewport. SubscribeRawOutput is used by the CLI's direct-attach mode, where raw bytes can be written to stdout.

TUI Terminal Panel

The right panel's Chat tab renders live agent output.

Rendering approach: Pre-rendered ANSI screen snapshots arrive via SubscribeScreen. The daemon's vt10x terminal emulator parses raw PTY output and renders the visible screen as ANSI SGR text. The TUI stores the latest snapshot and displays it in a lipgloss viewport, fitting naturally into Bubbletea's Elm update/view cycle.

Aspect Behavior
Scrollback Viewport supports scrollback via mouse scroll and PgUp/PgDn when terminal focused
Input mode When Chat panel focused, all keystrokes sent to agent via SendInput RPC
Global shortcuts Ctrl+q, Tab, Ctrl+h intercepted before input reaches agent
Resize Right panel size change → Resize RPC (debounced 100ms) → daemon resizes PTY → agent receives SIGWINCH
Empty state "No agent running. Press s on a task to start."
Agent exited "Agent stopped." with last output visible in scrollback
Wildfire display Shows current phase badge above terminal: Execute #0001, Refine, Generate

TUI Task Status Display

Internal Status Display Label Terminal Visual
draft Draft [ ] default style
ready (no agent) Ready [R] highlighted
ready (agent active) Active [●] animated spinner
done (success: true) Done [✓] green
done (success: false) Failed [✗] red

Task list item format:

[●] #0002 Implement login flow
[R] #0007 Add search feature
[ ] #0001 Setup project structure
[✓] #0003 Create database schema
[✗] #0006 Fix bug in auth

Tasks are grouped by status section (Draft, Ready, Done) with section headers showing count.

TUI Agent Issue Display

Agent issues (auth errors, rate limits) appear as colored banners above the terminal viewport in the right panel.

Issue Type Banner Color Banner Text Recovery Action
auth_required Yellow ⚠ Authentication required — switch to Chat and run /login User switches to terminal, runs /login in agent
rate_limited Yellow ⚠ Rate limited — resets at {time} Wait for reset, or press R to resume (ResumeAgent RPC)
(cleared) (none) Banner removed Automatic on issue clear

The agent badge in the header also changes to ⚠ Issue when an issue is active.

TUI Definition Editor

The Definition tab displays the project definition text.

Aspect Behavior
Display Read-only bubbles/viewport showing definition markdown as plain text
Edit Press e or Enter → launches $EDITOR via tea.ExecProcess
Suspend/resume TUI suspends (Bubbletea gives up terminal), external editor takes over, TUI resumes when editor exits
Save On editor exit, TUI reads temp file content, calls UpdateProject RPC with new definition
Pattern Same approach as existing CLI definition.go — external editor for multiline content

TUI Secrets Editor

The Secrets tab displays the secrets/instructions.md content.

Aspect Behavior
Display Read-only bubbles/viewport showing secrets instructions as plain text
Edit Press e or Enter → launches $EDITOR via tea.ExecProcess
Storage .watchfire/secrets/instructions.md — plain Markdown file on disk (not embedded in project.yaml)
Agent injection Content appended to system prompt under ## Secrets & Setup Instructions section
GUI Textarea with 1s debounced auto-save, same pattern as Definition tab

TUI Settings View

The Settings tab shows an inline form for project configuration.

Field Type Maps To
Name Text input project.name
Color Text input (hex) project.color
Auto-merge Toggle (on/off) project.auto_merge
Auto-delete branch Toggle (on/off) project.auto_delete_branch
Auto-start tasks Toggle (on/off) project.auto_start_tasks

Navigation: j/k moves between fields. Enter or Space edits text fields or toggles booleans. Changes saved immediately via UpdateProject RPC. Brief "Saved" indicator shown in status bar on success.

TUI Logs Tab

The right panel's Logs tab shows past agent session logs.

Aspect Behavior
List view bubbles/list showing log entries, newest first
Entry format Task #0001 — Session 2 — 2026-02-03 14:30 (completed) or Chat — Session 1 — ...
View log Enter on entry → switches to bubbles/viewport showing log content
Back Esc returns to log list
Loading Logs loaded on demand: ListLogs RPC when tab selected, GetLog RPC when entry opened

TUI Overlays & Modals

Overlays render on top of the main layout and capture all input until dismissed.

Architecture: Root model has an activeOverlay field. When set, the view function renders the overlay on top, and the update function routes all input to the overlay first.

Overlay Trigger Fields / Content Dismiss
Help Ctrl+h Keybinding reference table Esc or Ctrl+h
Add Task a (task list focused) Title (text), Prompt (textarea), Criteria (textarea), Status (draft/ready) Ctrl+s saves, Esc cancels
Edit Task e or Enter (task list focused) Same fields as Add, pre-filled Ctrl+s saves, Esc cancels
Confirm Delete x (task list focused) Inline in status bar: Delete task #0001? (y/n) y confirms, n or Esc cancels
Quit Confirm Ctrl+q (if agent running) Inline in status bar: Agent running. Quit? (y/n) y quits, n or Esc cancels

Multiline fields (Prompt, Acceptance Criteria) use bubbles/textarea. Submit with Ctrl+s, cancel with Esc. Tab in textarea inserts a tab character (panel switching disabled while overlay active).

TUI Header & Status Bar

Header format:

● project-name  Tasks | Definition | Settings    Chat | Logs  ● Working
Element Description
Project dot Circle in project's configured color
Project name From project.yaml
Left tabs Active tab is bold/highlighted, inactive is dimmed
Right tabs Same styling as left tabs
Agent badge Current agent state (see below)

Agent badge states:

State Badge
No agent ● Idle (dim)
Agent running (task) ● Task #0001 (green)
Agent running (chat) ● Chat (green)
Agent running (wildfire) ● Wildfire (orange)
Agent issue ⚠ Issue (yellow)

Status bar key hints by context:

Context Key Hints
Task list Ctrl+q quit Ctrl+h help Tab switch a add e edit s start r ready d done x delete
Terminal Ctrl+q quit Ctrl+h help Tab switch (input goes to agent)
Definition Ctrl+q quit Ctrl+h help Tab switch e edit (opens $EDITOR)
Settings Ctrl+q quit Ctrl+h help Tab switch j/k navigate Enter edit Space toggle
Overlay Ctrl+s save Esc cancel

TUI Focus Management

The TUI has two focus zones: left panel and right panel.

Aspect Behavior
Switch focus Tab toggles between left and right panel
Visual indicator Focused panel has highlighted/bright border, unfocused panel has dimmed border
Mouse click Clicking inside a panel focuses it; clicking a tab switches to that tab
Chat focus When Chat (right panel) is focused, all input routes to agent via SendInput except global shortcuts (Ctrl+q, Tab, Ctrl+h)
Left panel focus Single-letter shortcuts (a, e, s, r, t, d, x) active only when left panel focused
Tab switching 1/2/3 switches left panel tabs, h/l or ←/→ switches tabs in focused panel

TUI Resize Behavior

Aspect Behavior
Window resize tea.WindowSizeMsg → recalculate panel dimensions → propagate to all children
Default split 40% left / 60% right
Draggable divider Mouse drag on divider adjusts split ratio
Minimum terminal size 80 columns × 24 rows. Below this, display "Terminal too small" message
Agent resize Right panel dimension change → Resize RPC (debounced 100ms) → daemon resizes PTY

TUI Styling

Element Color / Style
Focused border Bright white or project color
Unfocused border Dim gray
Active tab Bold, underlined
Inactive tab Dim
Task: draft Default foreground
Task: ready Cyan/blue
Task: active (agent) Green with spinner
Task: done (success) Green
Task: done (failed) Red
Issue banner Yellow background, dark text
Status bar Inverted (light text on dark background)
Header Bold project name, styled tabs

Color approach: ANSI 256 colors (not true color) for broad terminal compatibility. Use lipgloss.AdaptiveColor to provide both light and dark terminal variants for each color.

TUI Startup Flow

Step-by-step sequence when watchfire is invoked with no arguments:

1. Cobra detects no subcommand → launches Bubbletea program
2. Init() returns startup commands:
   a. Check/start daemon (read daemon.yaml, verify PID, start if needed)
   b. Connect to daemon via gRPC
3. On DaemonConnectedMsg:
   a. Load project (GetProject RPC)
   b. Load tasks (ListTasks RPC)
   c. Check agent status (GetAgentStatus RPC)
4. On data loaded:
   a. If agent running → subscribe to SubscribeRawOutput + SubscribeAgentIssues
   b. If no agent → auto-start chat session (StartAgent RPC, mode=chat)
5. Render initial view
6. Enter main update/view loop

Auto-reconnect: If daemon connection drops, TUI shows "Disconnected" in status bar and attempts reconnection every 3 seconds. On reconnect, reloads project/tasks and resubscribes to streams.

TUI Error States

Error Display Recovery
Daemon not running "Starting daemon..." progress in center Auto-start daemon, retry connection
Connection lost "⚠ Disconnected" in status bar Auto-reconnect every 3s
Daemon shut down "Daemon shut down. Press any key to exit." Exit TUI
Agent crashed "Agent stopped unexpectedly." in terminal panel User restarts via s
Project not found "Not a Watchfire project. Run watchfire init first." → exit User runs watchfire init
gRPC error Brief error flash in status bar (3s) Automatic retry for transient errors
Git not found "Git is required. Install git and try again." → exit User installs git
Agent binary missing "Claude Code not found. Configure path in settings." → exit User configures agent path

TUI v1 Scope

Included:

  • Split-view layout (left panel + right panel)
  • Task CRUD with full status transitions (draft ↔ ready → done)
  • Agent terminal streaming via raw PTY bytes
  • Agent start/stop (chat, task, start-all, wildfire modes)
  • Wildfire phase display in terminal panel
  • Project definition editing via external $EDITOR
  • Settings form with inline editing
  • Help overlay with keybinding reference
  • Issue banners (auth required, rate limited) with recovery actions
  • Mouse support (click, scroll, drag divider)
  • Keyboard navigation (vim-style + arrows)
  • Context-sensitive status bar with key hints
  • Connection status with auto-reconnect
  • Log list and log viewer

Excluded (future):

  • Inline definition editor (always uses $EDITOR in v1)
  • Trash tab (soft-deleted tasks managed via CLI commands)
  • Branch management tab
  • Log deletion from TUI
  • Task search/filter
  • Multi-project switching
  • Theme customization
  • Notifications
  • Split terminal (multiple agents visible)

Task Work Order

When multiple tasks are in ready status, agent picks next by:

  1. Sort by position (ascending)
  2. If equal, sort by task_number (ascending)
  3. Pick first

New tasks are appended to the bottom of the queue (position = max(position)+1). Manual reorder via ReorderTasks rewrites positions densely 1..N.

Wildfire Mode

Three-phase autonomous loop. Each phase is a separate agent process. The daemon manages transitions.

Phase Working Dir Completion Signal Description
Execute Worktree Task YAML status: done Work on ready tasks (same as task mode). One agent per task.
Refine Project root .watchfire/refine_done.yaml Analyze codebase, improve a draft task's prompt/criteria, set status: ready.
Generate Project root .watchfire/generate_done.yaml Analyze project, create new tasks if meaningful work remains.

State machine:

Execute (ready tasks) → Refine (draft tasks) → Generate (no tasks left)
         ↑                      ↑                         |
         └──────────────────────┘─────────────────────────┘
                                                (if new tasks created → loop)
                                                (if no new tasks → chat mode)
Aspect Behavior
Entry watchfire wildfire
Phase selection Daemon picks phase based on available tasks: ready → execute, draft → refine, none → generate
Phase completion Agent creates signal file → daemon detects, deletes file, stops agent → next phase
Stop conditions Generate phase creates no new tasks → transitions to chat mode, OR Ctrl+C
Autonomy Fully autonomous, no human approval between cycles

GUI (Electron)

Overview

The GUI is a multi-window, multi-project client built with Electron, connected to the daemon via gRPC-Web. v8 "Inferno" replaced the single-window-with-sidebar-navigation model with independent OS windows: a persistent home window (dashboard / mission control) anchors the set, and each project opens in its own per-project window that can live on a separate monitor. An optional always-on-top mini-monitor gives an ambient fleet view. A main-process window registry tracks every window, and PTY output, lifecycle events, and notification/focus clicks are all routed per-window.

Multi-Window Model

Window registry (main process)

gui/src/main/windows.ts holds the registry, keyed by Electron BrowserWindow.id:

interface WfWindow { win: BrowserWindow; kind: 'home' | 'project' | 'monitor'; projectId?: string }
const windows = new Map<number, WfWindow>()
  • kind: 'home' — dashboard / mission-control singleton.
  • kind: 'project' — one window per project (carries projectId).
  • kind: 'monitor' — always-on-top mini-monitor singleton.

A module-level lastFocusedId tracks the most-recently-focused window (via a win.on('focus') listener) for tray/notification fallback routing. Accessors: getProjectWindow(projectId), getHomeWindow(), getMonitorWindow(), allWindows(), getOpenProjectWindowIds().

Window creation

Function Behavior
createHomeWindow() Dashboard / mission control. Singleton — focuses the existing window if open. Restores bounds via loadWindowState('home') (default 1280×800). Renderer loaded with no query (home scope).
createProjectWindow(projectId) One window per project — focuses the existing one if already open. Title = project name (from projects.yaml, fallback Watchfire). Renderer loaded with ?project=<id> so it boots straight into the project (no dashboard flash). Records the project in openProjects[] for session restore; removed on close.
createMonitorWindow() Always-on-top mini-monitor singleton. Sets alwaysOnTop: true + setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); small (default 320×460, min 240×200). Renderer loaded with ?monitor=1. Excluded from the window-cycle shortcut.

restoreOpenProjectWindows() runs at startup to reopen the project windows that were open at last quit, validating each id against projects.yaml (stale ids skipped, so deleted projects aren't resurrected).

Per-window PTY routing

The integrated terminal's PTYs run in the Electron main process (gui/src/main/pty-manager.ts, node-pty) — not the daemon. Each session is keyed to the window that created it:

interface PtySession { pty: IPty; id: string; windowId: number }
const sessions = new Map<string, PtySession>()   // keyed by session UUID
  • pty-create resolves the originating window via BrowserWindow.fromWebContents(ev.sender); output (pty-output / pty-exit) is sent back only to that window's webContents.
  • On window close, destroyForWindow(windowId) kills only that window's sessions; other windows' terminals persist. window-all-closed / before-quit tear down everything.

Window state & session restore

gui/src/main/window-state.ts persists geometry to ~/.watchfire/window-state.json:

interface WindowStateFile {
  home?: WindowBounds                      // singleton dashboard
  monitor?: WindowBounds                   // singleton mini-monitor
  projects?: Record<string, WindowBounds>  // keyed by projectId
  openProjects?: string[]                  // reopened on relaunch
}
  • Saves are debounced (500ms) on resize/move/close.
  • isOnVisibleDisplay(bounds) discards off-screen bounds (e.g. an unplugged monitor) so a window never restores out of view.
  • The pre-v8 flat {x, y, width, height} shape (single home window) is migrated to { home: bounds } on read.

Single-instance lock

app.requestSingleInstanceLock() (gui/src/main/index.ts) ensures a single GUI process. A second launch quits immediately; the running instance receives second-instance and opens/focuses the home window. (Two GUI processes would each run a daemon watcher and double-merge worktrees.)

IPC fan-out & routing

Event Routing
daemon-ready, daemon-shutdown, the four update-* events, project-windows-changed broadcast(...) in windows.ts — sent to every window in the registry
Notification click (TASK_FAILED, RUN_COMPLETE) Open/focus the project's own window via createProjectWindow(projectId), then send notifications:click (deferred to did-finish-load if the window is still loading)
Notification click (WEEKLY_DIGEST) Surface the home window (global event)
Tray focus event (DaemonService.SubscribeFocusEvents) Project events → focusProjectWindow(projectId, target, taskNumber) (open/focus, route to tab/task); global/digest → home window

Single-notifier election (decision D1). With N windows subscribed to the daemon's notification stream, only the home window plays the sound and fires the OS toast — guarded by isHomeWindow() in both the notifications store's start() and App.tsx. The home window is likewise the sole subscriber to the tray focus stream (startFocus() gated on isHomeWindow()), so a single event never fans out into N toasts or N focus attempts. Project and monitor windows skip these streams entirely.

Renderer boot-scoping

gui/src/renderer/src/lib/window-scope.ts derives the scope from the URL the main process set when creating the window:

type WindowScope = { kind: 'home' } | { kind: 'project'; projectId: string } | { kind: 'monitor' }

?monitor=1 → monitor, ?project=<id> → project, no query → home. The app-store reads this once at boot (windowScope), so a project window pre-selects its project and renders ProjectView full-bleed — no sidebar project list, no dashboard flash. isHomeWindow() / isMonitorWindow() gate window-specific behaviour (e.g. the single-notifier election).

Views

Home Window — Dashboard / Mission Control

The home window is the cross-window monitor: "what's running everywhere, and what needs me?" It hosts the dashboard (and a denser row view) over all registered projects, the sidebar, and global Settings.

  • Project cards/rows reuse the Beacon Glance treatment: status dot, task counts (Draft/Ready/Done), current task, per-card live PTY preview, elapsed timers, last-activity timestamp, needs-attention treatment, auto-sort by activity — plus a wildfire phase badge when a project is looping (see Wildfire below).
  • "Open in new window" (context menu / modifier-click) on each card and sidebar row → createProjectWindow(projectId); a plain click also opens the project's window.
  • Needs-attention aggregates across all projects (auth/rate-limit issues, TASK_FAILED) with click-through that opens/focuses the relevant project window. The system tray's Needs-attention / Working / Idle sections route clicks the same way.
  • "Add Project" button.

Add Project Wizard (3 steps)

Step 1 — Project Info:

  • Project name
  • Path (folder picker)
  • Git status (detected)
  • Branch (detected)

Step 2 — Git Configuration:

  • Target branch
  • Automation toggles:
    • Auto-merge on completion (local only)
    • Delete branch after merge (local only)
    • Auto-start tasks

Step 3 — Project Definition:

  • Rich markdown editor for project context (the shared MarkdownEditor, see below)
  • Skip option

Project View (per-project window)

Each project window is one project's cockpit. v8 flipped the layout to chat-primary — the agent is the work surface, the rest is reference:

┌───────────────────────────────────────────────────────────────┐
│  Chat / Agent terminal (LEFT, primary)  ║  Reference (RIGHT)   │
│  ┌ Chat | Branches | Logs ┐             ║  ┌ Tasks | Defn |    │
│                                         ║    Insights | Secrets│
│  > live agent output...                 ║    | Trash | Settings│
│                                         ║  resizable / hideable │
├───────────────────────────────────────────────────────────────┤
│  Integrated terminal (bottom, Cmd+`)                          │
└───────────────────────────────────────────────────────────────┘

Left pane (primary, wide) — the agent Chat / terminal streamed from the daemon, with Branches and Logs as sibling tabs (views/ProjectView/RightPanel.tsx; the component name is historical — it now renders on the left).

Tab Content
Chat Agent terminal streamed from daemon (gRPC-Web SubscribeScreen). User can type input. Mode badge + wildfire control in the header.
Branches List of worktrees/branches with status, actions
Logs Past session logs per task

Right region (reference, resizable & hideable) — a tabbed panel (REF_TABS). Width persists in localStorage (wf-right-panel-width, ~560px default) via a drag divider.

Tab Content
Tasks Grouped by status (Draft, Ready, Done). Search, filters. Add task button. Status transition buttons.
Definition Rich markdown editor for the project definition (see below)
Insights Per-project analytics: task throughput + code-output (commits, net lines, files, merge rate, code-churn-by-day, per-agent output)
Secrets .watchfire/secrets/instructions.md editor
Trash Soft-deleted tasks. Restore, permanent delete, empty trash.
Settings Git config, automation toggles, project color

Focus chat: the header toggle (Maximize2/Minimize2) and double-clicking the divider hide the right reference region so chat goes full-width — there is no side-swap toggle (chat-left is the only layout; decision in the v8 spec). Per-project focus state persists in wf-chat-focus-<projectId>. A tray focus-request for Tasks reveals the reference region if it was collapsed.

Bottom panel (footer bar, expands upward):

  • Integrated terminal — Always-visible footer bar at the bottom of the project view. Clicking it or pressing Cmd+` spawns a shell session via node-pty (runs in the Electron main process, not the daemon). Supports up to 5 tabbed sessions per project with resizable panel height. Per-window PTY routing (above) keys each session to its window. Sessions persist across panel collapse and are cleaned up when the user closes the tab (X button) or closes the window.

Wildfire (GUI)

Autonomous wildfire mode (Execute → Refine → Generate) runs in the GUI (v8 Inferno — parity with the TUI/daemon). It reuses the existing gRPC surface — StartAgent with mode = "wildfire" and the AgentStatus.wildfire_phase field ("execute" | "refine" | "generate" | "") — so no new RPC was needed.

  • views/ProjectView/WildfireControl.tsx — a start control in the ProjectView, a confirm-before-start modal ("Start Wildfire?" — autonomous, runs unattended, spends tokens continuously), a live phase indicator, the current task number, and a stop control.
  • components/WildfirePhaseBadge.tsx — an Execute → Refine → Generate stepper (a flame icon pulses on the active phase), plus a compact single-line variant ("Wildfire · Execute" / "Idle") for space-constrained surfaces.
  • Mission control: the home window's project cards (Dashboard/ProjectCard.tsx) and rows (Dashboard/ProjectRow.tsx) render the compact badge, so you can see which projects are looping and where.

Rich Markdown Editor

components/ui/MarkdownEditor.tsx is the standard editing surface for all markdown / long-text fields (closes issue #22). It is built on CodeMirror 6 (@codemirror/lang-markdown) and is source + preview, not WYSIWYG — deliberately, so exact markdown/whitespace round-trips cleanly into the daemon-written YAML block scalars (prompt, acceptance_criteria, project definition). It offers three view modes (edit / split / preview), a formatting toolbar (bold, italic, inline code, link, heading, bullet list), and Cmd/Ctrl+B / Cmd/Ctrl+I shortcuts. Surfaces: the project definition (DefinitionTab.tsx), the Add Project wizard's definition step (AddProject/StepDefinition.tsx), and the task modal's prompt + acceptance_criteria fields (TasksTab/TaskModal.tsx). Each swap preserves the surface's existing save semantics (DefinitionTab's debounced autosave, the wizard's value plumbing + Skip path, the modal's create/update flow); short fields like the task title stay plain inputs.

Mini Monitor (v8 Inferno — stretch)

An always-on-top, floating mini-window that gives a glanceable, ambient view of the whole fleet without keeping the full dashboard in front. Opened from the Window → Mini Monitor menu item (Cmd/Ctrl+Shift+M) or the sidebar's "Mini Monitor" button, and persisted/restored by geometry like the other windows.

  • Window: created by createMonitorWindow() in gui/src/main/windows.ts as a separate registry entry of kind: 'monitor' (singleton). It bypasses the 900×600 baseWindowOptions floor (default 320×460, min 240×200), sets alwaysOnTop: true, and setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) so it floats above other apps and over fullscreen spaces. It is excluded from the Cmd+Shift+]/[ window-cycle. Bounds are saved under the monitor key in window-state.json.
  • Scope: the renderer boots with ?monitor=1; lib/window-scope.ts resolves this to { kind: 'monitor' } (isMonitorWindow()). App.tsx renders only views/MiniMonitor/MiniMonitor.tsx for this scope — no sidebar, header, or reconnect overlays. Like project windows (and unlike the home window), it does not subscribe to the daemon notification/focus streams, so it never adds a duplicate OS toast (D1).
  • Content: one compact row per project (activity-sorted via sortProjectsByActivity): a status dot that pulses when the agent is working, the name, and a one-line status — what the agent is doing, a red needs-attention flag (reusing useNeedsAttention), or the ready/idle task counts. Clicking a row opens (or focuses) that project's own window. It owns its own light poll of the project list + agent statuses while open.

Global Settings

Section Content
Defaults Default branch, automation toggles for new projects
Appearance Theme (System/Light/Dark)
Claude CLI Path detection, custom path, install instructions
Updates Check frequency, auto-download toggle

Task Status Display

Internal Status Display Label Visual
draft Todo Default style
ready In Development Highlighted
ready + agent active In Development Animated indicator (pulsing/spinner)
done (success: true) Done Green indicator
done (success: false) Failed Red indicator

Scope

Included:

  • Multi-window model: home window (dashboard / mission control) + independent per-project windows + optional mini-monitor (window registry, per-window PTY routing, window-state.json + session restore, single-instance lock)
  • Home window dashboard with Glance project cards/rows + cross-project needs-attention + tray click-through
  • Add Project wizard
  • Chat-primary Project View (Chat/Branches/Logs left; Tasks/Definition/Insights/Secrets/Trash/Settings right; integrated terminal footer)
  • Wildfire mode in the GUI (control + confirm modal + Execute/Refine/Generate phase indicator)
  • Rich markdown editor (MarkdownEditor) across definition, wizard, and task fields
  • Per-project + global Insights (task throughput + code-output) with CSV/Markdown export
  • OS notifications + sounds (single-notifier election) + system tray sections
  • Global Settings (Defaults, Appearance, Claude CLI, Updates)

Excluded (future):

  • Generate Tasks / Auto-fill Definitions buttons in the GUI (available via TUI/CLI)
  • Branch mode toggle (always "new branch per task")

MCP Server (watchfire mcp) — v9.0 Firestorm

Overview

The MCP server turns Watchfire into a factory for other coding agents. Any MCP client — Claude Code, Codex, Gemini CLI, opencode, Copilot CLI, or a custom agent — can connect to Watchfire and delegate work: file tasks, launch sandboxed agent runs, await completion, and inspect the resulting diff. The outer agent plans and reviews; Watchfire manufactures the code.

It is the fourth thin client. It contains no orchestration logic of its own: every tool call is a translation to an existing daemon gRPC RPC, exactly like the TUI and GUI. The daemon remains the single brain (worktrees, sandboxing, merging, chaining, notifications all keep working unchanged — a task created over MCP is indistinguishable from one created in the TUI).

MCP client (Claude Code / Codex / …)
   │  stdio (JSON-RPC, MCP)
   ▼
watchfire mcp serve            ← thin translation layer, no state
   │  gRPC (localhost)
   ▼
watchfired                     ← all orchestration

Transport & Lifecycle

  • Transport: stdio only in v9.0. The MCP client spawns watchfire mcp serve as a subprocess; the server auto-starts the daemon if needed (same path as the CLI) and connects via config.LoadDaemonInfo().
  • SDK: official github.com/modelcontextprotocol/go-sdk (v1.6.1).
  • Shutdown: the MCP spec shuts a stdio server down by closing its stdin. Serve wraps the transport so it can tell a clean end-of-input (or a cancelled context from SIGINT/SIGTERM) from a real fault, and exits 0 in that case — the SDK reports the same condition as a session error, and a nonzero exit would make clients log every normal shutdown as a crash. mcp serve also sets SilenceUsage so a genuine startup failure is not buried under a flag dump.
  • Scoping: tools take an optional project argument (project id or name). When watchfire mcp serve is started inside a registered project directory, that project is the default and project may be omitted — mirroring the CLI's project resolution (cwd walk-up + auto-register). A single server instance can address all registered projects.
  • Concurrency: the server is stateless; concurrent tool calls are safe because the daemon already serializes per-project agent operations.

Tool Catalog

Tool names are unprefixed (clients namespace by server name). Kept deliberately small: 22 tools in five documented groups.

The Telegram group (v10.1 Torch) is the exception to "every tool is a thin translation of one RPC": telegram_status and telegram_configure each compose two RPCs, because the pairing status and the integration document are separate reads and Save needs the current document to avoid resetting unspecified fields. No proto change was needed — every Telegram RPC already existed for the CLI and the settings UIs.

The "Group" column below is the documented grouping. It is not always the registry group in code: --read-only filtering keys off a per-tool registry group, and the three pure reads that live in write-heavy documented groups (get_agent_status, list_tasks, get_task) carry the inspect registry group so a read-only server keeps them. Each tool declares its own readOnly flag in toolSpec, and a test asserts the flag and the registry group can never disagree.

Group Tool Backing RPC Notes
Project list_projects ProjectService.ListProjects + AgentService.GetAgentStatus Status summary per project (agent running, mode, current task)
Project get_project ProjectService.GetProject + GetGitInfo + TaskService.ListTasks Definition, default agent, branch, task counts
Task create_task TaskService.CreateTask title, prompt, acceptance_criteria?, status (draft|ready, default draft), agent? override, position?. Validated daemon write path — never authors YAML directly. Does not start anything
Task list_tasks TaskService.ListTasks Optional include_deleted. Read-only ⇒ served under --read-only
Task get_task TaskService.GetTask Full task incl. status / success / failure_reason. Read-only ⇒ served under --read-only
Task update_task TaskService.UpdateTask Edit fields, flip draftready (done is the executing agent's to write)
Task delete_task TaskService.DeleteTask Soft delete (trash); restorable from TUI/GUI
Run run_task AgentService.StartAgent (mode=task) Pre-checks GetAgentStatus and refuses if an agent is running — the daemon's StartAgent would otherwise replace it
Run run_all AgentService.StartAgent (mode=start-all) Run all ready tasks in sequence; same refusal pre-check
Run start_wildfire AgentService.StartAgent (mode=wildfire) Autonomous three-phase loop; same refusal pre-check
Run stop_agent AgentService.GetAgentStatus + StopAgent No-op (stopped: false) when nothing is running, not an error
Run get_agent_status AgentService.GetAgentStatus Mode, task, wildfire phase, blocking issue if any. Read-only ⇒ served under --read-only
Run wait_for_task polls TaskService.GetTask + GetAgentStatus Blocking with timeout_seconds (default 300, max 600). Returns terminal task state, or current state + timed_out: true. Clients re-call to keep waiting — this is the factory loop's synchronization point
Inspect get_task_diff InsightsService.GetTaskDiff FileDiffSet rendered as unified diff text, honoring the daemon's truncation cap
Inspect get_agent_screen AgentService.GetScrollback Tail of the live agent terminal (plain text, ANSI stripped) — lets the outer agent peek at a stuck run
Inspect get_insights InsightsService.GetProjectInsights / GetGlobalInsights Throughput + code-output summary; scope = project (default) | global
Inspect list_logs LogService.ListLogs Past session transcripts (metadata)
Inspect get_log LogService.GetLog One transcript, tail-capped at 64 KiB
Telegram telegram_status IntegrationsService.GetTelegramPairingStatus + ListIntegrations Bridge running, enabled, token stored, bot username, pairing state, paired chats — plus a next_step naming the one action that unblocks setup. Read-only ⇒ served under --read-only
Telegram telegram_configure IntegrationsService.ListIntegrations + SaveIntegration Flip enabled, optionally store the bot_token. Reads current state first because Save replaces the whole document; an omitted token keeps the stored one. Refuses to enable with no token anywhere
Telegram telegram_pair IntegrationsService.BeginTelegramPairing Mints the one-time code + deep link and returns immediately — pairing is observed by polling telegram_status, not by blocking
Telegram telegram_unpair IntegrationsService.RevokeTelegramChat Removes one chat_id from the allowlist. The only surface that exposes revocation — the CLI has no unpair

Descriptions are part of the contract. The catalog is the only thing an outer model reads before choosing a call, so every tool carries a paragraph stating consequences (not just capability), a title, and MCP annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint: false — the tools' world is this machine). internal/mcpserver/catalog_test.go asserts these properties over the real tools/list payload: argument naming is uniform (project, task_number, log_id), the schema constraints are published (enums, defaults, min/max), and the specific sentences an outer agent needs are present.

The Factory Loop

The canonical outer-agent workflow the tool surface is designed around:

  1. create_task — files the work; creating a task never starts it
  2. run_task — starts a sandboxed agent on it in an isolated worktree
  3. wait_for_task — blocks until the daemon's reactive lifecycle finishes (agent writes status: done → watcher → stop → merge/auto-PR → cleanup); on timed_out: true the client simply calls again
  4. get_task — check success / failure_reason (done ≠ succeeded)
  5. get_task_diff — review what was merged
  6. Iterate: file follow-up tasks or fix-ups

status: ready only queues a task: it makes the task eligible for run_all and for an in-flight run_all/wildfire chain to pick up. Nothing in the daemon starts an agent in response to a task becoming ready (Project.AutoStartTasks is persisted and exposed in the settings UIs but is not read by any daemon codepath), so the tool descriptions must not promise otherwise.

Escape hatches: get_agent_screen to diagnose a stuck agent, stop_agent to abort, update_task/delete_task to reshape the queue.

Error Contract

Tool errors are read by a model, not by a human tailing a log, so they name the problem and the way out. internal/mcpserver/errors.go centralises this:

  • Daemon RPC failures go through rpcErr, which strips the gRPC envelope (rpc error: code = … desc = …) and passes the daemon's own specific message through.
  • Unavailable / DeadlineExceeded / Canceled are reported as the daemon is not reachable — a different failure from a bad argument, with watchfire daemon start as the fix.
  • A daemon that cannot be started or reached during handshake fails through startupErr, which explains that the MCP server is a thin client needing a local watchfired.
  • Argument errors enumerate the valid values: unknown project → the registered projects, unknown agent backend → the registered backends, busy project → the running mode and task number plus stop_agent / wait_for_task.

Safety & Limits

  • Local-only, by construction: the MCP server never opens a listening socket. Its only transport is stdio, spawned as a subprocess by an MCP client running on the same host as the daemon. It runs as the invoking user and talks to the localhost daemon over the same unauthenticated channel as the CLI. Nothing in v9.0 makes Watchfire reachable from outside the host machine. This is enforced, not just asserted: local_only_test.go parses the package's own source (plus cmd/watchfire/mcp.go) and fails on any net.Listen* / http.ListenAndServe / grpc.NewServer call or any HTTP/SSE MCP transport, and pins that Serve wires the transport to os.Stdin/os.Stdout; the e2e test checks the same property from outside by lsof-ing the live server process for listening or non-loopback sockets.
  • --read-only flag: watchfire mcp serve --read-only registers only the observation tools — 10 of the 18: list_projects, get_project, list_tasks, get_task, get_agent_status, get_task_diff, get_agent_screen, get_insights, list_logs, get_log. Filtering happens at registration time, so the eight write/run tools are absent from tools/list and unknown when called by name, not merely refused. An observation-only deployment for dashboards or less-trusted callers.
  • Recursion: a Watchfire-managed agent could itself call the Watchfire MCP server (the sandbox permits local exec + network). This is permitted but not the designed pattern; the designed pattern is outer agent → Watchfire. Documentation warns about unbounded task-spawning loops.
  • Destructive scope: no tool deletes projects, edits settings/integrations, empties trash, or touches secrets. That surface stays in the human-facing clients.

Client Onboarding

Setup follows one UX rule everywhere: pick one of the most-used harnesses and Watchfire does the whole process for you, or pick Custom and get the generic snippet to paste into any MCP client.

The installer logic lives in internal/mcpserver/install/ as pure, dependency-light config writers (detect client → parse existing config → merge watchfire entry → write back). It is shared by every surface:

Surface Mechanism
CLI watchfire mcp install [client] — direct; no-arg shows an interactive picker (five clients + Custom)
Daemon SettingsService.GetMcpClientStatus (per-client: detected? configured? config path) + SettingsService.InstallMcpClient — the daemon runs as the same user, so it can write the same config files. This is the only proto/daemon change in Firestorm
TUI Settings view → "MCP" section: client list with detected/configured badges, Enter to install, Custom shows the copyable snippet
GUI Global Settings → "MCP" panel: same list with one-click Install buttons + copy-snippet for Custom, via gRPC-Web

Per-client mechanisms:

Client Mechanism
claude-code claude mcp add watchfire -- watchfire mcp serve (user scope)
codex ~/.codex/config.toml [mcp_servers.watchfire] entry
gemini ~/.gemini/settings.json mcpServers entry
opencode opencode config mcp entry
copilot Copilot CLI MCP config entry
custom Show/print the standard {"command": "watchfire", "args": ["mcp", "serve"]} JSON block for any other MCP client (--print in the CLI)

Each installer is best-effort and idempotent: if the client CLI/config is absent or unparseable, surface the manual snippet instead of failing or clobbering the file.

Package Layout

cmd/watchfire/mcp.go            # cobra: watchfire mcp serve|install
internal/mcpserver/
├── server.go                   # SDK wiring, toolSpec registry, --read-only filtering, stdio transport
├── errors.go                   # rpcErr / startupErr / isCleanShutdown — the tool error contract
├── conn.go                     # daemon ensure+connect (reuses/exports internal/cli helpers)
├── resolve.go                  # project argument → project_id resolution (id, name, cwd default)
├── tools_project.go
├── tools_task.go
├── tools_run.go                # incl. wait_for_task polling
├── tools_inspect.go            # diff/scrollback/insights/logs rendering
├── catalog_test.go             # tools/list audit: naming, consequences, annotations, schemas
├── local_only_test.go          # source guard: no listener, no HTTP/SSE transport
├── e2e_test.go                 # //go:build mcpe2e — real binary + real daemon (make test-mcp-e2e)
└── install/                    # per-client config writers — shared by cmd/watchfire (CLI) AND the daemon's SettingsService

internal/mcpserver imports the generated proto client + internal/config only — no daemon-internal packages. The exception is internal/mcpserver/install/, which is deliberately standalone (stdlib + config parsing only) so the daemon can import it without pulling in the MCP runtime.

Testing

Layer Where Runs in
Handler unit tests tools_*_test.go — handlers against fake gRPC clients make test
Catalog audit catalog_test.go — registers the real tools over an in-memory transport and asserts the tools/list payload make test
Local-only guard local_only_test.go — AST + source scan of the serve path make test
End-to-end e2e_test.go — spawns the real watchfire mcp serve against a real watchfired under an isolated HOME, speaks MCP over stdio: initialize → tools/list → list_projects → create_task(draft) → update_task(ready) → get_task → delete_task, plus the actionable-error, --read-only, no-listening-socket and onboarding-consistency checks make test-mcp-e2e

The e2e test sits behind the mcpe2e build tag (so make test never compiles it) and skips itself when the binaries are absent. It never starts a coding agent: the run tools are exercised only through refusal paths that fail before a process is created, and it asserts that flipping a task to ready starts nothing.

Scope

Included (v9.0): stdio server (local-only — no listening socket), the 18-tool catalog above, --read-only, client onboarding from all four surfaces (CLI mcp install, daemon RPCs, TUI Settings section, GUI Settings panel) for the five known harnesses + Custom snippet, and the test layers above (catalog audit, local-only guard, end-to-end factory loop).

Excluded (future):

  • Streamable HTTP transport + auth (would build on the Echo inbound server infrastructure)
  • MCP resources (task files, logs, digests as subscribable resources) & prompts
  • MCP-driven project creation/registration (watchfire init remains interactive)
  • Sampling / elicitation (server-initiated requests to the client)

Telegram Bridge (internal/daemon/telegram/) — v10.0 Torch

Overview

The Telegram bridge lets a user supervise Watchfire from their phone: pick a project from chat, see status, get pushed events, watch the agent conversation live, and reply into a running session — without disturbing whatever the TUI/GUI is doing. It is daemon-internal (not a separate client binary): a long-polling goroutine inside watchfired, started beside the Discord registrar and the Echo inbound server.

Command routing reuses the transport-agnostic inbound router from v4/v5: echo.Route(...) plus the production echo.CommandContext callbacks (internal/daemon/server/command_context.go, built in v10 task 0133 and shared by Slack, Discord, and Telegram — one implementation, three transports).

Local-only by long polling

Every other inbound provider (GitHub/Slack/Discord/GitLab/Bitbucket) requires a public HTTPS endpoint — a real obstacle for a local-first daemon on a laptop. Telegram's Bot API supports long polling (getUpdates): the daemon dials out; no listener, no tunnel, no port forward. This is the same local-only posture as the v9 MCP stdio decision. The bridge therefore does not go through the Echo HTTP server at all — it is an outbound-dialing goroutine like the Discord Gateway.

The bridge is fully inert unless configured: startTelegramBridge() (internal/daemon/server/server.go) is a no-op unless Telegram is enabled in ~/.watchfire/integrations.yaml AND the bot token resolves from the keyring — an unconfigured install starts no goroutine and dials nothing. SaveIntegration/DeleteIntegration restart the bridge against the fresh config, mirroring restartEchoServer/restartDiscordRegistrar.

Components

internal/daemon/telegrambot/        # Thin Bot API client (stdlib HTTP only, like slackbot/discordbot)
    client.go                       # getUpdates (long-poll, default 45s, Telegram max 50s), sendMessage,
                                    # editMessageText, setMyCommands, answerCallbackQuery, getMe
internal/daemon/telegram/           # The bridge (mirrors internal/daemon/discord/ in shape)
    bridge.go                       # Long-poll loop, offset tracking, graceful shutdown, restart-on-config-change
    commands.go                     # Per-chat dispatch: echo.Route verbs + Telegram-only verbs, /help, setMyCommands
    pairing.go                      # One-time pairing codes (crypto/rand, 8 chars, 10-min TTL, single active code)
    render.go                       # echo.CommandResponse → Telegram HTML (parse_mode=HTML; 4096-char chunking)
    runcontrol.go                   # /run /wildfire /generate /plan /new /stop /say + plain-text conversation — write side through the RunController seam
    login.go                        # /login — drives the session's OAuth dialog end to end: link to phone, code pasted back via injectSay, confirming Enter
    agents.go                       # /agent — AgentSelector seam (backend list + project default_agent)
    watch.go                        # Live conversation relay ("watch mode"): tailer, screen deltas, chatSender
internal/daemon/relay/telegram.go   # Outbound relay.Adapter: TASK_FAILED / RUN_COMPLETE / WEEKLY_DIGEST → paired chats
internal/daemon/server/
    command_context.go              # Production echo.CommandContext (shared by Slack/Discord/Telegram)
    integrations_telegram.go        # BeginTelegramPairing / GetTelegramPairingStatus / RevokeTelegramChat RPCs
    telegram_sessions.go            # telegram.SessionSource impl over agent.Manager (read-only seam)
    telegram_runcontrol.go          # telegram.RunController impl (StartAgent path + refusal backstop)

Configuration & secrets

Optional telegram: section in ~/.watchfire/integrations.yaml (models.TelegramIntegration): enabled, bot_token_ref, per-event toggles, and the paired_chats list (chat id, user id, display-only username, default_project_id, muted, watch_off — negative polarity so an absent field means watching, live relay being the default). The bot token lives in the OS keyring under watchfire.integration.telegram.bot_tokennever in YAML — via the existing LookupIntegrationSecret/PutIntegrationSecret path, and ListIntegrations serves only a token_set boolean. Old daemons ignore the new key (non-strict YAML); new daemons without it behave exactly as before.

Pairing (the security boundary)

Telegram bots are globally reachable — anyone can DM the bot. Pairing is the allowlist:

1. User creates a bot with `@BotFather`, pastes the token into Watchfire
   (GUI Settings → Integrations / TUI integrations overlay / CLI)
   → token goes to the keyring; daemon validates via getMe and starts the poller
2. User clicks Pair (any surface) → BeginTelegramPairing RPC returns a one-time
   code (8 chars, crypto/rand, 10-min TTL, single active code) + deep link
   https://t.me/<bot_username>?start=<code>  (rendered as a QR in the GUI)
3. User opens the link (or sends /pair <code>) → poller matches the code,
   persists the chat to paired_chats, replies with a welcome + command list,
   and invalidates the code
4. Unpaired chats: every message is ignored except /start and /pair, which reply
   with pairing instructions only. No project data ever flows to an unpaired chat.
5. Revoke = RevokeTelegramChat RPC (GUI/TUI list, revoke affordance);
   the poller drops the chat immediately

Clients poll GetTelegramPairingStatus to flip the UI from "code pending" to "paired" (states: NONE | PENDING | PAIRED | EXPIRED).

Command set

Routing: /status, /tasks, /retry, /cancel are pure dispatch through echo.Route with the Telegram CommandContext (which sees all registered active projects — pairing binds the chat to the daemon owner, so no per-guild/team scoping applies). The remaining verbs are Telegram-only, implemented in the bridge. setMyCommands registers the full set for Telegram's autocomplete.

Command Behavior
/projects Numbered list of registered projects (with agent-running glyphs) + inline keyboard buttons
/use <name|number> Select the active project for this chat (fuzzy name match; number indexes the last /projects listing). Persisted as default_project_id — survives daemon restarts
/status / /status all Bare: the existing router status handler for the active project (agent state, current task, todo/in-dev/done counts, needs-attention). all: the fleet view — one line per project with its live session state resolved through SessionSource.ActiveSession (wildfire phase, current task, "chat session", idle) plus a working count
/tasks Top active tasks (ListTopActiveTasks)
/run <n> / /run all Start task n / run-all (/runall remains a hidden alias). Replaces a running agent — a mode switch from Telegram has the same semantics as the GUI's mode buttons and the TUI: the daemon's StartAgent does its atomic kill+restart, and the confirmation names what was replaced ("Replaced the running task #0007 — …"). The MCP run_task never-replace contract is MCP-only, where the caller is another agent
/generate / /plan Start a generate-definition / generate-tasks session for the chat's project (replaces a running agent like /run, naming it); with watch on, the session streams like any other
/new Start a FRESH chat session, clearing the conversation context. A running chat is replaced atomically via RunController.RestartChat. This is the one remaining refusal: /new never displaces a working non-chat agent (the daemon's own guard enforces it too) — the reply says to /stop first
/login Re-authenticate Claude from the phone. Claude's OAuth token is revoked periodically ("Please run /login · 401 OAuth access token has been revoked"), and the CLI's sign-in URL carries a per-process PKCE code_challenge — it cannot be pre-generated, so the bridge drives the live session's own /login dialog through the two primitives it already has: screen snapshots (read) and injectSay (write). It types /login, confirms the method picker (Enter → Claude subscription), scrapes the …/oauth/authorize?… URL off the (possibly line-wrapped) screen, sends it to the chat, and arms the chat so its next plain-text message is pasted into the session as the code ("cancel" disarms). The dialog does not end there: Claude answers a good code with "Login successful. Press Enter to continue…" and holds the session until that keystroke arrives, so the flow waits for that screen, presses the final Enter, verifies the dialog closed, and reports the account it logged in as (a rejected code is named as rejected). The URL is rebuilt only from the run of consecutive unbroken screen lines starting at https:// — joining the whole screen welded the "Paste code here if prompted >" line onto the state parameter (v10.0.4). When a watched session raises auth_required, the relay posts a one-time hint pointing at /login. Dialog markers ("Select login method", "Use the url below to sign in", "Paste code here", "Press Enter to continue") were pinned from live captures of Claude Code v2.1.238/v2.1.239
/stop User-stop whatever agent is running for the chat's project — task, run-all, wildfire (the chain ends), or chat — via RunController.StopAgent; the reply names what was stopped, and the next plain-text message auto-starts a fresh chat
/wildfire / /wildfire off Bare /wildfire starts the autonomous loop ("on"/"start" are aliases; replaces a running agent like /run, while an already-running wildfire is reported rather than restarted); off is a user-stop so the chain doesn't continue. While watch is on, wildfire sessions relay a milestone feed instead of a raw stream: planning phases post "🔥 wildfire — generating new tasks…" / "…reviewing the plan and refining the backlog…" and close with "✚ generated task NNNN — title" (via SessionSource.TasksCreatedSince); execute-phase sessions post "🔥 wildfire — implementing task NNNN — title" and the usual outcome marker
/retry <n> / /cancel <n> Existing router verbs (retry re-queues done+failed; cancel stops the agent then marks failed, reason "cancelled via Telegram")
/screen One-shot plain-text snapshot of the live session (last 40 normalized lines, <pre> block)
/say <text> Inject <text> + \r into the running agent's PTY. The argument is re-carved verbatim from the raw message (internal whitespace preserved), not the whitespace-normalized dispatcher rest
(plain text) Any non-command message from a paired chat talks to a chat agent through the same injectSay path — Telegram is a conversation surface, so no /say prefix is needed. Three cases, resolved against the session watch mode streams (the chat's /use selection, or the auto-attached live session): a live chat-mode session → inject; a live non-chat session (task/run-all/wildfire/generate) → never typed into implicitly — the reply names what's running and offers options (/watch on, /screen, explicit /say, /cancel <n>); nothing running → the bridge auto-starts a chat agent on the chat's /use project (via RunController.StartChat, queued per project so concurrent messages don't double-start) and injects the queued messages once the session paints its first screen plus a settle delay. With watch off, deliveries ack with a /watch on hint
/watch on|off Toggle live conversation relay for this chat (persisted per chat; on by default for fresh pairings — a newly paired chat that stays silent while an agent runs reads as broken)
/mute on|off Pause/resume outbound event pushes to this chat (honored per send by the relay adapter; /unmute remains a hidden alias)
/pair <code> Redeem a pairing code
/agent [name] Show or switch the project's default_agent backend via the AgentSelector seam (backend registry + project YAML write). Exact/unique-prefix match on key or display name; uninstalled backends are refused. Applies to NEW sessions — /new restarts chat with it
/help The FULL grouped command list. setMyCommands (Telegram's autocomplete menu) registers the same canonical set; only the hidden aliases (/runall, /unmute) stay out of the menu

Watch mode (live conversation relay)

Three fidelity tiers, cheapest first:

  • Events (always on unless muted) — the outbound relay adapter (below) pushes TASK_FAILED / RUN_COMPLETE / WEEKLY_DIGEST.
  • On-demand (/status, /screen) — pull, no background cost.
  • Live (/watch on) — the bridge relays the agent's conversation — not raw PTY bytes — to the chat:
    • Primary source — transcript tail. A polling TranscriptTailer (1s interval; tolerates file-not-yet-created and truncation; final drain on session end) tails the agent-native JSONL transcript via the backend's existing LocateTranscript, emitting assistant text blocks and one-line tool-use summaries ("⚒ Edit internal/tui/model.go"). Claude Code is first-class; backends without a tailable transcript (or a tailer that errors mid-session) fall to tier 2 behind the same TailableTranscript interface. The Claude locator derives the transcript directory with Claude's own encoding (every non-alphanumeric character of the work dir → -, so n9o.xyzn9o-xyz); because session names are reused (":chat" every run), it only accepts transcripts touched at/after the session's start and picks the freshest; and since a tailer can still locate before the session's own file exists, it re-runs Locate after ~8 no-growth polls and switches to the fresh path, draining it from the top — it can never stay locked on a dead predecessor's file.
    • Fallback — screen deltas. Debounced (≥5s) plain-text vt10x snapshots (same normalization as the MCP get_agent_screen tool), sent as <pre> blocks only when the content actually changed.
    • Rate discipline (per-chat chatSender): 4096-char chunking at line boundaries (tags never split); coalescing to ≤1 send per 2.5s; consecutive assistant text grows the current message in place via editMessageText until 3500 rendered chars or an edit failure — and a real user turn in the transcript emits a TurnBreak that ends the grown message, so the answer to a new question always arrives as a new bubble after the user's own; a flood cap sends one "output is heavy" notice and throttles to 1 send/30s, recovering when the rolling 60s window drains.
    • Session markers: "▶ task NNNN — title" when the reconciler first sees a task-mode session; the end marker ("✔ merged" / "✔ done" / "⚠ merge failed" / "✖ failed: reason" / "■ session ended") is resolved from the task YAML via SessionSource.TaskOutcome — deliberately not the notify bus, so watching chats don't get duplicates of the relay adapter's pushes.
    • Lifecycle: a reconciler loop (2s) matches watching chats against live sessions; each watching chat gets its own relay + sender state; a finished relay stays registered until its session disappears so a session is never relayed twice. A watching chat with no /use selection auto-attaches to the most recently started live session anywhere (resolveWatchSession), so a fresh pairing streams activity without setup; /use pins it to one project.
    • Typing indicator: while a relayed session is recently active (an emission within the last 15s — seeded at session start, so boot counts), the relay re-sends the sendChatAction "typing…" status every 4s, so the user sees the agent working between coalesced sends. The plain-text paths also show it right after an injection and while an auto-started chat agent boots.
    • Watch defaults ON. Persisted as a negative-polarity watch_off flag on the paired chat, so the zero value (including chats paired before the field existed) means watching; /watch off opts out.

Invariants (enforced, not asserted)

The bridge observes sessions only through the telegram.SessionSource seam (plain screen lines, the current detected issue type, task YAML outcomes, the active-project list for auto-attach, and created-task summaries for the wildfire milestone feed — no Process, no PTY handle) and writes only through telegram.RunController. Mode starters on that seam replace the running agent (GUI/TUI semantics, v10.0.2); only the plain-text chat auto-start is refusal-gated, and /new refuses to displace a working agent:

  1. The bridge never calls Resize. Terminal size is global per project; an external bridge resizing would fight the attached TUI/GUI.
  2. injectSay is the only PTY write. The single sanctioned SendInput call site is Bridge.injectSay, reached from exactly two intents: the /say verb and plain-text chat forwarding. Both deliver the user's text verbatim plus exactly one \r — as two separate writes with a ~250ms beat between them, because a single text+\r chunk trips the agent CLI's paste detection and the Enter never submits.

watch_guard_test.go parses the package source and fails on any Resize reference, any SendInput reference outside the one allowlisted call site (and fails if that call-site count ≠ 1), and any import of the agent-manager package. A TUI/GUI attached simultaneously sees zero difference; per-subscriber cursors mean a slow Telegram consumer can only drop its own bytes.

Outbound events (relay adapter)

relay.TelegramAdapter (internal/daemon/relay/telegram.go) is a normal relay.Adapter registered in buildRelayAdapters() — it inherits the dispatcher's retry and circuit breaker for free. It formats TASK_FAILED / RUN_COMPLETE / WEEKLY_DIGEST for every paired, un-muted chat, gated on the config's per-event toggles; per-chat failures are aggregated. TestIntegration supports the telegram kind for the settings-UI test button.

Surfaces

Surface What it offers
gRPC IntegrationKind TELEGRAM, TelegramIntegration (masked, token_set), IntegrationsService.BeginTelegramPairing / GetTelegramPairingStatus / RevokeTelegramChat — see the gRPC API section
GUI Settings → Integrations → TelegramDetail.tsx: token entry + test, Pair with QR + deep link, paired-chats table with revoke and per-chat watch/mute toggles; settings-search entries
TUI Integrations overlay (Ctrl+i): Telegram row in the add-form kind cycle, pairing-code display, per-chat rows with revoke/watch/mute
CLI watchfire integrations add telegram, watchfire telegram pair, watchfire telegram status

Directory Structures

Global (~/.watchfire/)

~/.watchfire/
├── daemon.yaml         # Connection info (host, port, PID, started_at)
├── agents.yaml         # Running agents state (project, mode, task info)
├── projects.yaml       # Projects index (id, path, name, position)
├── settings.yaml       # Global settings (agent paths, defaults)
├── installation_id     # Stable UUID for analytics (decoupled from settings)
└── logs/               # Session logs
    └── <project_id>/
        ├── <task_number>-<session>-<timestamp>.log      # PTY scrollback (fallback)
        └── <task_number>-<session>-<timestamp>.jsonl     # Agent JSONL transcript (preferred)

Log filename examples:

  • 0001-1-2026-02-03T13-05-00.log — task 1, session 1 (PTY scrollback)
  • 0001-1-2026-02-03T13-05-00.jsonl — task 1, session 1 (agent JSONL transcript)
  • chat-1-2026-02-03T15-00-00.log — chat mode (no task)

Transcript discovery: On agent exit, the daemon calls the active backend's LocateTranscript to find the session's JSONL file (Claude Code: ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl matched by customTitle; Codex: <CODEX_HOME>/sessions/**/rollout-*.jsonl; opencode: collates per-message JSON files under <OPENCODE_DATA_DIR>/storage/message/**/*.json into a synthesized transcript.jsonl). The JSONL is copied to the logs directory. ReadLog prefers the .jsonl and dispatches to the backend's FormatTranscript for rendering; falls back to .log if no transcript exists.

Per-Project (<project>/.watchfire/)

<project>/
├── .gitignore              # Daemon appends .watchfire/ here on init
└── .watchfire/             # Gitignored
    ├── project.yaml        # Project definition
    ├── tasks/
    │   ├── 0001.yaml        # Task files (4-digit padded task_number)
    │   └── 0001.metrics.yaml # Per-task metrics sidecar (duration, tokens, cost, code-output)
    ├── memory.md            # Persistent project knowledge across agent sessions
    ├── secrets/
    │   └── instructions.md # Agent-readable instructions for external services/credentials
    └── worktrees/
        └── 0001/           # Git worktrees (named by task_number)

On project init, daemon:

  1. Creates .watchfire/ directory structure
  2. Appends .watchfire/ to project's .gitignore (creates if missing)
  3. Commits the .gitignore change (prevents worktree circular dependencies)
  4. Adds project to ~/.watchfire/projects.yaml

Data Structures

Screen Buffer (daemon → client)

{
  "timestamp": "2026-02-03T13:05:00.123Z",
  "rows": 24,
  "cols": 80,
  "cursor": {"row": 5, "col": 12, "visible": true},
  "cells": [
    [{"char": ">", "fg": 7, "bg": 0, "bold": true, "italic": false, "underline": false, "inverse": false, "blink": false, "strikethrough": false, "dim": false}, ...]
  ],
  "scrollback_available": 150
}
  • cells is a 2D array: cells[row][col]
  • fg and bg are ANSI color codes (0-15 for standard, 16-255 for extended)
  • Attributes: bold, italic, underline, inverse, blink, strikethrough, dim
  • Full buffer sent each time (no diff mechanism)
  • scrollback_available: lines in history, retrievable via separate RPC

Task File Format

Tasks are YAML files in <project>/.watchfire/tasks/.

Filename: <task_number_4digit>.yaml (e.g., 0001.yaml, 0012.yaml)

version: 1
task_id: "k7xm2nq9"                   # 8-char alphanumeric, internal only
task_number: 1                        # Sequential within project, user-facing
title: "Create HTML structure"
prompt: |
  Detailed instruction for the agent...
acceptance_criteria: |
  - index.html exists with valid HTML5 structure
  - 9 cells are rendered in a 3x3 layout
status: "draft"                       # draft | ready | done
success: true                         # Only when status=done
failure_reason: "..."                 # Only when success=false
position: 1                           # Display/work ordering
agent_sessions: 2                     # How many times agent worked on this
retrofit_archived: true               # v10 Torch, optional — soft-deleted by the definition-retrofit
                                      # archive; still counted in insights (Task.HiddenFromInsights())
created_at: "2026-02-03T13:05:00Z"
started_at: "2026-02-03T14:00:00Z"    # When agent first started
completed_at: "2026-02-03T15:30:00Z"  # When status changed to done
updated_at: "2026-02-03T22:16:32Z"
deleted_at: null                      # Soft delete timestamp

Task Status Flow:

draft → ready → done (success: true)
                  └→ done (success: false, failure_reason: "...")
Status Meaning
draft Created, not ready for agent
ready Agent can pick up (triggers auto-start if enabled) OR agent currently working
done Completed. Check success flag for outcome

User reference: watchfire task 1 (uses task_number, not task_id)

Project File Format

Project configuration in <project>/.watchfire/project.yaml:

version: 1
project_id: "53760635-1694-4cd1-8c30-5e705350f577"
name: "my-project"
status: "active"                      # active | archived (future)
color: "#22c55e"                      # Project color for GUI (hex)
default_agent: "claude-code"
sandbox: "auto"                         # "auto" | "seatbelt" | "landlock" | "bwrap" | "none"
auto_merge: true
auto_delete_branch: true
auto_start_tasks: true
definition: |
  # Project Name

  Description of what this project is...

  ## Technical Stack
  - React, Node.js, etc.

  ## Goals
  - Feature 1
  - Feature 2
created_at: "2026-02-03T13:02:52Z"
updated_at: "2026-02-03T14:30:00Z"
next_task_number: 6
last_retrofit_task_number: 4          # v10 Torch, optional — definition-retrofit watermark
                                      # (0/absent = never retrofitted). Advanced by the daemon's
                                      # handleRetrofitDone; agents never write it

Global Settings File Format

Global configuration in ~/.watchfire/settings.yaml.

Note: The installation_id (analytics UUID) is stored separately in ~/.watchfire/installation_id to prevent accidental loss during settings save/load cycles.

version: 1

agents:
  claude-code:
    path: null                    # null = lookup in PATH, or absolute path
  # future agents here

defaults:
  auto_merge: true
  auto_delete_branch: true
  auto_start_tasks: true
  default_sandbox: "auto"
  default_agent: "claude-code"

updates:
  check_on_startup: true
  check_frequency: "every_launch"  # every_launch | daily | weekly
  auto_download: false
  last_checked: "2026-02-03T13:00:00Z"

appearance:
  theme: "system"                  # system | light | dark

Projects Index File Format

Registry of all projects in ~/.watchfire/projects.yaml:

version: 1
projects:
  - project_id: "53760635-1694-4cd1-8c30-5e705350f577"
    name: "my-project"
    path: "/Users/me/code/my-project"
    position: 1
  - project_id: "a1b2c3d4-5678-90ab-cdef-1234567890ab"
    name: "another-project"
    path: "/Users/me/code/another"
    position: 2

Notes:

  • path updated if same project_id opened from new location
  • position used for GUI ordering
  • Self-healing: Every project-scoped CLI command calls EnsureProjectRegistered(), which reads the local .watchfire/project.yaml and re-registers the project in the global index if missing, updates the path if the project moved, and reactivates it if archived. This means deleting projects.yaml or moving a project directory is automatically repaired on first use.

Daemon Connection File Format

Written by daemon on startup (~/.watchfire/daemon.yaml):

version: 1
host: "localhost"
port: 52431
pid: 12345
started_at: "2026-02-03T13:02:52Z"

Port allocation: Daemon starts with port 0 → OS auto-allocates free port → daemon writes actual port here.

Daemon discovery logic:

Condition Meaning Action
File doesn't exist No daemon running Client starts daemon
File exists, PID alive Daemon running Client connects
File exists, PID dead Daemon crashed Client deletes stale file, starts daemon

PID check: kill -0 <pid> (returns 0 if process exists)

Agent State File Format

Written by daemon agent manager (~/.watchfire/agents.yaml):

version: 1
agents:
  - project_id: "abc12345"
    project_name: "my-project"
    project_path: "/home/user/my-project"
    mode: "task"              # chat | task | start-all | wildfire |
                              # generate-definition | generate-tasks | retrofit-definition
    task_number: 1            # Only when mode is task or wildfire
    task_title: "Add auth"    # Only when mode is task or wildfire
    issue_type: "rate_limited"  # auth_required | rate_limited | "" (optional)
    issue_message: "You've hit your limit"  # Original error message (optional)
  • Updated by daemon agent manager whenever agents start or stop
  • Read by watchfire daemon status to display active agents
  • Cleaned up on daemon shutdown
  • issue_type and issue_message reflect current blocking issue if any

Agent Detection & Error Handling

Issue Detection Architecture

The daemon detects issues (auth errors, rate limits) in real-time by scanning PTY output in the read loop. Detection happens in Process.detectIssues() which is called after each PTY read. Lines are buffered and scanned for known patterns.

PTY Read → detectIssues() → Pattern Match → AgentIssue → Broadcast to Subscribers

Issue Types

Type Constant Patterns
Auth Required auth_required "API Error: 401", "OAuth token expired", "Please run /login"
Rate Limited rate_limited "You've hit your limit", "rate limit", "too many requests", "API Error: 429"

Auth Issues

Aspect Behavior
Detection Daemon parses agent PTY output for auth-required patterns
Claude Code Detects "401 authentication_error", "OAuth token expired", "/login" prompts
Agent state Agent keeps running (user needs terminal access for /login)
Notification Clients receive issue via SubscribeAgentIssues stream
Recovery User runs /login in terminal, issue auto-clears on successful auth

Rate Limits

Aspect Behavior
Detection Daemon parses agent output for rate limit patterns
Reset time parsing Extracts reset time from messages like "resets 4am (Europe/Lisbon)"
Agent state Agent keeps running (allows user to wait or take action)
Notification Clients receive issue with reset_at and cooldown_until timestamps
User override Call ResumeAgent RPC to clear cooldown and retry

Restart Protection

If the same task is restarted multiple times without completing (e.g., due to rate limits, crashes, or auth errors that aren't detected as issues), the daemon stops chaining and transitions to chat mode.

Aspect Behavior
Trigger Same task restarted 3+ times consecutively without reaching status: done
Action Stop wildfire/start-all chaining, start chat-mode agent instead
Counter Per-project in Manager.taskRestarts memory map (reset on task progression)
Reset Counter resets when a different task is chained (successful progression) or agent is stopped by user
Logging Warning logged with task number and restart count when limit reached

gRPC RPCs for Issues

RPC Purpose
SubscribeAgentIssues Stream of AgentIssue messages when issues detected/cleared
ResumeAgent Clear current issue (e.g., after rate limit cooldown)
GetAgentStatus Includes current AgentIssue if any

AgentIssue Message

message AgentIssue {
  string issue_type = 1;                    // "auth_required" | "rate_limited" | ""
  google.protobuf.Timestamp detected_at = 2;
  string message = 3;                       // Original error message from agent
  optional google.protobuf.Timestamp reset_at = 4;      // When limit resets
  optional google.protobuf.Timestamp cooldown_until = 5; // When to auto-resume
}

Startup Checks

On watchfire init, TUI launch, or GUI launch:

Check Required Action if missing
Git Yes Block with error: "Git is required. Install git and try again."
Coding agent Yes Block until user configures agent path in settings

Update Check

On every client startup:

Aspect Behavior
When Every client startup (configurable: every launch / daily / weekly)
Source Check GitHub releases for new version
Scope Updates entire stack (daemon, CLI/TUI, GUI) together
If update available Prompt user to download and install
If daemon running After update, prompt user to restart daemon
Settings Check frequency, auto-download toggle

Daemon Lifecycle

Aspect Behavior
Startup watchfire daemon start, or started automatically by any client command if not running. daemon.yaml is written only after the gRPC port is accepting connections. CLI and GUI verify port readiness before proceeding
Persistence Stays running when clients close
Shutdown watchfire daemon stop, or system tray "Quit"
Rationale Agents can continue working in background; system tray provides visibility

Git Behavior

Rule Description
No push Watchfire and agents NEVER push to upstream
Local only All changes and merges happen locally
Root reflection After merge, changes appear in project root (main worktree)
Worktrees Tasks run in .watchfire/worktrees/<task_number>/, branch watchfire/<task_number>

gRPC API

Defined in proto/watchfire.proto.

Request Metadata

Every request includes origin for analytics:

message RequestMeta {
  string origin = 1;        // "cli" | "tui" | "gui" | "api"
  string client_id = 2;     // Unique client instance ID
  string version = 3;       // Client version
}

Services

Service Purpose
ProjectService Project CRUD
TaskService Task CRUD + bulk operations
AgentService Agent control, terminal streaming
BranchService Worktree/branch management
LogService Session logs
DaemonService Daemon status, shutdown
SettingsService Global settings
NotificationService Live notification stream (Subscribe) for client-side notification centers (v4 Pulse)
InsightsService Per-project/global insights, task diffs, report exports (v4 Inspect)
IntegrationsService Outbound relay + inbound endpoints + Telegram pairing (v4 Relay / v10 Torch)

ProjectService

RPC Request Response Notes
ListProjects Empty ProjectList All registered projects
GetProject ProjectId Project Single project details
CreateProject CreateProjectRequest Project Init new project
UpdateProject UpdateProjectRequest Project Update settings/definition
DeleteProject ProjectId Empty Unregister project

TaskService

RPC Request Response Notes
ListTasks ListTasksRequest TaskList Filter by status, include deleted
GetTask TaskId Task Single task
CreateTask CreateTaskRequest Task Add new task
UpdateTask UpdateTaskRequest Task Edit task, change status
DeleteTask TaskId Task Soft delete
RestoreTask TaskId Task Restore soft-deleted
EmptyTrash ProjectId Empty Permanent delete all
BulkUpdateStatus BulkUpdateStatusRequest TaskList Update status for list of tasks
BulkDelete BulkDeleteRequest TaskList Soft delete multiple
BulkRestore BulkRestoreRequest TaskList Restore multiple
ReorderTasks ReorderTasksRequest TaskList Update positions
CreateTasksBatch CreateTasksBatchRequest TaskList v10 Torch quick-add: server-side task.ParseQuickAdd splits a free-text bullet list into tasks (one per top-level bullet; AC:/Acceptance: lines become acceptance criteria; title derived from the first sentence, ≤70 runes). Creates through the same validated path as CreateTask — a bad item fails the whole batch atomically. status is draft|ready only
ArchiveRetrofitTasks ArchiveRetrofitRequest TaskList v10 Torch definition retrofit: soft-deletes the folded done tasks at or below the project's retrofit watermark. The request never names task numbers (server-authoritative window); dry_run: true returns candidates for the confirm prompt. Archived tasks carry retrofit_archived: true and keep counting in insights (Task.HiddenFromInsights())
message BulkUpdateStatusRequest {
  RequestMeta meta = 1;
  string project_id = 2;
  repeated int32 task_numbers = 3;  // List of task numbers
  string new_status = 4;            // "draft" | "ready" | "done"
}

AgentService

RPC Request Response Notes
StartAgent StartAgentRequest AgentStatus Start agent. Modes: chat | task | start-all | wildfire | generate-definition | generate-tasks | retrofit-definition (v10 Torch — folds done tasks above the project's retrofit watermark back into the definition; completion signalled via .watchfire/retrofit_done.yaml, which advances last_retrofit_task_number)
StopAgent ProjectId AgentStatus Stop running agent
GetAgentStatus ProjectId AgentStatus Is agent running? Which task? Includes current issue.
SubscribeScreen SubscribeScreenRequest stream ScreenBuffer Live terminal stream (parsed via vt10x, for GUI)
SubscribeRawOutput SubscribeRawOutputRequest stream RawOutputChunk Raw PTY bytes stream (for CLI)
GetScrollback ScrollbackRequest ScrollbackLines Historical terminal lines
SendInput SendInputRequest Empty Send keystrokes to agent
Resize ResizeRequest Empty Resize terminal
SubscribeAgentIssues SubscribeAgentIssuesRequest stream AgentIssue Real-time auth/rate limit issue notifications
ResumeAgent ProjectId AgentStatus Clear current issue (e.g., after rate limit)

BranchService

RPC Request Response Notes
ListBranches ProjectId BranchList All watchfire branches
GetBranch BranchId Branch Single branch
MergeBranch MergeBranchRequest Branch Merge to target
DeleteBranch BranchId Empty Delete single
PruneBranches ProjectId BranchList Clean orphaned
BulkMerge BulkBranchRequest BranchList Merge multiple
BulkDelete BulkBranchRequest Empty Delete multiple

LogService

RPC Request Response Notes
ListLogs ListLogsRequest LogList Logs for project/task
GetLog LogId Log Single log content
DeleteLog LogId Empty Delete single
BulkDelete BulkLogRequest Empty Delete multiple
DeleteAllForTask TaskId Empty Delete all logs for task
DeleteAllForProject ProjectId Empty Delete all logs for project
message Log {
  string log_id = 1;
  string project_id = 2;
  int32 task_number = 3;        // 0 if chat mode
  int32 session_number = 4;     // Which session (1, 2, 3...)
  string agent = 5;             // "claude-code"
  string started_at = 6;
  string ended_at = 7;
  string content = 8;           // Simplified text from terminal
  string status = 9;            // "completed" | "failed" | "interrupted"
}

DaemonService

RPC Request Response Notes
GetStatus Empty DaemonStatus Port, uptime, active agents, update info (update_available, update_version, update_url)
Shutdown Empty Empty Graceful shutdown
Ping Empty Empty Lightweight health check

SettingsService

RPC Request Response Notes
GetSettings Empty Settings Global settings
UpdateSettings Settings Settings Update global settings
GetMcpClientStatus Empty McpClientStatusList v9.0 Firestorm — per-harness MCP setup state (detected? configured? config path) + Custom snippet
InstallMcpClient InstallMcpClientRequest McpClientStatus v9.0 Firestorm — register watchfire mcp serve with the named harness via shared internal/mcpserver/install writers

IntegrationsService

Covers outbound relay endpoints (webhook/Slack/Discord/GitHub), inbound endpoint config (v4 Echo), OAuth flows (v5.x), and — new in v10 Torch — the Telegram pairing surface. IntegrationKind enum: WEBHOOK | SLACK | DISCORD | GITHUB | TELEGRAM (TELEGRAM = 4, appended in v10). Secrets are write-only over the wire: SaveIntegration accepts them, ListIntegrations returns only *_set booleans (the Telegram bot token is served as token_set).

RPC Request Response Notes
ListIntegrations ListIntegrationsRequest IntegrationsConfig Secrets scrubbed to *_set booleans
SaveIntegration SaveIntegrationRequest IntegrationsConfig Persists config + secret → keyring; restarts the affected bridge (Echo / Discord / Telegram)
DeleteIntegration DeleteIntegrationRequest IntegrationsConfig Removes config + secret
TestIntegration TestIntegrationRequest TestIntegrationResponse Fires a synthetic notification through the adapter (supports telegram)
GetInboundStatus / SaveInboundConfig InboundStatus v4 Echo inbound HTTP server config
BeginOAuth / GetOAuthStatus / CancelOAuth / PostOAuthHello v5.x Slack/Discord OAuth bot-token flows
BeginTelegramPairing BeginTelegramPairingRequest BeginTelegramPairingResponse v10 Torch — mints a one-time code (8 chars, 10-min TTL, single active code) + https://t.me/<bot>?start=<code> deep link. Requires the bridge to be running (Telegram enabled + token stored)
GetTelegramPairingStatus GetTelegramPairingStatusRequest TelegramPairingStatus Poll for NONE | PENDING | PAIRED | EXPIRED; carries the paired chat on success
RevokeTelegramChat RevokeTelegramChatRequest IntegrationsConfig Removes a chat from the allowlist; the poller drops it immediately

Event Streaming (per-project)

RPC Request Response Notes
Subscribe SubscribeRequest stream Event Real-time events
message SubscribeRequest {
  RequestMeta meta = 1;
  string project_id = 2;
}

message Event {
  string event_type = 1;    // "task_created" | "task_updated" | "agent_started" | etc.
  string timestamp = 2;
  oneof payload {
    Task task = 3;
    AgentStatus agent = 4;
    Branch branch = 5;
  }
}

Flows

Daemon Flows

Shutdown Flow

1. User triggers shutdown (system tray "Quit" OR CLI command)
2. Daemon notifies all connected clients: "shutting down"
3. Clients display message and close gracefully
4. Daemon stops all running agents (sends SIGTERM)
5. Daemon cleans up (releases port, deletes daemon.yaml)
6. Daemon exits

System Tray

Item Action
Header "Watchfire Daemon"
Port "Running on port: {port}"
Separator
Active Agents Submenu per agent (see below)
No active agents "No active agents" (greyed)
Separator
Open GUI Launches GUI
Quit Shutdown daemon

Active Agent Submenu:

● project-name — Task #0001: Title
  ├─ Open in GUI
  └─ Stop Agent

Agent display format:

Mode Display
Chat "● project-name — Chat"
Task "● project-name — Task #0001: Title"
Wildfire "🔥 project-name — Wildfire (Task #0003)"

Tooltip: "Watchfire — {n} projects, {m} active"


CLI/TUI Flows

C1: Startup Flow

Client-side:

1. Check for updates (client checks GitHub)
   └─ If available → prompt, download, install

2. Check if daemon running
   └─ If not → start daemon in background
   └─ If stale → clean up, start daemon

3. Connect to daemon via gRPC

4. Call daemon's health/startup check
   └─ Daemon checks Git → returns error if missing
   └─ Daemon checks agent paths → returns error if none configured

5. If errors → display to user, exit (or prompt to configure)

After successful connection:

6. If no project (.watchfire/ missing):
   └─ CLI: exit with error
   └─ TUI: interactive init via daemon RPCs

7. TUI → subscribe, render
   CLI → execute RPC, display, exit

C2: watchfire init Flow

Client-side:

1. Run startup checks (C1 steps 1-5)
2. Check if already a project (.watchfire/ exists)
   └─ If yes → ERROR: "Already a Watchfire project." → exit

Interactive prompts (client displays, daemon executes):

3. Prompt: "Project name:" (default: folder name)
4. Prompt: "Project definition (optional):"
5. Prompt: "Project settings:"
   └─ Auto-merge after task completion? (y/n) [default: y]
   └─ Auto-delete worktrees after merge? (y/n) [default: y]
   └─ Auto-start agent when task set to ready? (y/n) [default: y]
   └─ Default branch? [default: main]

6. Client calls daemon CreateProject RPC with all inputs

Daemon-side (CreateProject RPC):

7. Check if git repo → if not, run `git init`
8. Create .watchfire/ directory structure
9. Add .watchfire/ to .gitignore (create if missing)
10. Commit .gitignore change
11. Register project in ~/.watchfire/projects.yaml
12. Return success to client

C3: Task Management Flows

Add Task (watchfire task add):

1. Prompt: Title, Prompt, Acceptance criteria, Status
2. Client calls daemon CreateTask RPC
3. Daemon creates task file, returns task
4. If status=ready AND auto_start → trigger agent

Edit Task (watchfire task <number>):

1. Call GetTask RPC
2. Display interactive editor
3. Call UpdateTask RPC
4. If status changed to ready AND auto_start → trigger agent

Delete/Restore:

DeleteTask → sets deleted_at timestamp
RestoreTask → clears deleted_at

Status Transitions (TUI):

Key Action
r Move to Ready (status change only)
t Move to Draft
d Mark Done (success=true)
s Start Agent (work on ready tasks)

C4: watchfire agent start Flow

Client-side:

1. Call daemon StartAgent RPC (project_id, optional task_number)
2. If agent already running → attach to existing stream
3. Subscribe to SubscribeScreen
4. Terminal shows agent output
5. User can type (SendInput RPC)
6. Ctrl+C → detach (task/wildfire) or stop (chat only)

Daemon-side (if not running):

1. Check for ready tasks (or use specified task_number)
2. If task → create worktree, set status=ready
3. Spawn agent in sandbox + PTY
4. Stream to clients
5. On task done → check for more ready → continue or chat mode

Agent behavior:

1. Pick first ready task (by position, then task_number)
2. Work on it
3. When done → check for more ready tasks
   └─ If yes → pick next
   └─ If no → switch to chat mode
4. Agent keeps running until explicitly stopped

C5: watchfire agent wildfire Flow

Wildfire three-phase loop (daemon-managed):

1. Daemon checks for ready tasks
   └─ If found → Execute phase: spawn agent in worktree (same as task mode)
   └─ Agent completes → daemon loops back to step 1

2. Daemon checks for draft tasks
   └─ If found → Refine phase: spawn agent at project root
   └─ Agent analyzes codebase, improves task, sets status: ready
   └─ Agent completes → daemon loops back to step 1

3. If previous phase was Generate → wildfire complete
   └─ Transition to chat mode

4. Generate phase: spawn agent at project root
   └─ Agent analyzes: definition, codebase, existing tasks
   └─ Agent decides: create new tasks OR create nothing ("best version")
   └─ Agent completes → daemon loops back to step 1
   └─ If no new tasks found → step 3 triggers chat transition

Each phase is a separate agent process. The daemon manages all transitions.

Restart protection: If the same task is restarted 3 times without completing, wildfire stops and transitions to chat mode. See "Restart Protection" in the Agent Detection section.

Ctrl+C in wildfire: Detaches only (agent continues in background)

Stop wildfire: System tray "Stop Agent" or GUI

C6: TUI Interaction

Startup: See "TUI Startup Flow" subsection for the full step-by-step sequence (Cobra → Bubbletea → daemon connect → load data → subscribe/auto-start → render).

Layout:

┌─────────────────────────────────────────────────────────────┐
│ ● project-name  Tasks | Definition | Settings  Chat | Logs │
├────────────────────────────────┬────────────────────────────┤
│  LEFT PANEL                    │  RIGHT PANEL               │
│  (Task list / Definition /     │  (Agent terminal +         │
│   Settings)                    │   issue banners)           │
└────────────────────────────────┴────────────────────────────┘

On TUI open:

  • If agent running → attach, show current state + stream via SubscribeRawOutput
  • If no agent → auto-start chat session
  • Subscribe to SubscribeAgentIssues for auth/rate limit banners

Auto-reconnect: If daemon connection drops, status bar shows "⚠ Disconnected" and TUI retries every 3s. On reconnect, reloads project/tasks and resubscribes to streams.

Global shortcuts:

Key Action
Tab Switch panels
Ctrl+q Quit TUI
Ctrl+h Help overlay

Left panel — Tasks:

Key Action
j/k / ↓/↑ Move selection
a Add task
e / Enter Edit task
r Move to Ready
t Move to Draft
d Mark Done
x Delete
s Start agent
1/2/3 Switch tabs

Right panel — Chat:

  • All input goes to agent (except global shortcuts)
  • Issue banners (auth, rate limit) appear above terminal viewport when active
  • Ctrl+C detaches (task/wildfire) or stops (chat)
  • Mouse scroll for scrollback

Mouse: Click, scroll, drag divider to resize

C7: Update Flow

1. Check GitHub releases on startup
2. If update available → prompt user
3. Download all binaries (daemon, CLI/TUI, GUI)
4. If daemon running → prompt to restart
5. Replace binaries, restart daemon if requested

GUI Flows

G1: Startup Flow

1. Launch Watchfire.app
2. Check for updates → show banner if available
3. Check/start daemon (poll daemon.yaml + verify port readiness)
4. Connect via gRPC-Web
5. Daemon health check (Git, agent)
6. Load projects list
7. Render Dashboard

Disconnect behavior: Views stay mounted during brief disconnects. A translucent overlay with reconnecting spinner appears on top. On reconnect, views re-fetch their data (e.g. settings). Only a full daemon shutdown replaces views with an exit message.

G2: Add Project Wizard

Entry: Dashboard "Add Project" button or sidebar

Step 1 — Project Info:

1. Folder picker opens ("New Folder" option available)
2. User selects/creates folder
3. If existing project (.watchfire/ exists):
   └─ Import project, register, navigate to Project View
   └─ Skip wizard
4. Display: Project name, path, git status, branch
5. Click "Next"

Step 2 — Git Configuration:

1. Target branch (default: main)
2. Automation toggles:
   └─ Auto-merge on completion (default: ON)
   └─ Delete branch after merge (default: ON)
   └─ Auto-start tasks (default: ON)
3. Click "Next"

Step 3 — Project Definition:

1. Markdown editor (optional)
2. Click "Create Project" or "Skip"

On create:

1. Call daemon CreateProject RPC
2. Daemon: init git, create .watchfire/, commit .gitignore, register
3. Success modal
4. Navigate to Project View

G3: Dashboard Interactions

Project card displays:

  • Status dot (green=idle, orange=running, red=error)
  • Project name
  • Task counts (Todo, In Dev, Done)
  • Active task (if agent running)

Interactions:

Action Result
Click card Open Project View
Click active task Open Project View, focus on task
Drag card Reorder projects

Empty state:

"No projects yet"
"Watchfire helps you orchestrate autonomous coding agent
sessions across your projects based on specs."
[Add Your First Project]

G4: Project View — Task Management

Task list features:

  • Grouped by status: Done, In Development, Todo
  • Search box
  • Filters: Failed, With Issue
  • Drag to reorder

Task interactions:

Action Result
Click task Select, show in right panel
Double-click Open editor modal
"→ Dev" button Move to Ready
"Done" button Mark success=true
"Todo" button Move to Draft
🗑 button Soft delete
Drag Reorder position

Add Task modal:

  • Title (required)
  • Prompt (markdown)
  • Acceptance criteria (markdown)
  • Status: Draft / Ready

Group actions:

  • 🗑 on group header → delete all in group
  • "▶ Start" on In Development → start agent

G5: Project View — Agent Interaction

Right panel tabs:

Tab Content
Chat Live agent terminal, user input
Branches Worktrees/branches for project
Logs Past session logs

Right panel header:

  • Collapse/expand button
  • Agent mode badge: "Chat" / "Task #0001: Title" / "🔥 Wildfire"

Chat Tab

On project open:

  • If agent running → attach, show current state + stream
  • If no agent → auto-start chat session

Display:

  • Live terminal stream from daemon (gRPC-Web SubscribeScreen)
  • User types → SendInput RPC
  • Mode badge in header

Agent controls:

Element Action
Stop button Stops agent (StopAgent RPC)

Starting task from GUI:

Entry point Behavior
"▶ Start" on task group Agent picks ready tasks, works through queue
Right-click task → "Start" Agent works on specific task

Note: If agent in chat mode, starting a task transitions it to task mode. If already working on tasks, new task gets queued (set to ready).


Branches Tab

Branch list displays:

watchfire/0001 — Task #0001: Title
├─ Status: merged / unmerged / orphaned
├─ Created: 2026-02-03
└─ Actions: [Merge] [Delete]

watchfire/0003 — Task #0003: Another task
├─ Status: unmerged (agent working)
└─ Actions: — (disabled while agent working)

Branch statuses:

Status Meaning
merged Branch merged to target, worktree cleaned
unmerged Branch exists, not yet merged
orphaned Worktree exists but no task reference

Branch actions:

Action When available Behavior
Merge Unmerged, agent not working MergeBranch RPC
Delete Not currently in use DeleteBranch RPC
Prune All Orphans exist PruneBranches RPC

Bulk actions:

  • Checkbox select multiple branches
  • "Merge Selected" / "Delete Selected" buttons

Logs Tab

Log list displays:

Task #0001 — Session 2 — 2026-02-03 14:30
├─ Duration: 25m
├─ Status: completed
└─ [View]

Task #0001 — Session 1 — 2026-02-03 13:05
├─ Duration: 15m
├─ Status: completed
└─ [View]

Chat — Session 1 — 2026-02-03 12:00
├─ Duration: 10m
├─ Status: interrupted
└─ [View]

Filter/sort:

  • Filter by task (dropdown)
  • Sort by date (default: newest first)

View log:

  • Click "View" → opens log viewer modal
  • Simplified text rendering (no terminal colors)
  • Scroll, search within log

Log actions:

Action Behavior
Delete single DeleteLog RPC
Delete all for task DeleteAllForTask RPC
Delete all DeleteAllForProject RPC (confirmation required)

G6: Settings

Entry: Sidebar "Settings" link

Sections:

Section Content
Defaults Default values for new projects
Appearance Theme selection
Claude CLI Agent path configuration
Updates Update preferences

Defaults Section:

  • Default branch (text input, default: "main")
  • Auto-merge on completion (toggle, default: ON)
  • Delete branch after merge (toggle, default: ON)
  • Auto-start tasks (toggle, default: ON)

Appearance Section:

  • Theme: System / Light / Dark (radio or dropdown)

Claude CLI Section:

  • Status: "Detected at /usr/local/bin/claude" or "Not found"
  • Custom path input (optional override)
  • "Test" button → validates path
  • Link to install instructions if not found

Updates Section:

  • Check frequency: Every launch / Daily / Weekly (dropdown)
  • Auto-download updates (toggle, default: OFF)
  • "Check Now" button
  • Last checked: timestamp

Save behavior: Auto-save on change (UpdateSettings RPC), show brief "Saved" toast

G7: Update Flow

Update check triggers:

  • App startup (based on frequency setting)
  • Manual "Check Now" in Settings

If update available:

1. Banner appears at top of window:
   "Update available: v1.2.0 → v1.3.0  [Download] [Dismiss]"

2. User clicks "Download":
   └─ Progress indicator
   └─ Downloads all components (daemon, CLI/TUI, GUI)

3. Download complete:
   └─ If daemon running with active agents:
      "Restart required. Active agents will be stopped. [Restart Now] [Later]"
   └─ If daemon idle or not running:
      "Ready to install. [Restart Now] [Later]"

4. User clicks "Restart Now":
   └─ Stop daemon (if running)
   └─ Replace binaries
   └─ Restart app
   └─ Start daemon

"Later" behavior:

  • Banner persists: "Update downloaded. [Restart to Install]"
  • Update applies on next manual restart

Auto-download (if enabled):

  • Downloads silently in background
  • Shows "Update ready" banner when complete

Telegram Flows (v10 Torch)

T1: Pairing Flow

1. User pastes `@BotFather` token into any surface (GUI / TUI / CLI)
   └─ SaveIntegration → token to keyring → daemon validates via getMe
   └─ startTelegramBridge(): long-poll goroutine begins dialing api.telegram.org
2. User triggers Pair (GUI button / TUI action / `watchfire telegram pair`)
   └─ BeginTelegramPairing RPC → one-time code (8 chars, crypto/rand, 10-min TTL,
      single active code) + deep link https://t.me/<bot>?start=<code>
   └─ GUI renders the link as a QR; CLI prints code + link and polls
      GetTelegramPairingStatus every 2s
3. User opens the link on their phone (Telegram sends "/start <code>" for them)
   or sends /pair <code> manually
4. Bridge poller receives the message
   └─ Code matches → chat appended to paired_chats (config.SaveIntegrations),
      code invalidated, welcome + command list sent
   └─ Code wrong/expired → pairing instructions only; no project data
5. Surfaces flip to "paired" via GetTelegramPairingStatus (PENDING → PAIRED)
6. Revoke (any surface) → RevokeTelegramChat RPC → chat removed from
   paired_chats → poller drops the chat immediately

T2: Watch-Mode Relay Flow

1. Paired chat sends /watch on
   └─ TelegramPairedChat.Watch persisted via config.SaveIntegrations
2. Reconciler loop (2s) matches watching chats (Watch && DefaultProjectID)
   against live sessions from SessionSource.ActiveSession
   └─ Match → dedicated chatRelay + chatSender created for this chat
   └─ "▶ task NNNN — title" marker sent for task-mode sessions
3. Relay picks the fidelity tier:
   └─ Backend has a tailable transcript (Claude Code) →
      TranscriptTailer polls the JSONL every 1s, emits assistant text +
      one-line tool-use summaries
   └─ Otherwise (or tailer error mid-session) → debounced (≥5s) plain-text
      screen snapshots, sent only on change
4. chatSender applies the rate discipline per chat:
   └─ Coalesce to ≤1 send per 2.5s; chunk at 4096 chars on line boundaries
   └─ Consecutive assistant text grows the current message via editMessageText
      (up to 3500 rendered chars, then a fresh message)
   └─ Flood → one "output is heavy" notice, throttle to 1 send/30s, recover
      when the rolling 60s window drains
5. Session ends → tailer drains → outcome resolved from task YAML via
   SessionSource.TaskOutcome (polled briefly — the merge runs after process exit)
   └─ End marker: "✔ merged" / "✔ done" / "⚠ merge failed" / "✖ failed: reason"
      / "■ session ended"
   └─ Relay stays registered until the session disappears (never relayed twice)
6. Throughout: bridge reads only (transcript file + screen snapshots via the
   SessionSource seam); the sole PTY write path is injectSay (/say and
   plain-text chat forwarding)

Development

Repository Structure

Monorepo containing all components:

watchfire/
├── cmd/
│   ├── watchfired/       # Daemon entry point
│   └── watchfire/        # CLI/TUI entry point
├── internal/
│   ├── buildinfo/        # Build-time version vars (injected via ldflags)
│   ├── updater/          # GitHub Releases update checker + binary replacer
│   ├── cli/              # CLI commands
│   ├── config/           # YAML loading/saving, path helpers
│   ├── daemon/           # Daemon packages
│   │   ├── agent/        # Manager, Process (PTY+vt10x), worktree, sandbox, prompts
│   │   ├── server/       # gRPC server + per-service files:
│   │   │   ├── server.go           # Server setup, listener, registration
│   │   │   ├── helpers.go          # Shared helpers (getProjectPath)
│   │   │   ├── project_service.go  # ProjectService RPCs
│   │   │   ├── task_service.go     # TaskService RPCs
│   │   │   ├── agent_service.go    # AgentService RPCs + mode helpers
│   │   │   ├── branch_service.go   # BranchService RPCs
│   │   │   ├── log_service.go      # LogService RPCs
│   │   │   ├── settings_service.go # SettingsService RPCs
│   │   │   ├── daemon_service.go   # DaemonService RPCs
│   │   │   ├── command_context.go  # Production echo.CommandContext (Slack/Discord/Telegram)
│   │   │   ├── integrations_telegram.go # Telegram pairing RPCs (v10 Torch)
│   │   │   ├── telegram_sessions.go     # SessionSource seam impl (v10 Torch)
│   │   │   ├── telegram_runcontrol.go   # RunController seam impl (v10 Torch)
│   │   │   └── converters.go       # Model-to-proto converters
│   │   ├── tray/         # System tray integration
│   │   ├── notify/       # Desktop notifications + event bus (platform-abstracted)
│   │   ├── relay/        # Outbound delivery adapters (webhook, Slack, Discord, GitHub PR, telegram.go)
│   │   ├── echo/         # Inbound HTTP server + transport-agnostic command router
│   │   ├── telegram/     # Telegram bridge: long-poll loop, pairing, render, watch mode (v10 Torch)
│   │   ├── telegrambot/  # Thin Telegram Bot API client, stdlib HTTP only (v10 Torch)
│   │   ├── watcher/      # fsnotify watcher with debouncing
│   │   ├── task/         # Task manager (+ quickadd.go batch parser, retrofit.go fold window — v10 Torch)
│   │   └── project/      # Project manager
│   ├── models/           # Data structures
│   └── tui/              # TUI (Bubbletea Elm architecture):
│       ├── model.go      # Model struct, Init, Update dispatch, View
│       ├── keyhandler.go # Key event routing
│       ├── mousehandler.go # Mouse event handling
│       ├── msghandler.go # Message processing (gRPC responses, etc.)
│       ├── actions.go    # Task action methods
│       └── ...           # Components: tasklist, terminal, panels, styles, etc.
├── proto/                # Protobuf definitions
├── gui/                  # Electron app
├── assets/               # Icons, images
├── scripts/              # Build & signing scripts
├── .github/workflows/    # CI/CD (ci.yml, release.yml)
├── version.json          # Version tracking (single source of truth)
├── CHANGELOG.md          # Release changelog
├── Makefile              # Dev commands
└── ...

Separate repo: Website and documentation

Dev Commands

make dev-daemon       # Run daemon in foreground with hot reload
make dev-tui          # Build and run TUI
make dev-gui          # Run Electron in dev mode
make build            # Build all Go binaries (native arch)
make build-universal  # Build universal (arm64+amd64) Go binaries via lipo
make sync-version     # Sync version.json → gui/package.json
make package-gui      # Build universal binaries + package Electron DMG
make test             # Run all tests
make lint             # Run all linters

Ad-hoc: go run ./cmd/watchfire or go run ./cmd/watchfired

Linting & Formatting

Component Tools
Go gofmt, golangci-lint
Electron prettier, eslint
Proto buf lint

CI/CD (GitHub Actions)

On Pull Request (ci.yml)

Job Runner Description
go-lint macos-latest golangci-lint
go-test macos-latest make test
go-build-arm64 macos-14 Build daemon + CLI for arm64 (native, avoids CGO cross-compile)
go-build-amd64 macos-latest Build daemon + CLI for amd64 (native, avoids CGO cross-compile)
gui-lint-build macos-latest npm ci + npm test + npm run build (macOS) — renderer unit tests run in CI since v10 Torch
gui-build-linux ubuntu-latest npm ci + npm test + npm run build (Linux)
go-build-linux ubuntu-latest Build CLI for linux-amd64
go-build-windows ubuntu-latest Build CLI for windows-amd64
proto-lint ubuntu-latest buf lint

On Push to Main (release.yml)

  1. version job — read version.json, output version + codename for other jobs
  2. build-go job — matrix {macos-14/arm64, macos-latest/amd64}. Build with ldflags, codesign, notarize, upload arch-specific artifacts
  3. build-go-linux job — matrix {amd64, arm64} on ubuntu-latest. Build CLI + daemon for Linux
  4. build-go-windows job — matrix {amd64, arm64} on ubuntu-latest. Build CLI + daemon for Windows
  5. build-gui job — download both macOS arch artifacts, lipo universal binaries, sync version, npm ci && npm run package with signing env vars. Uploads DMG + zip + latest-mac.yml
  6. build-gui-linux job — download Linux amd64 binaries, build AppImage + deb on ubuntu-latest. Uploads AppImage + deb + latest-linux.yml
  7. build-gui-windows job — download Windows amd64 binaries, build NSIS installer on windows-latest. Uploads installer exe + latest.yml
  8. release job — create/update draft GitHub Release with all artifacts + changelog notes

Build artifacts:

  • Watchfire.dmg — macOS Universal installer (GUI + CLI + daemon)
  • Watchfire-x.y.z.AppImage — Linux x64 AppImage (GUI + CLI + daemon)
  • watchfire_x.y.z_amd64.deb — Linux x64 Debian package (GUI + CLI + daemon)
  • Watchfire-Setup-x.y.z.exe — Windows x64 NSIS installer (GUI + CLI + daemon)
  • watchfire-darwin-arm64 / watchfire-darwin-amd64 — CLI only (macOS)
  • watchfired-darwin-arm64 / watchfired-darwin-amd64 — Daemon only (macOS)
  • watchfire-linux-amd64 / watchfire-linux-arm64 — CLI only (Linux)
  • watchfired-linux-amd64 / watchfired-linux-arm64 — Daemon only (Linux)
  • watchfire-windows-amd64.exe / watchfire-windows-arm64.exe — CLI only (Windows)
  • watchfired-windows-amd64.exe / watchfired-windows-arm64.exe — Daemon only (Windows)

Required GitHub Secrets

Secret Purpose
CSC_LINK Base64 .p12 certificate (Developer ID Application)
CSC_KEY_PASSWORD .p12 password
APPLE_ID Apple ID email
APPLE_APP_SPECIFIC_PASSWORD App-specific password
APPLE_TEAM_ID Developer Team ID
CODESIGN_IDENTITY Signing identity string

Version Tracking

version.json is the single source of truth for version info. Go binaries receive the version via -ldflags at build time (internal/buildinfo). gui/package.json version is synced via make sync-version.

{
  "version": "0.1.0",
  "codename": "Ember"
}

CHANGELOG.md:

# Changelog

## [0.1.0] Ember — 2026-XX-XX

### Added
- Initial release
- Daemon with PTY management
- CLI/TUI client
- Electron GUI
- ...

Publishing Release

Manual step: Edit draft release → Publish


Packaging & Installation

macOS DMG (Watchfire.dmg)

Aspect Behavior
Binary bundling watchfire and watchfired bundled in Watchfire.app/Contents/Resources/ via electron-builder extraResources
Universal binaries Fat binaries created with lipo from separate arm64 + amd64 builds
First-launch install App checks if /usr/local/bin/watchfire[d] exists and matches app version. If missing/outdated, shows install dialog.
Admin privileges Tries direct copy; falls back to osascript with administrator privileges (macOS password prompt)
Homebrew detection Checks if installed binary is a symlink from Homebrew (/opt/homebrew/, /usr/local/Cellar/). If so, skips and informs user.
Version sync version.json is single source of truth. gui/package.json version synced via make sync-version. Go binaries get version via -ldflags.

Code Signing & Notarization

Aspect Behavior
Certificate Developer ID Application (.p12), stored as GitHub Secret CSC_LINK
Hardened runtime Required for notarization. Entitlements in gui/entitlements.mac.plist
GUI signing electron-builder handles signing + notarization via env vars
Standalone binaries Signed with codesign --sign --options runtime --timestamp, notarized via xcrun notarytool

Homebrew

brew tap watchfire/tap
brew install watchfire   # Installs CLI + daemon

Tap repo: watchfire/homebrew-tap (separate repo, auto-updated on release)

Linux (AppImage + deb)

Aspect Behavior
Formats AppImage (portable, no install) and .deb (Debian/Ubuntu)
Architecture x64 only for GUI; CLI binaries available for amd64 + arm64
Binary bundling watchfire and watchfired bundled in AppImage/deb via electron-builder extraResources
First-launch install App checks if ~/.local/bin/watchfire[d] exists and matches version. If missing/outdated, shows install dialog. Falls back to pkexec for admin privileges.
Sandbox Landlock (kernel 5.13+) or bubblewrap fallback
System tray Headless (no CGO in cross-compiled builds)
Notifications Linux desktop notifications via beeep
Auto-update electron-updater checks GitHub Releases for latest-linux.yml

Windows (NSIS Installer)

Aspect Behavior
Format NSIS installer (Watchfire-Setup-x.y.z.exe)
Architecture x64 only for GUI; CLI binaries available for amd64 + arm64
Binary bundling watchfire.exe and watchfired.exe bundled via electron-builder extraResources
First-launch install App checks if %LOCALAPPDATA%\Watchfire\watchfire.exe exists and matches version. Falls back to PowerShell elevation.
Sandbox Not available — agent runs unsandboxed
System tray Headless (no CGO in cross-compiled builds)
Notifications Windows toast notifications via beeep
Auto-update electron-updater checks GitHub Releases for latest.yml

Install Scripts

Platform Command
macOS / Linux curl -fsSL https://raw.githubusercontent.com/watchfire-io/watchfire/main/scripts/install.sh | bash
Windows irm https://raw.githubusercontent.com/watchfire-io/watchfire/main/scripts/install.ps1 | iex

Both scripts auto-detect OS/arch, download the latest binaries from GitHub Releases, and install to a standard location (/usr/local/bin or ~/.local/bin on Unix, %LOCALAPPDATA%\Watchfire on Windows).

Distribution

Channel What
GitHub Releases DMG (macOS), AppImage + deb (Linux), NSIS installer (Windows), standalone binaries (macOS/Linux/Windows amd64+arm64), auto-update YAMLs (latest-mac.yml, latest-linux.yml, latest.yml)
Homebrew CLI + daemon (macOS/Linux)
Install script install.sh (macOS/Linux), install.ps1 (Windows)

Auto-Update

Component Mechanism Details
GUI (macOS) electron-updater Checks GitHub Releases for latest-mac.yml. Downloads .zip update. On restart, new app's first-launch logic updates CLI/daemon binaries.
GUI (Linux) electron-updater Checks GitHub Releases for latest-linux.yml. Downloads .AppImage update. On restart, updates CLI/daemon binaries in ~/.local/bin.
GUI (Windows) electron-updater Checks GitHub Releases for latest.yml. Downloads NSIS installer update. On restart, updates CLI/daemon binaries in %LOCALAPPDATA%\Watchfire.
CLI watchfire update Queries GitHub Releases API. Downloads arch-specific binaries (watchfire-{os}-{arch}[.exe]). Stops daemon (SIGTERM on Unix, Kill on Windows). Atomically replaces self + daemon binary with rollback. Restarts daemon. Works on macOS, Linux, and Windows.
Daemon Startup check Checks GitHub on startup (based on settings frequency: every_launch, daily, weekly). Stores result in memory. Exposes via DaemonStatus.update_available/update_version/update_url fields for clients to display.

Constraints

When implementing, follow these rules:

  1. No premature abstraction. Write simple, obvious code first.
  2. No alternative approaches. Use the tech stack specified above. Do not suggest alternatives.
  3. Test manually. Describe how to test before writing code.
  4. Ask if unclear. If the architecture doesn't specify something, ask—don't guess.