WatchTower

August 9, 2026 · View on GitHub

License

Run fleets of AI coding-agent workers, unattended. File tickets into named queues. Workers drain them automatically. wt status shows which queues are stuck: nothing more, nothing less.

The engine is a single durable JSON queue (append-only, file-locked): stdlib-only Python, zero runtime dependencies. WatchTower is a standalone product. It does not require Claude Command Center or any other desktop app to run; every command below works from a plain terminal. A couple of optional integrations (an HTTP delegate for one alternate messaging transport, a shared model-default file) light up automatically if CCC happens to be installed on the same machine, and are called out explicitly where they appear. Nothing in the core queue, worker, or dashboard path depends on them.

The 30-second demo

The signature feature is wt wait: it blocks until a queue has zero open tickets, so you can gate a script on a fleet of agents finishing their work. See it end to end, with no agent CLI required, in one script:

git clone https://github.com/amirfish1/watchtower.git
cd watchtower && pip install -e .
./examples/quickstart-demo.sh

It files a ticket, starts wt wait in the background, claims and closes the ticket (standing in for a real worker), and shows wt wait unblock the instant the queue empties. Read examples/quickstart-demo.sh; it is a plain shell script, nothing hidden.

Quick start (3 steps)

Requirements: Python 3.11+, macOS or Linux. An agent CLI, Claude Code or Codex, is only needed once you want WatchTower to spawn real AI workers instead of draining tickets yourself.

# 1. Install
git clone https://github.com/amirfish1/watchtower.git
cd watchtower && pip install -e .

# 2. Create a queue, point it at your repo
wt set -q MYAPP --repo-path /path/to/your/repo --engine claude
wt drain on MYAPP      # enable auto-drain + start the service

# 3. File your first ticket
wt add -q MYAPP --title "Fix the login page" --type bug

That's it. A Claude worker spawns automatically in /path/to/your/repo and starts draining. Open the dashboard to watch:

wt dashboard    # opens http://127.0.0.1:8787 in your browser

Install

git clone https://github.com/amirfish1/watchtower.git
cd watchtower
pip install -e .     # installs the `wt` CLI
wt --version

Service (background watcher + reconciler)

wt start        # starts the service; installs the LaunchAgent on first run
wt uninstall    # remove the LaunchAgent

There's no separate install step: the first wt start (or wt drain on, which starts the service too) writes and loads a macOS LaunchAgent so the watcher starts on every login and restarts on crash. Re-running wt start later just (re)starts the already-installed service.

wt stop     # stop it
wt status   # check service + queue health

If you'd rather write the LaunchAgent without starting it, wt install still works, but it's a hidden command now: wt start is the path everyone should use.

Agent skills (Claude Code + Codex + Antigravity)

