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.
| 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 |
- 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/grpcwith 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
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.
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
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.
- Implement
Backendininternal/daemon/agent/backend/<name>.go(name, display name, executable resolution, command builder, system-prompt installer, transcript locate/format). - Register it in that file's
init()viabackend.Register(&<Name>Backend{}). - Contribute sandbox extras from
SandboxExtras()— writable paths (config dir, auth dir), cache patterns, env vars to strip (e.g. nested-session detection variables). - 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.
| 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.yaml → defaults.default_sandbox. Per-project: project.yaml → sandbox. 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 |
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) |
| 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 ...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).
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) |
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"
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.
| 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 |
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).
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.
| Mode | Entry | Description |
|---|---|---|
| TUI | watchfire (no args) |
Interactive split-view: task list + agent terminal |
| CLI | watchfire <command> |
Scriptable commands, returns to shell |
| 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 |
| 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 |
| Command | Alias | Description |
|---|---|---|
watchfire define |
def |
Edit project definition in $EDITOR |
watchfire configure |
config |
Configure project settings (interactive) |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
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
┌──────────────────────────────────────────────────────────────────────┐
│ ● 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 |
| 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.
- 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
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.
| 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.
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.
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 |
| 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.
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.
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 |
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 |
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.
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 |
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).
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 |
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 |
| 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 |
| 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.
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.
| 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 |
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
$EDITORin 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)
When multiple tasks are in ready status, agent picks next by:
- Sort by
position(ascending) - If equal, sort by
task_number(ascending) - 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.
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 |
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.
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 (carriesprojectId).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().
| 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).
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 UUIDpty-createresolves the originating window viaBrowserWindow.fromWebContents(ev.sender); output (pty-output/pty-exit) is sent back only to that window'swebContents.- On window close,
destroyForWindow(windowId)kills only that window's sessions; other windows' terminals persist.window-all-closed/before-quittear down everything.
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.
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.)
| 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.
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).
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.
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
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.
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 acompactsingle-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.
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.
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()ingui/src/main/windows.tsas a separate registry entry ofkind: 'monitor'(singleton). It bypasses the 900×600baseWindowOptionsfloor (default 320×460, min 240×200), setsalwaysOnTop: true, andsetVisibleOnAllWorkspaces(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 themonitorkey inwindow-state.json. - Scope: the renderer boots with
?monitor=1;lib/window-scope.tsresolves this to{ kind: 'monitor' }(isMonitorWindow()).App.tsxrenders onlyviews/MiniMonitor/MiniMonitor.tsxfor 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 (reusinguseNeedsAttention), 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.
| 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 |
| 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 |
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")
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: stdio only in v9.0. The MCP client spawns
watchfire mcp serveas a subprocess; the server auto-starts the daemon if needed (same path as the CLI) and connects viaconfig.LoadDaemonInfo(). - SDK: official
github.com/modelcontextprotocol/go-sdk(v1.6.1). - Shutdown: the MCP spec shuts a stdio server down by closing its stdin.
Servewraps 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 servealso setsSilenceUsageso a genuine startup failure is not buried under a flag dump. - Scoping: tools take an optional
projectargument (project id or name). Whenwatchfire mcp serveis started inside a registered project directory, that project is the default andprojectmay 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 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 draft⇄ready (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 canonical outer-agent workflow the tool surface is designed around:
create_task— files the work; creating a task never starts itrun_task— starts a sandboxed agent on it in an isolated worktreewait_for_task— blocks until the daemon's reactive lifecycle finishes (agent writesstatus: done→ watcher → stop → merge/auto-PR → cleanup); ontimed_out: truethe client simply calls againget_task— checksuccess/failure_reason(done≠ succeeded)get_task_diff— review what was merged- 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.
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/Canceledare reported as the daemon is not reachable — a different failure from a bad argument, withwatchfire daemon startas 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 localwatchfired. - 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.
- 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.goparses the package's own source (pluscmd/watchfire/mcp.go) and fails on anynet.Listen*/http.ListenAndServe/grpc.NewServercall or any HTTP/SSE MCP transport, and pins thatServewires the transport toos.Stdin/os.Stdout; the e2e test checks the same property from outside bylsof-ing the live server process for listening or non-loopback sockets. --read-onlyflag:watchfire mcp serve --read-onlyregisters 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 fromtools/listand 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.
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.
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.
| 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.
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 initremains interactive) - Sampling / elicitation (server-initiated requests to the client)
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).
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.
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)
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_token — never 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.
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).
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 |
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 existingLocateTranscript, 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 sameTailableTranscriptinterface. The Claude locator derives the transcript directory with Claude's own encoding (every non-alphanumeric character of the work dir →-, son9o.xyz→n9o-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-runsLocateafter ~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_screentool), 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 viaeditMessageTextuntil 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
/useselection auto-attaches to the most recently started live session anywhere (resolveWatchSession), so a fresh pairing streams activity without setup;/usepins 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_offflag on the paired chat, so the zero value (including chats paired before the field existed) means watching;/watch offopts out.
- Primary source — transcript tail. A polling
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:
- The bridge never calls
Resize. Terminal size is global per project; an external bridge resizing would fight the attached TUI/GUI. injectSayis the only PTY write. The single sanctionedSendInputcall site isBridge.injectSay, reached from exactly two intents: the/sayverb 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+\rchunk 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.
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.
| 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 |
~/.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.
<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:
- Creates
.watchfire/directory structure - Appends
.watchfire/to project's.gitignore(creates if missing) - Commits the
.gitignorechange (prevents worktree circular dependencies) - Adds project to
~/.watchfire/projects.yaml
{
"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
}cellsis a 2D array:cells[row][col]fgandbgare 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
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 timestampTask 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 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 itGlobal configuration in ~/.watchfire/settings.yaml.
Note: The
installation_id(analytics UUID) is stored separately in~/.watchfire/installation_idto 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 | darkRegistry 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: 2Notes:
pathupdated if sameproject_idopened from new locationpositionused for GUI ordering- Self-healing: Every project-scoped CLI command calls
EnsureProjectRegistered(), which reads the local.watchfire/project.yamland re-registers the project in the global index if missing, updates the path if the project moved, and reactivates it if archived. This means deletingprojects.yamlor moving a project directory is automatically repaired on first use.
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)
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 statusto display active agents - Cleaned up on daemon shutdown
issue_typeandissue_messagereflect current blocking issue if any
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
| 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" |
| 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 |
| 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 |
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 |
| 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 |
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
}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 |
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 |
| 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 |
| 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> |
Defined in proto/watchfire.proto.
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
}| 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) |
| 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 |
| 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"
}| 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) |
| 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 |
| 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"
}| 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 |
| 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 |
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 |
| 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;
}
}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
| 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"
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
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
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) |
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
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
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
SubscribeAgentIssuesfor 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
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
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.
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
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]
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
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) |
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
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
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
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)
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
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 lintersAd-hoc: go run ./cmd/watchfire or go run ./cmd/watchfired
| Component | Tools |
|---|---|
| Go | gofmt, golangci-lint |
| Electron | prettier, eslint |
| Proto | buf lint |
| 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 |
versionjob — readversion.json, output version + codename for other jobsbuild-gojob — matrix{macos-14/arm64, macos-latest/amd64}. Build with ldflags, codesign, notarize, upload arch-specific artifactsbuild-go-linuxjob — matrix{amd64, arm64}onubuntu-latest. Build CLI + daemon for Linuxbuild-go-windowsjob — matrix{amd64, arm64}onubuntu-latest. Build CLI + daemon for Windowsbuild-guijob — download both macOS arch artifacts,lipouniversal binaries, sync version,npm ci && npm run packagewith signing env vars. Uploads DMG + zip +latest-mac.ymlbuild-gui-linuxjob — download Linux amd64 binaries, build AppImage + deb onubuntu-latest. Uploads AppImage + deb +latest-linux.ymlbuild-gui-windowsjob — download Windows amd64 binaries, build NSIS installer onwindows-latest. Uploads installer exe +latest.ymlreleasejob — 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)
| 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.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
- ...Manual step: Edit draft release → Publish
| 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. |
| 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 |
brew tap watchfire/tap
brew install watchfire # Installs CLI + daemonTap repo: watchfire/homebrew-tap (separate repo, auto-updated on release)
| 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 |
| 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 |
| 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).
| 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) |
| 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. |
When implementing, follow these rules:
- No premature abstraction. Write simple, obvious code first.
- No alternative approaches. Use the tech stack specified above. Do not suggest alternatives.
- Test manually. Describe how to test before writing code.
- Ask if unclear. If the architecture doesn't specify something, ask—don't guess.