Daemon (watchfired)
The Watchfire daemon is the backend brain — managing projects, spawning agents, handling git workflows, and serving clients over gRPC.
The daemon is the backend brain of Watchfire. It manages multiple projects simultaneously, watches for file changes, spawns coding agents in sandboxed PTYs with terminal emulation, handles git worktree workflows, and serves state to thin clients over gRPC.
Lifecycle
| Aspect | Behavior |
|---|---|
| Development | Run watchfired --foreground for hot reload (tray still active) |
| Production | Runs in background, started automatically by CLI/TUI/GUI if not running |
| Persistence | Stays running when thin clients close |
| Shutdown | Ctrl+C (foreground), CLI command, or system tray quit |
The daemon starts automatically when you run any watchfire command. It persists in the background even after the CLI/TUI exits, so agent sessions keep running.
Network
| Aspect | Decision |
|---|---|
| Protocol | gRPC + gRPC-Web (multiplexed on same port) |
| Port | Dynamic allocation (OS assigns free port) |
| Discovery | Connection info written to ~/.watchfire/daemon.yaml |
| Clients | CLI/TUI use native gRPC, GUI uses gRPC-Web |
Multi-Project Management
| Aspect | Behavior |
|---|---|
| Projects index | ~/.watchfire/projects.yaml lists all registered projects |
| Registration | Projects added via CLI (watchfire init) or GUI |
| Concurrency | One active task per project, multiple projects in parallel |
| Client tracking | Tracks which clients are watching which projects |
| Task cancellation | Task stops only when ALL clients for that project disconnect |
File Watching
The daemon uses fsnotify to watch for changes:
| Aspect | Behavior |
|---|---|
| Mechanism | fsnotify with debouncing |
| Robustness | Handles create-then-rename pattern (common with AI tools) |
| Per-project | .watchfire/project.yaml, .watchfire/tasks/*.yaml |
| Polling fallback | 5s polling as safety net for missed watcher events |
| Re-watch on chain | Re-watches directories created during earlier phases |
Agent Backends
The daemon dispatches all agent-specific behavior through a pluggable Backend interface. Each supported agent — Claude Code, OpenAI Codex, opencode, Gemini CLI, and GitHub Copilot CLI — is a Backend implementation registered with a process-wide registry at startup. The daemon looks backends up by name and asks them to:
- Resolve the agent binary (user-configured path →
PATH→ common install locations) - Build the PTY command line (binary, args, env, initial prompt handling)
- Install the composed Watchfire system prompt into whatever form the agent expects (CLI flag,
AGENTS.md,system.md, …) - Locate and format the JSONL transcript the session produced
- Contribute writable paths, cache patterns, and env vars to strip to the sandbox policy
Agent Resolution
When spawning a session, the daemon walks a four-step chain to pick the backend:
task.agent → project.default_agent → settings.defaults.default_agent → claude-code
Empty string at any level defers to the next. Chat and wildfire-refine/generate sessions aren't scoped to a single task, so they skip the task step.
Per-Session Homes
Codex, opencode, Gemini, and Copilot each run with an isolated per-session home so the Watchfire system prompt and per-session data don't leak into the user's personal config, while auth still flows from the real config directory:
| Backend | Env var(s) | Per-session path |
|---|---|---|
| Codex | CODEX_HOME | ~/.watchfire/codex-home/<session>/ |
| opencode | OPENCODE_CONFIG_DIR, OPENCODE_DATA_DIR | ~/.watchfire/opencode-home/<session>/ |
| Gemini | GEMINI_SYSTEM_MD | ~/.watchfire/gemini-home/<session>/system.md |
| Copilot | COPILOT_HOME, COPILOT_CUSTOM_INSTRUCTIONS_DIRS | ~/.watchfire/copilot-home/<session>/ |
Claude Code has no isolation — the daemon delivers the system prompt via the --append-system-prompt CLI flag and uses ~/.claude/ directly.
SettingsService.ListAgents
The daemon exposes a SettingsService.ListAgents RPC that returns the registered backends (name + display name). The GUI (and TUI settings tab) call this to populate agent-picker dropdowns, so any new backend registered in the daemon automatically appears in the UI.
Task Lifecycle
The daemon manages the reactive task lifecycle:
1. Client calls StartTask(task_id)
2. Daemon resolves the backend (task → project → global → claude-code)
3. Daemon creates git worktree for task
4. Daemon calls backend.InstallSystemPrompt (e.g. writes AGENTS.md into a per-session home)
5. Daemon spawns coding agent in sandboxed PTY (inside worktree)
6. Daemon streams screen buffer to subscribed clients
7. Agent updates task file when done (status: done)
8. Daemon detects via fsnotify OR polling fallback (5s)
9. Daemon kills agent (if still running)
10. Daemon calls backend.LocateTranscript and copies JSONL to logs
11. Daemon processes git rules (merge, delete worktree)
12. Daemon starts next task (if queued and merge succeeded)
Session Logging
The daemon captures two types of logs for each agent session, stored in ~/.watchfire/logs/<project_id>/:
| Format | Filename | Source |
|---|---|---|
| PTY scrollback | <task_number>-<session>-<timestamp>.log | Raw terminal output captured during the session |
| JSONL transcript | <task_number>-<session>-<timestamp>.jsonl | Structured conversation transcript from the active backend (preferred) |
Transcript Auto-Discovery
Transcript discovery is owned by each backend — the daemon calls backend.LocateTranscript on agent exit and copies (or synthesizes) the resulting JSONL into the logs directory:
| Backend | Source |
|---|---|
| Claude Code | ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl, matched by customTitle against the session name |
| Codex | <CODEX_HOME>/sessions/**/rollout-*.jsonl in the per-session home |
| opencode | Per-message JSON files under <OPENCODE_DATA_DIR>/storage/message/ collated into a synthesized transcript.jsonl |
| Gemini | ~/.gemini/tmp/<project_hash>/chats/session-*.jsonl (or legacy logs.json) |
| Copilot | <COPILOT_HOME>/session-state/**/events.jsonl in the per-session home |
Log Viewer
The ReadLog RPC serves session logs to clients (TUI and GUI). It prefers the .jsonl transcript when available and dispatches to the active backend's FormatTranscript to render a readable User/Assistant conversation with tool-call summaries. If no JSONL transcript exists, it falls back to the .log file (raw PTY scrollback). The same flow runs for every backend — no special-casing for Claude Code.
Phase Completion Signals
The daemon detects phase completion via signal files:
| Phase | Signal File | Daemon Response |
|---|---|---|
| Task | Task YAML status: done | Stop agent, merge worktree, start next |
| Refine | .watchfire/refine_done.yaml | Stop agent, start next phase |
| Generate | .watchfire/generate_done.yaml | Check for new tasks or end wildfire |
| Generate Definition | .watchfire/definition_done.yaml | Stop agent (single-shot) |
| Generate Tasks | .watchfire/tasks_done.yaml | Stop agent (single-shot) |
System Tray
The tray menu (internal/daemon/tray/tray.go) is rebuilt dynamically as project state changes — instead of a flat list of running agents it groups projects into intent-driven sections:
| Section | Content |
|---|---|
| Status header | "Watchfire Daemon" + version + "Running on port: port" |
| Needs attention | Projects with at least one failed task or a TASK_FAILED event in the recent JSONL window — clicking jumps straight to that project's task list |
| Working | Projects with an active agent — clicking opens the live Chat panel |
| Idle | Registered projects with nothing in flight — clicking opens the project view |
| Notifications (N today) ▸ | Submenu listing today's events from the JSONL fallback (~/.watchfire/logs/<project_id>/notifications.log), newest first. Each row routes back into the GUI for the originating task. |
| Open GUI | Launches the Electron GUI |
| Quit | Shuts down the daemon |
Click-through routing flows over the new DaemonService.SubscribeFocusEvents stream: when a tray entry is clicked the daemon publishes a focus event (project + task + tab), and any connected GUI/TUI subscribed to the stream brings the right view forward. The same mechanism is used by clicking an OS toast.
Desktop Notifications
The Beacon notification subsystem lives in the internal/daemon/notify package and exposes a typed notify.Bus that fans events out to every subscribed channel (slow consumers are dropped, never blocked). Every event carries a stable MakeID derived as sha256(kind|project_id|task_number|emitted_at_unix)[:8], so the same event can be deduplicated across the OS toast, the tray submenu, the focus router, and any future subscriber.
In addition to live in-process delivery, every event is appended as a JSONL line to ~/.watchfire/logs/<project_id>/notifications.log. This file is the headless fallback: clients that weren't connected when an event fired (or the dynamic tray submenu rebuilding from cold) read it back to reconstruct the recent timeline.
| Platform | OS toast backend |
|---|---|
| macOS | Native UNUserNotificationCenter via CGo (displays Watchfire icon) |
| Linux | github.com/gen2brain/beeep |
| Other | No-op (logs to stderr) |
On macOS, notifications are safely handled when running outside the .app bundle (e.g., during development or when launched directly). The daemon checks for app bundle availability before sending notifications to avoid crashes. This was fixed in v0.4.0 — previously, a notification outside the .app bundle could cause a daemon crash (exit code 2) due to an unhandled NSInternalInconsistencyException.
Event Kinds
| Kind | When it fires | OS toast title / body |
|---|---|---|
TASK_FAILED | Emitted from internal/daemon/server/task_failed.go::emitTaskFailed whenever a task transitions to done && !success. | Title <project> — task #NNNN failed; body is the task title plus the optional failure_reason. |
RUN_COMPLETE | Fires on the falling edge of every autonomous run — single-task, run all, and wildfire — bounded by RunningAgent.RunStartedAt so a run is exactly the work between a start and the moment no agent is running for that project. | Title names the project + run kind; body is N tasks done · M failed. |
WEEKLY_DIGEST | Scheduled by digestRunner using a re-armable time.Timer driven by models.DigestSchedule.NextFire. The schedule is DST-stable and includes a 24-hour catch-up window so a digest missed during sleep/shutdown still fires once on next start. The Markdown report is rendered to ~/.watchfire/digests/<YYYY-MM-DD>.md and a toast points at it. | Defaults off — opt in from settings. |
Bundled Sounds
Beacon ships two short cues under assets/sounds/:
| File | Plays for |
|---|---|
task-done.wav | RUN_COMPLETE |
task-failed.wav | TASK_FAILED |
Both are mono 22050 Hz so the renderer can decode them with no extra dependencies. The shouldPlaySound(kind, prefs) helper decides whether a given event should play audio at all (master toggle, per-event toggle, sounds enabled, quiet hours, per-project mute). When the GUI renderer is foregrounded it plays the sound itself at the user-set volume — and in that case the OS toast is sent silent so users never hear the cue twice.
Settings & Gating
Notification preferences live under defaults.notifications in ~/.watchfire/settings.yaml (the same file used by the rest of the daemon defaults). Every send path funnels through the models.ShouldNotify helper, which combines the master toggle, per-event toggle, quiet-hours window, and per-project mute list before any toast, sound, or JSONL append happens. The schema is the single source of truth — both the GUI Settings panel and the TUI globalsettings tab read and write the same fields.
Health Check
The daemon exposes a Ping RPC — a lightweight health check that clients can use to verify the daemon is alive and accepting connections. Unlike GetStatus, which returns detailed daemon information, Ping is a simple empty-to-empty call designed for fast connection verification.
Startup Reliability
The daemon guarantees that ~/.watchfire/daemon.yaml (the connection discovery file) is only written after the gRPC server is fully ready and accepting connections. This eliminates race conditions where clients would read the file and attempt to connect before the port was open.
CLI and GUI both verify port readiness before proceeding after launching the daemon, so commands like watchfire agent start will not fail with "connection refused" errors even when starting the daemon for the first time.
Daemon Commands
# Start the daemon (no-op if already running)
watchfire daemon start
# Show daemon status
watchfire daemon status
# Stop the daemon
watchfire daemon stop