The first wt start also syncs the bundled skills into every agent harness present on the machine (~/.claude/skills/, ~/.codex/skills/, ~/.gemini/skills/ for Antigravity), so any session, regardless of which repo it's rooted in, can use them without being told about wt first. Each is a symlink to the copy inside the installed package, so a git pull (editable install) or package upgrade updates it everywhere instantly with no separate re-sync step. Four skills ship today:

  • watchtower: look up a ticket by ref (wt find HERMES-20), check queue health, file/claim/close tickets.
  • wt-triage-queue: unstick a queue that's flagged stuck or has aging, abandoned, or unclosed claims.
  • critique (/critique in any synced harness): spawn two cross-family agents that independently critique a plan/design/diff and report back via wt send. Wraps wt critique; the reports arrive asynchronously (wt critique --json returns the spawned worker records for scripted callers, and each critic's log file holds the raw output).
  • group-chat-checkin: coordinate parallel sessions for discussion, task execution, and git commits.
wt skills sync      # same sync `wt install` runs; safe to re-run anytime
wt skills status    # show what's linked, without changing anything
wt skills remove    # undo: removes only the symlinks wt manages

See docs/agent-discovery.md for the design rationale (why a skill, not just a repo convention or an MCP server).

Browser annotation widget (optional)

Drop contrib/annotate-widget.js into your project to let users file tickets directly from the browser with a single click. See contrib/annotate-widget.md.

Usage

wt status                              # per-queue depth, oldest-open age, stuck flag
wt ls -q DEMO                          # list tickets in a queue
wt find DEMO-1                         # look up one ticket by ref, no -q needed

wt add -q DEMO --title "Fix nav" \
       --text "navbar overlaps on mobile" --type bug

wt import plan.md -q DEMO                 # preview document tasks
wt import plan.md -q DEMO --apply         # file only new tasks

wt edit   DEMO-1 --priority p0 --type feature  # patch fields on an existing ticket
wt edit   DEMO-1 --queue OTHER                 # move to a different queue in place (reassigns ref)

wt claim  -q DEMO                      # claim the next open ticket (smart sort)
wt claim  -q DEMO DEMO-1               # claim a specific ticket by ref
wt run    DEMO-1                       # mark an existing GitHub issue runnable
wt close  DEMO-1 --summary "fixed the overlap" --commit <SHA>
wt close  DEMO-1 --summary "documented the decision" --no-code
wt close  DEMO-1 --summary "fixed the overlap" --commit <SHA> \
          --caveat "only tested on iOS" --follow-up "add a regression test"

wt drain on DEMO                       # enable auto-drain for a queue
wt drain off DEMO                      # disable auto-drain
wt set -q DEMO --repo-path /path/to/repo --engine claude

wt workers                             # list workers the watcher started
wt wait   -q DEMO --timeout 600 --cmd "say done" # block until drained, then run cmd

wt start                               # start the watcher; installs the LaunchAgent on first run
wt stop                                # stop the watcher
wt uninstall                           # remove the LaunchAgent
wt dashboard                           # open the night-watch dashboard (non-blocking)
wt dashboard --no-open                 # ensure the server is up, don't open a browser
wt skills sync                         # (re-)sync the bundled skills into installed harnesses

Import a document into a queue

wt import sends the complete document through one Claude reasoning call. It infers explicit and implicit work, chooses ticket-sized units that one worker session can complete coherently, and preserves dependency order. Checkboxes, headings, and numbered steps are hints rather than parsing rules.

The command previews the full reasoned ticket set by default. Pass --apply to file it and optionally --type bug|feature to override every inferred type. The locally authenticated claude CLI must be available. A missing CLI, timeout, malformed response, invalid source anchor, or invalid dependency fails loudly before any ticket is filed. Imports use Sonnet in Claude safe mode by default; set WATCHTOWER_IMPORT_MODEL to override the model. --tools "" alone is not an isolation boundary: Claude Code may still load local skills, plugins, and project instructions. Keep --safe-mode on the importer invocation so that ambient local context cannot consume the import budget.

wt import docs/launch-plan.md -q LAUNCH
wt import docs/launch-plan.md -q LAUNCH --apply --type feature

Each ticket records the resolved source file and line anchor. A stable source key makes repeat imports safe: importing the same document again creates no duplicates, while newly inferred document tasks create new tickets.

Agent engines

WatchTower supports two agent engines. Set the engine per queue with wt set:

wt set -q MYAPP --engine claude    # default: Claude Code (stream-json, FIFO, live)
wt set -q MYAPP --engine codex     # OpenAI Codex (one-shot exec)

The engine is stored in ~/.watchtower/queue-config.json and picked up by the reconciler on the next spawn. Existing live workers are unaffected until they are released from queue staffing and a replacement is started.

claude (default)

Requires the Claude Code CLI (claude on $PATH).

Workers are spawned as:

claude -p --input-format stream-json --output-format stream-json \
       --verbose --permission-mode bypassPermissions

The drain goal is delivered as the first stream-json message on a named pipe (FIFO) that is the worker's stdin. This makes the worker a live, pushable process: when wt add files a new ticket while the worker is idle, the message arrives on the FIFO immediately, with no polling and no sleep loop; warm context is preserved. The worker's prompt cache stays hot for ~5 minutes after the last claim (Anthropic's cache TTL), so tickets that arrive in that window are cheaper and faster to handle.

Cache warmth does not control staffing. After 30 minutes of verified inactivity, the reconciler gracefully releases the conversation from this queue without killing it; later work gets replacement staffing as needed.

codex

Requires the OpenAI Codex CLI (codex on $PATH).

Workers are spawned as:

codex exec <drain-goal>

The drain goal is passed directly as the command-line argument (one-shot exec). There is no FIFO stdin channel and no live push notification: the worker drains until the queue is empty, performs the idle audit, completes the active drain goal, and exits immediately. New tickets filed while it is running are picked up on the next wt claim loop iteration. wt add notifications are not delivered to a running codex worker.

Comparison

claudecodex
Spawn modestream-json over FIFOexec <goal>
Live push (wt add)yes, via FIFOno
Prompt cache warm wakeyes (~5 min)no
Multi-ticket sessionyes, one process, warm ctxone process, warm ctx
RequiresClaude Code CLIOpenAI Codex CLI

GitHub Issues backend (optional)

A queue can use GitHub Issues instead of the local JSON queue file. WatchTower still exposes the same commands; behind the scenes wt add creates an issue, wt claim assigns it, and wt close closes it with a resolution comment.

So yes: closing a GitHub-backed ticket closes the corresponding GitHub Issue. The required --summary plus a completion proof (--commit <SHA> for code changes or --no-code for non-code work), and any caveats, follow-ups, or unresolved items, are posted as that issue's resolution comment and retained in WatchTower's metadata for the dashboard and CLI history.

gh auth login
wt set -q MYAPP --backend github --github-repo <OWNER>/<REPO>

wt add -q MYAPP --title "Fix checkout" --text "Steps to reproduce..."
wt run MYAPP-123                       # run this issue now (overrides drain/grace)
wt claim -q MYAPP --worker worker-1
wt close MYAPP-123 --worker worker-1 --summary "fixed the null state" --commit <SHA>

GitHub-backed refs use the issue number (MYAPP-123 is issue #123). A GitHub-backed queue lists open issues plus issues completed in the last 14 days from the configured repository, so GitHub is the visible storage layer.

Whether a ticket gets worked comes from three independent inputs, not from a whitelist label:

InputLives onDefault
auto_drain (wt drain on|off MYAPP)queueoff
watchtower:no-auto-drainissue labelabsent
watchtower:play (wt run MYAPP-123)issue labelabsent
grace_s (wt config -q MYAPP --grace-s 180)queue180s
auto_eligible   = auto_drain AND NOT no-auto-drain AND age >= grace_s
manual_eligible = run_requested            # wt run / the dashboard Run action
work_it         = auto_eligible OR manual_eligible

So a queue with drain on works every open issue except the ones labelled watchtower:no-auto-drain, and leaves brand-new issues alone for grace_s seconds so a human can label them first. wt run MYAPP-123 overrides all three. The old watchtower:<QUEUE> label no longer admits anything; it is only still read when two or more queues point at the same repo, where it is the only way to say which issue belongs to which queue. Turning drain on for a public repo prints a warning first — agents will work strangers' issues. Claims assign the issue to @me by default; override with wt set -q MYAPP --github-assignee USERNAME.

wt status shows, per queue, depth (open) / WIP / done, oldest-open age, idle time, a WORKERS column (total (n live)), and a STUCK/draining/ok flag, followed by a drain readout (~3/min · empty in ~20m, or stalled when no ticket has closed recently). Then a workers section listing each tracked worker's id, queue, pid, LIVE/DEAD state (process liveness via os.kill(pid, 0)), and what it is doing right now: -> DASH-3 (4m) (the in-progress ticket it holds and how long ago it claimed it) or idle.

Drain rate + ETA

WatchTower turns the queue's own close timestamps into a live estimate, so wt status and the dashboard read like a forecast rather than a static count:

  • drain rate: tickets closed in the last 30 minutes (a fixed window) over the window, i.e. closes/min.
  • ETA: depth / drain_rate, rendered as ~20m / ~2h; stalled (null in JSON) when nothing has closed in the window.

These are computed from data already in the queue (closed_at timestamps); no new persistent state, no transcript parsing. /api/status carries drain_rate_per_min, eta_seconds, and eta_human on each queue, and active_ref / active_since_human on each worker.

The dashboard: wt dashboard

wt dashboard [--port 8787] [--host 127.0.0.1]
             [--no-open] [--stop] [--foreground] [--once]

wt dashboard is a night-watch operations console: a calm, dark instrument panel over your agent fleet that lights up like a beacon when a queue stalls. It does not block your terminal:

  1. Starts the HTTP server detached in the background if it isn't already running (idempotent: a second wt dashboard won't start a second server), recording its pid in ~/.watchtower/dashboard.pid (override with $WATCHTOWER_DASHBOARD_PID).
  2. Opens your browser to the URL (webbrowser.open).
  3. Prints the URL and returns immediately.

Flags:

  • --no-open: ensure the server is up, but don't open a browser.
  • --stop: stop the background dashboard server (via the pidfile).
  • --foreground: run the server in the foreground (blocking), for debugging.
  • --once: handle a single request then exit (used by the test suite).
  • --host / --port: bind address (default 127.0.0.1:8787, local-first).

The watcher can host it too: wt start --dashboard brings the dashboard up alongside the watcher in one process (--host/--port apply there as well).

The page (stdlib-only http.server, no dependencies, auto-refreshes ~5s):

  • A tower header: the WatchTower wordmark with a beacon dot (calm green normally, amber when any queue is stuck) and a mono fleet summary (3 queues · 1 stuck · 2 workers live).
  • A responsive instrument grid: one card per queue with a big mono readout (7 open · empty in ~14m, STALLED, or clear), a slim drain bar, and a live-worker count. A stuck card gets an amber accent border and a slow pulsing beacon glow (respecting prefers-reduced-motion).
  • A workers section: worker id (mono), queue, activity (-> SHIP-3 (4m) or idle), and a LIVE / DEAD pill.
  • Drill-down: clicking a queue card opens /q/<queue>, listing that queue's active tickets (ref / status / worker / title) and, below them, a Closed section where each row shows its resolution summary + chips.
  • An empty state that reads as an invitation, not an error: a dim beacon and "All queues clear".

It also exposes read-only JSON:

  • GET /api/status: health rows (each annotated with worker counts) + the worker roster.
  • GET /api/queues: per-queue counts (mirrors wt queues).
  • GET /api/queue/<name>: the active tickets in one queue plus a closed array (each closed item carries its resolution).

Resolutions: record HOW a ticket was fixed

Closing a ticket is also where a worker records what it did: the trust-layer signal that turns a drained queue into an auditable log.

wt close DEMO-1 --summary "rewrote the flex container" --commit <SHA> \
         --caveat "watch the sticky footer on Safari" \
         --follow-up "add a visual regression test" \
         --unresolved "the print stylesheet still clips"

--summary is the one-liner. Every close also needs exactly one proof: --commit <SHA> validates and records the code-change commit in the ticket's repository; --no-code explicitly records that the work changed no code. --caveat, --follow-up, and --unresolved are repeatable. The resolution is stored on the item (item["resolution"]) and surfaced in the dashboard's per-queue Closed section and on closed wt ls rows. If a verified change cannot be committed, block the ticket with progress instead of closing it.

Pass --enqueue-follow-ups to also file each follow-up / unresolved item as a new open ticket in the same queue (opt-in), so loose ends don't get lost.

The drill-down page (/q/<queue>) lists active tickets, then a Closed section (most-recent first) where each row shows its summary with small chips for caveats / follow-ups / unresolved. GET /api/queue/<name> returns both the active tickets and the closed array (closed items carry their resolution).

The signature feature: wt wait

wt wait -q DEMO blocks (polling) until the queue has zero open tickets, then exits 0, optionally running a --cmd afterward. Drop it at the end of a script to gate on a fleet of agents finishing their work. examples/quickstart-demo.sh runs this exact flow start to finish.

Cross-agent messaging

Queues move work; messaging moves conversation. WatchTower can push a message to any agent session, ask one a question and wait for the answer, and run multi-agent group chats where the daemon (deterministically, no LLM moderator) nudges the right participants to respond.

wt send @planner "heads up: schema changed, re-read models.py"
wt ask  @reviewer "is PR 42 safe to merge?" --timeout 60
wt agents                        # named registry + workers + last-3-days sessions
wt agents register planner --session <uuid>  # set-name is an alias for register

wt chat new "release plan" --with @planner,@reviewer --include-human
wt chat post <ref> "let's cut v0.2 tonight"
wt chat read <ref> --tail 20
wt chat ls / add / leave / nudge / archive / close

Targets are a worker id, a registered @name, or any session UUID (or unique prefix). Any session on disk is reachable; liveness is not required.

Delivery falls through three adapters:

  1. fifo: the target is a live WatchTower worker, message goes straight down its stdin pipe.
  2. resume: claude --resume headless, with a busy-hold: if the target's transcript changed in the last 120s the message waits in a durable outbox and is delivered the moment the session goes quiet (never forks a parallel turn).
  3. delegate (optional): an HTTP endpoint ($WATCHTOWER_DELEGATE_URL, or auto-detected from a local Claude Command Center) for instant injection into live terminal tabs and for non-Claude engines. Unset and undetected means fully standalone; off disables it explicitly.

Undeliverable messages persist in ~/.watchtower/outbox.json and are retried by the daemon with backoff (dead-lettered after 20 attempts, visible in the activity log).

Group chats are file-compatible with CCC group chats (same markdown + JSON sidecar under ~/.claude/group-chats when present), so both tools can serve the same conversations. Per-engine feasibility ground truth lives in docs/engine-capability-matrix.md.

Where the queue lives

WatchTower resolves its store in this order:

  1. $WATCHTOWER_STORE: explicit override (used by tests/CI).
  2. The existing CCC store at ~/.claude/command-center/ux-fixes-queue.json if it already exists on this machine, so WatchTower drains real work today without migration.
  3. ~/.watchtower/queues.json: WatchTower's own default, used whenever no CCC store is present. A fresh install with no CCC on the machine always lands here.

Tracked workers live in ~/.watchtower/workers.json; the watcher daemon's pidfile is ~/.watchtower/daemon.pid, and the background dashboard server's is ~/.watchtower/dashboard.pid.

Stuck detection

A queue is stuck when it has open tickets AND no ticket has been closed in the last 10 minutes. This is pure queue ground-truth: WatchTower decides from the queue file alone, with no dependency on any external liveness signal.

Roadmap

  • Tap-to-act / buttons: claim, close, or requeue tickets from the dashboard UI.
  • Cost / token tracking: per-queue and per-worker spend.
  • Push notifications: alert on a queue going stuck or draining.
  • MCP server: a structured, versioned tool API (wt_find, wt_status, wt_claim, ...) as the longer-term follow-up to the CLI + skill combo above, for harnesses without shell access. See docs/agent-discovery.md for the full option comparison and why the skill (now shipped) was the right near-term move.

More docs

License

WatchTower Software License © 2026 Amir Fish — source-available, free for non-commercial use; commercial use requires permission. Versions released before 2026-07-28 remain under the MIT License.