Skip to content

Repository files navigation

Agent Task Board

CI License: MIT Next.js React Tailwind CSS Tests

Mission control for the work you hand to AI coding agents.

A local-first kanban for delegating tasks to AI coding agents. Queue a prompt, hand it off, watch what's running with live timers, review the result, and ship — all in your browser. Nothing leaves your machine.

The manual board is agent-agnostic — the agent label on a card is free text, so you can track work you hand to any tool. The automated orchestration layer (dispatcher, PR flow, review gate, session-resume) is built specifically around Claude Code (claude -p … --output-format json / --resume); pointing it at another CLI means giving up the Claude-specific pieces (JSON result parsing, independent review gate, revise-in-place session resume).

Agent Task Board — four lanes (Queued, Running, Review, Done) of prompt-first cards on a dark mission-control board


Contents

Why

If you drive more than one AI agent at a time, the bottleneck stops being writing prompts and becomes tracking them: what's queued, what's actually running, what's waiting on your review, and what you can copy-paste again next week. Agent Task Board is a focused board for exactly that loop — prompt-first cards in four lanes that mirror the delegation lifecycle:

Lane Meaning
Queued Drafted prompts, not yet handed off
Running Handed to an agent, work in flight (live elapsed timer)
Review Agent finished, needs your eyes
Done Reviewed, merged, shipped (shows time-to-done)

Features

  • Prompt-first cards — each card's payload is the reusable prompt you give the agent, shown in monospace with one-click copy.
  • Four-lane delegation flow — Queued → Running → Review → Done, with zero-padded counts and a per-lane status colour.
  • Drag-and-drop — reorder within a lane or move across lanes (pointer + full keyboard support via dnd-kit).
  • Move buttons‹ › on every card for quick, touch-friendly lane changes.
  • Live timers — Running cards tick up in real time; Done cards show how long the work took (tabular figures, no jitter).
  • PR-aware cards — when a task opens a pull request, the card surfaces a one-click PR link field; result text is expandable (show more/less) with clickable links.
  • Independent review gate (opt-in) — before a PR opens, a fresh agent that didn't write the code reviews the diff alongside the repo's own checks (lint/typecheck/test) and iterates fixes until the change clears a confidence gate — so you only ever review PRs that already passed. See Independent review gate.
  • Send a PR back to fix (revise + resume) — a Review card whose PR has failing CI or a merge conflict can be sent back for another pass that updates the same PR (never opens a new one): from the card's "Send back for revision" button or Telegram /revise <id> <fix>. The dispatcher reopens the existing branch, resumes the original agent session (so it keeps its implementation context), merges the latest base, and force-free pushes the fix. See Revise a PR.
  • Terminal-state cleanup — the merge-watcher advances a merged PR's card to Done and deletes a card whose PR you closed without merging, pruning the stale atb/<id> branch either way — so rejected work and dead branches don't pile up.
  • Compact Done lane + archive — Done cards collapse to a one-line summary; archive finished cards behind a per-lane reveal (with restore) so the board never bloats.
  • Search — filter across titles, prompts, agents, tags, and notes instantly.
  • Local-first by default — the board lives in localStorage. No account, no telemetry.
  • Agent orchestration (opt-in) — switch to a server-backed live board and let real agents work the queue: an MCP server to enqueue by talking to an agent, a dispatcher that claims tasks (with optional concurrency), routes each by agent label and a /repo slash command (or repo: tag) to the right repo, and — for code tasks — opens a pull request automatically in an isolated git worktree before the card lands in Review. A Telegram bot is your control surface. See Agent orchestration.
  • Run insights (live board) — an in-board Insights panel (pulse icon in the header, live mode only) reads the dispatcher's run history: total runs, success rate, average duration, total agent cost (USD), per-status / per-repo / per-agent breakdowns, and a recent-runs list with review scores, per-run cost, and PR links. It's the UI over the same data as the npm run history CLI. See Run insights.
  • Per-task cost — the dispatcher captures each run's total_cost_usd from Claude Code's JSON output (implementer plus every review-gate reviewer/fixer run) and writes it back: a cost chip on the card, a Total cost KPI in the Insights panel, and a total cost line in npm run history. So the review gate's extra spend is finally visible, not just warned about.
  • Which model ran it — alongside the cost chip, the card shows the model that implemented the task (e.g. Sonnet 5, Opus 4.8), parsed from Claude Code's modelUsage output. Pin a route to a specific model with --model in routes.json (see Routing); left unpinned, it just reports whatever your claude CLI's configured default is. Tasks completed before this feature (or before a control-plane restart picks up a routing change) won't show a model chip — it isn't backfilled.
  • Automated dependency updatesDependabot opens weekly grouped PRs for npm and GitHub Actions bumps (majors stay ungrouped for individual review).
  • Export / Import — back up or move your board as a JSON file.
  • Undo — deletes and board-clears are undoable from a toast.
  • Keyboard shortcutsn to add a task, / to focus search, ⌘↵ to save, Esc to close.
  • Accessible & responsive — labelled controls, visible focus rings, reduced-motion support, and a horizontal-scroll layout on mobile.

Tech stack

  • Next.js 16 (App Router, Turbopack) + React 19
  • TypeScript
  • Tailwind CSS v4
  • dnd-kit for drag-and-drop
  • Vitest + Testing Library for the core logic
  • Visual language vendored from dragonfly-ds — a dark, editorial design system (black canvas, hairline grid, single orange-red accent, three-face type system)

Getting started

npm install
npm run dev
# open http://localhost:3000

The board seeds itself with a sample set of tasks on first visit. Clear it (trash icon) to start from an empty board; Load sample board brings the demo back.

Scripts

Command Description
npm run dev Start the dev server (Turbopack)
npm run build Production build
npm run start Serve the production build
npm run lint ESLint
npm run typecheck tsc --noEmit
npm run check:agent node --check every agent/**/*.mjs (syntax guard for the ESM orchestration scripts)
npm run test Run the Vitest suite
npm run test:coverage Run the Vitest suite with a coverage summary (v8)
npm run test:watch Vitest in watch mode
npm run agents Bring up the whole control plane: board + dispatcher + Telegram bot (details)

Architecture

The board is split into pure, framework-free logic and a thin React layer, which keeps the core fully unit-testable.

Agent Task Board control plane — producers (improvement scout + Telegram bot) feed the board's Queued → Running → Review → Done lanes; the dispatcher claims a task, runs it in an isolated git worktree through the review gate and PR flow, and the merge-watcher polls GitHub to move merged PRs to Done, with a revise send-back loop from Review back to Queued

lib/
  types.ts        Domain types (Task, Status, BoardState)
  board.ts        Pure reducer: add / update / delete / move / commitDrag / claimNext / setResult
  columns.ts      Lane metadata (labels, hints, colours)
  time.ts         Timer & relative-time formatting
  storage.ts      localStorage persistence + JSON export/import
  seed.ts         Sample board
  boardEngine.ts  Engine contract + EMPTY sentinel + mode flag
  localEngine.ts  Local-first engine (localStorage)
  apiEngine.ts    Live engine (server-backed, polls /api/board)
  useBoard.ts     useSyncExternalStore hook; selects the engine by mode
  insights.ts     Pure display helpers + types for the Insights panel (/api/history)
  server/         store.ts (file-backed, mutex-guarded), auth.ts, parse.ts  ← server-only
components/
  ds/             Vendored dragonfly-ds primitives (Panel, Text, Button, Rule) + tokens
  BoardApp.tsx    Orchestrator: state, search, modals, toasts, shortcuts
  Board.tsx       DndContext + drag logic
  TaskCard.tsx    The prompt-first card (renders the agent result)
  InsightsPanel.tsx  Run-history dashboard modal (live mode; polls /api/history)
app/api/          Route Handlers: board, tasks, claim, tasks/[id]/result, history
agent/
  launch.mjs        One command: board + dispatcher + watcher (`npm run agents`)
  dispatcher.mjs    Claim → route by agent → run → report (to board + Telegram)
  merge-watcher.mjs PR merged → Review→Done; closed → delete card; prunes the branch (polls `gh`)
  scout.mjs         Improvement scout: scan ~/code, rank ideas, queue the single best (`npm run scout`)
  mcp-server.mjs    MCP stdio server exposing board tools
  telegram-bot.mjs  Inbound: messages → queued tasks
  launchd/          macOS LaunchAgent installers: whole control plane (persistent) + scout (every 2h)
  lib/              api.mjs (board client), telegram.mjs, prs.mjs, routes.mjs (routing + repo/PR helpers), git.mjs (worktree-isolated PR flow), scout.mjs (scan/rank helpers), scout-memory.mjs (cross-run scan ledger), message.mjs

BoardState is modelled as a flat tasks map plus ordered id-lists per column — the canonical multi-container shape — so reorders and cross-lane moves are simple array splices and dnd-kit's arrayMove slots in cleanly. The same pure reducer in board.ts backs both the browser (localEngine) and the server store, so the local and live boards behave identically.

Testing

npm run test       # 306 tests: reducer (incl. archive + revise send-back), claim/result, storage, time, insights display helpers, server store, agent routing/PR/repo-slug/auto-requeue/revise helpers, review-gate helpers, merge/close + branch-prune PR helpers, /revise + /cancel Telegram helpers, scout scan/rank/propose + incremental-scan memory/backlog helpers, run-history, Telegram message parsing, orphaned-task requeue-on-restart, and the git-flow integration suite (real worktree/PR/revise against a local origin)
npm run typecheck
npm run lint
npm run build

The reducer (move/commitDrag timestamping, atomic claimNext, setResult, reconcile/repair), storage round-trips, time formatting, and the server store (FIFO + concurrent-claim atomicity) are all covered. The impure git/PR flow (agent/lib/git.mjs) — normally verified by hand — also has an integration suite (agent/lib/git.integration.test.mjs) that drives the real functions against a throwaway repo with a bare local origin (a fake gh on PATH stands in for GitHub): worktree isolation, branching off origin/<base>, the hydration-artifact exclusion that keeps node_modules/.env out of PRs, and the revise merge/conflict path. UI flows (create, move, copy, delete+undo, search, persistence, drag-and-drop) were verified in the browser, and the full agent loop (enqueue → dispatch → run → review) plus the MCP server were verified end-to-end against a live server.

Design system

The interface is built on a vendored copy of dragonfly-ds (in components/ds/): a dark editorial system with a black canvas, a faint 10%-white hairline grid, a single orange-red accent (#fa4c14), and a three-face type system (Fraunces / Inter / JetBrains Mono, self-hosted via next/font). Four sparing status hues — one per lane — are layered on top.

Privacy

100% client-side. Your tasks and prompts live only in your browser's localStorage and are never sent anywhere. Use Export to keep a backup.

Securing API mode

In API mode the board is a small HTTP server, open by default (local/trusted-network posture). Setting AGENT_TOKEN switches on uniform bearer-token auth: every /api route — GET/POST/DELETE /api/board, POST /api/tasks, PATCH/DELETE /api/tasks/:id, POST /api/claim, POST /api/tasks/:id/result, POST /api/tasks/:id/cancel — returns 401 without a valid Authorization: Bearer <token> header. Tokens are compared in constant time (timingSafeEqual). The agent-side clients (dispatcher, merge-watcher, MCP server, Telegram bot, control-plane launcher) all read the same AGENT_TOKEN env var and send it automatically.

The browser UI in API mode needs the token too: set NEXT_PUBLIC_AGENT_TOKEN to the same value (e.g. in .env.local). Note that a NEXT_PUBLIC_* var is inlined into the client JavaScript bundle at build time — anyone who can load the UI can read it. AGENT_TOKEN therefore protects the API from other processes and hosts on the network, not from someone you serve the UI to; for real multi-user exposure put the board behind a reverse proxy with proper auth. Leaving AGENT_TOKEN unset keeps the whole API open — the local-first default.

Bind to localhost. npm start runs next start, which listens on 0.0.0.0 — reachable from anywhere on your network. Since the local-first default leaves the API open, prefer npm run start:local (next start -H 127.0.0.1) so the board is only reachable from your own machine. If you do need to expose it beyond localhost, set AGENT_TOKEN and put it behind a reverse proxy with proper auth — don't rely on the open default on a shared network.

Agent orchestration

Beyond the manual board, Agent Task Board can run a full delegate → dispatch → run → review loop where real agents pull work off the queue. The pieces:

  Telegram ──▶ MCP / API ──▶ [ Queue ] ──▶ Dispatcher ──▶ runner (claude -p)
   (you)        (enqueue)                  (claim+route)      │
      ▲                                                       ▼
      └──────────── "picked up by X" / result ◀────── Review lane (your approval)
  1. You enqueue — talk to the Telegram bot (or any MCP client via the MCP server, or POST /api/tasks). Your message becomes a queued task.
  2. The dispatcher claims it atomically (one task → one agent), routes it to the right runner by the task's agent label, and notifies Telegram "🟢 picked up → Claude Code".
  3. The runner executes the prompt (e.g. claude -p), and the dispatcher posts the output back to the Review lane and to Telegram "✅ done" / "❌ failed".
  4. You review in the board (live mode shows cards move on their own and renders the agent's output on the card) and approve to Done — drag the card, or PATCH /api/tasks/:id {"status":"done"} / the MCP move_task.
  5. Or let it auto-complete. For tasks that open a pull request (label them commit-push, or have the runner print a github.com/.../pull/N url), the merge-watcher polls gh and moves the card to Done by itself the moment the PR is merged — so "merge to master" is the approval.

Turn it on

One command — bring up the board (in api mode) and the dispatcher, with labelled output and a clean Ctrl-C that stops everything:

npm run agents                    # board + dispatcher (dry-run) + merge-watcher + inbound bot
npm run agents -- --execute       # let the dispatcher actually run runners + open PRs
npm run agents -- --concurrency 3 # run up to 3 tasks at once (each PR task is worktree-isolated)
npm run agents -- --no-telegram   # don't run the built-in inbound bot
npm run agents -- --prod          # serve a production build (run `npm run build` first)
npm run agents -- --no-board      # attach to an already-running board (BOARD_URL)
npm run agents -- --no-watcher    # don't run the merge-watcher

The built-in inbound bot is auto-on whenever TELEGRAM_BOT_TOKEN is set — every message becomes a queued task. Pass --no-telegram to disable it (do that when an external front door like hans / telegram-claude-agent owns inbound on the same bot — two pollers on one token 409; give the board its own bot token to run both). BOARD_URL is the single port knob: set it to e.g. http://localhost:3738 and the board binds that port.

Run persistently (macOS) — keep the whole control plane (board + dispatcher + watcher + bot) alive across logins and crashes with a LaunchAgent; it provides its own board, so nothing else need run (don't also npm run agents by hand — two boards clash on the port). Logs land in .data/controlplane.{out,err}.log:

npm run agents:install            # dry-run; add `-- --execute --prod` to run runners off a prod build
npm run agents:uninstall

Once installed, manage it with launchctl (label com.davidcjw.agent-task-board.controlplane) — no reinstall needed. kickstart/bootout/bootstrap/print are subcommands of the built-in /bin/launchctl, not separate binaries (launchctl kickstart …, not a kickstart on your PATH):

LABEL=com.davidcjw.agent-task-board.controlplane
launchctl print "gui/$(id -u)/$LABEL" | grep -E "state =|pid ="    # status (running? pid?)
launchctl kickstart -k "gui/$(id -u)/$LABEL"                       # restart in place
launchctl bootout "gui/$(id -u)/$LABEL"                            # stop (KeepAlive won't auto-revive a manual bootout)
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/$LABEL.plist   # start again from the installed plist
tail -f .data/controlplane.out.log .data/controlplane.err.log     # follow logs

Or run the pieces by hand:

# 1. run the board in live mode (.env.local)
echo "NEXT_PUBLIC_BOARD_MODE=api" > .env.local
npm run dev                       # serves the UI + the /api routes

# 2. dispatch agents (dry-run by default — reports the command, runs nothing)
npm run dispatcher                # add --execute to actually run runners
                                  # add --once to drain the queue and exit

# 3. (optional) merge-watcher: move Review → Done when a task's PR is merged
npm run watcher                   # polls `gh` every WATCHER_INTERVAL ms (default 30s)

# 4. (optional) Telegram control surface
TELEGRAM_BOT_TOKEN=… npm run telegram

# 5. (optional) MCP server, so you can enqueue by chatting with an agent
npm run mcp

# 6. (optional) run-history stats — summarize every task the dispatcher finished
npm run history                   # human summary; add --json for the raw object

Run history. Each time the dispatcher finishes a task (success, failure, or cancel) it appends one JSON line to .data/history.jsonl — id, title, agent, repo, final status, elapsed ms, the review score (if the review gate ran), the run cost in USD (implementer + review-gate runs), and the PR url (if opened). It's best-effort (a failed write never breaks the dispatch loop) and gitignored via the .data/ rule. npm run history reads that log and prints totals, success rate, average duration, total cost, and counts by status / repo / agent; an empty or absent log prints a clean zero-state.

Backups

The live board lives in a single JSON file (<BOARD_DATA_DIR>/board.json, defaulting to .data/board.json), so a wipe is one bad write away. Two zero-dep scripts make it recoverable:

npm run backup                 # snapshot board.json → .data/backups/board-<timestamp>.json
npm run backup -- --keep 50    # keep the newest 50 (default 20; or set BOARD_BACKUP_KEEP)
npm run restore                # list available backups
npm run restore -- --latest    # atomically restore the newest snapshot over board.json
npm run restore -- --from board-2026-07-05T12-34-56.789Z.json   # restore a specific one

backup writes a timestamped copy and prunes to the newest N (default 20, override with --keep <n> or BOARD_BACKUP_KEEP); it exits cleanly with a notice if no board file exists yet. restore refuses a file that doesn't parse to a valid board state, and won't overwrite a non-empty current board without --force; the restore itself is atomic (temp file + rename). Both honour BOARD_DATA_DIR, so pointing them at a custom data dir just works. Backups land under the gitignored .data/.

Run insights

The same run-history data is also available in the board (live mode only). A pulse icon in the header opens the Insights panel, which polls GET /api/history and shows:

  • KPIs — total runs, success rate, average duration, and total agent cost (USD).
  • Breakdowns — runs by status (lane-coloured), by repo, and by agent, as ranked mini-bars.
  • Recent runs — the latest finished tasks with a status dot, repo, review score (when the review gate ran), run cost, duration, and a one-click PR link.

The panel is a thin read-only view — the numbers come from .data/history.jsonl via summarizeHistory (the same pure helper behind npm run history), so the CLI and the UI never drift. The button only appears on a live (server-backed) board, since the local-first board keeps no server-side history. GET /api/history honours the same AGENT_TOKEN auth as every other route.

Copy .env.example to .env and fill in what you need.

The API

Method & path Purpose
GET /api/board Full board (the live UI polls this)
POST /api/tasks Create a task
PATCH /api/tasks/:id Update / move a task
DELETE /api/tasks/:id Delete a task
POST /api/claim Atomically claim the oldest queued task → Running
POST /api/tasks/:id/result Post an agent's result → Review
GET /api/history Run-history summary + recent records (the Insights panel polls this)
POST /api/reconcile Requeue tasks stranded in Running (worker died) → Queued (control plane calls once on boot)

When AGENT_TOKEN is set, every /api route requires Authorization: Bearer <token> and returns 401 otherwise; when unset the API is open. The browser UI in API mode sends NEXT_PUBLIC_AGENT_TOKEN. See Securing API mode.

Routing

The dispatcher routes each claimed task to a runner by its agent label using agent/routes.json (falls back to routes.example.json):

{
  "default":     { "command": "claude", "args": ["-p", "{prompt}", "--output-format", "json"], "cwd": "{repo}", "pr": true },
  "Claude Code": { "command": "claude", "args": ["-p", "{prompt}", "--output-format", "json"], "cwd": "{repo}", "pr": true,
                   "review": { "iterations": 2, "threshold": 95, "checks": ["lint", "typecheck", "test"] } },
  "knowledge-base": { "command": "claude", "args": ["-p", "{prompt}", "--agent", "knowledge-base"], "cwd": "." }
}

Placeholders {prompt} {title} {id} {agent} {tags} are substituted per task; commands run without a shell (safe with arbitrary prompt text). A cwd of "{repo}" makes one route serve every repo: a task tagged repo:<name> (set with the Telegram slash command /my-app, or a #repo:my-app tag) runs in <AGENT_REPO_BASE>/<name> (default ~/code). Code routes set "pr": true to open a PR automatically (see below); subagent routes use claude --agent <name> and a literal cwd.

Pinning the implementer's model: add "--model", "<alias-or-id>" to a route's args to fix which model runs its tasks:

"Claude Code": { "command": "claude", "args": ["-p", "{prompt}", "--model", "claude-opus-4-8", "--output-format", "json"], "cwd": "{repo}", "pr": true }

Use a full id (claude-opus-4-8, claude-sonnet-5) to pin an exact version, or a rolling alias (opus, sonnet, fable) to always track the latest release under that name. Left unset, the run just inherits claude's own configured default. Whichever model actually ran is reported back and shown as a chip on the card, alongside its cost. (This is separate from the review gate's REVIEW_MODEL, which only pins the reviewer/fixer passes.)

⚠️ Safety: the dispatcher is dry-run by default — it reports the command it would run and touches nothing. Pass --execute (or AGENT_EXECUTE=1) only when you're ready for agents to run commands and edit repos on your machine. Results always land in Review for your approval, never straight to Done.

Pull request by default, then auto-Done on merge

Any code route ("pr": true) acting on a repo:-tagged task opens a PR automatically — the old commit-push label is folded in, so you don't need a special label. The flow is dispatcher-driven and isolated: the agent only edits files, then the dispatcher itself runs the task in its own git worktree (branch atb/<id>, under AGENT_WORKTREE_DIR), commits, pushes, and runs gh pr create --fill. Because each task gets its own worktree, your main checkout is never touched and concurrent tasks (--concurrency N) — even on the same repo — never collide. The worktree is hydrated with a node_modules symlink + copied .env so builds/tests still work.

The agent's prompt (implementPrompt) also confines it to the worktree: work only in the current directory, never cd, never run git, and ignore any absolute path in the task (e.g. /Users/…/code/<repo>/…) — acting instead on the matching path inside the worktree. This is a real backstop: a --dangerously-skip-permissions agent will otherwise obey an absolute path embedded in a task (e.g. one the scout wrote) and edit/commit in your real checkout, leaving the worktree empty — so no PR opens, the task skips Review straight to Done, and stray commits accumulate under ~/code. The scout's scan prompt is the matching source-side fix (it emits repo-relative paths only, never git steps).

Telegram: /my-app add a health-check endpoint → worktree on atb/<id> → PR opened → card sits in Review with a one-click PR link.

The merge-watcher (agent/merge-watcher.mjs, started by npm run agents) then polls every PR found on a Review card via gh and acts on the PR's fate: merged → move the card to Done (Telegram "🎉 merged → Done"); closed without merging (you rejected it) → delete the card. Either way it prunes the now-stale atb/<id> branch so dead branches don't accumulate. On a merge it also fast-forwards the local checkout (git pull --ff-only in the task's repo: dir) so your working copy — and the scout, which fingerprints local HEAD to decide what to scan — reflect the merged commit instead of re-proposing already-shipped work; a dirty or diverged checkout is left untouched. So merging the PR to master is the approval and closing it is the rejection; you never touch the board. Detection is detached from how the card was created — any Review card whose result contains a github.com/.../pull/N url is watched.

  • Set the poll interval in .env via WATCHER_INTERVAL (ms, default 30000), or npm run watcher -- --interval 60000.
  • Point the task at the repo you want changed — the Telegram slash command /<name> (or a #repo:<name> tag) maps to <AGENT_REPO_BASE>/<name> — and make sure gh is authenticated there.
  • Branch pruning is guarded to atb/* heads (never a hand-made PR's branch); an already-deleted branch is a harmless no-op.

Revise a PR (send back)

A PR sitting in Review isn't a dead end. When CI fails or main moves under it, send it back for another pass that updates the same PR — no new PR, no force-push:

  • From the board — a "Send back for revision" button on Review cards opens a dialog for your correction.
  • From Telegram — bare /revise lists the revise-able cards; /revise <id> <correction> sends one back. The id is right there: every "in Review" notification includes a ready-to-use ↩️ send back: /revise <id> <fix> line, and the PR body carries a Board task: <id> footer.

The dispatcher then reopens the existing atb/<id> branch in a worktree at the same path, resumes the original agent session (claude --resume — so it recalls why it wrote the code, not just what) with your correction, merges the latest base (resolving conflicts), and fast-forward-pushes — the open PR just gains a new commit. Session capture is automatic (Task.sessionId); if a PR predates it, revise falls back to a fresh agent working from the checked-out branch. Implementation: runWithRevise + createReviseWorktree/finishRevise (agent/lib/git.mjs), routed by isRevise/resumeRoute/revisePrompt (agent/lib/routes.mjs).

Independent review gate (opt-in)

Before a PR ever opens, the dispatcher can run an independent review-and-fix loop inside the same worktree — so the PRs that reach your Review lane have already cleared an automated check. A fresh agent process (no context from the agent that wrote the code — genuinely independent, since each claude -p is one-shot) reviews the diff alongside the repo's own checks, and a fixer iterates until the change passes or a cap is hit.

implementer edits  →  [ loop: run checks → independent reviewer → fixer ]  →  open PR

The gate passes only when all three hold: the repo's checks are green and the reviewer reports zero blocking findings and its confidence ≥ the threshold. (The confidence number alone isn't trusted — an LLM's self-reported confidence isn't calibrated — so it's anchored to the hard signal of real lint/typecheck/test runs.) If the cap is reached without passing, the PR still opens, but flagged — the unresolved findings + confidence are folded into the card's result and the Telegram message shows ⚠ Flagged: … needs a closer human look.

Turn it on two ways (off by default, so existing routes are unchanged):

// per route in agent/routes.json — bare flag uses the defaults…
"Claude Code": { "command": "claude", "args": [...], "cwd": "{repo}", "pr": true, "review": true }

// …or tune the knobs:
"Claude Code": { …, "review": { "iterations": 2, "threshold": 95, "checks": ["lint", "typecheck", "test"] } }
# …or force it on for EVERY pr:true route, no routes.json edit:
AGENT_REVIEW=1 npm run agents -- --execute     # AGENT_REVIEW=0 forces it off
Knob Default Meaning
iterations 1 Max fix rounds (so ≤ iterations + 1 review passes) before opening a flagged PR
threshold 90 Minimum reviewer confidence (%) to pass the gate
checks auto Which package.json scripts to run; omit to auto-detect lint/typecheck/test (build excluded — too slow)

Cost: each enabled task spawns up to 2 × (iterations + 1) extra claude runs (reviewer + fixer per round) plus the check commands, so keep iterations modest. Review only runs for tasks that would open a PR (pr: true route + a repo: tag); plain questions and subagent tasks are never gated. That extra spend is no longer invisible — the reviewer/fixer runs are summed into the task's per-task cost alongside the implementer's.

Implementation lives in agent/lib/review.mjs (pure helpers unit-tested in review.test.mjs), wired into the PR flow in agent/dispatcher.mjs.

Timeouts & failure handling

Every runner the dispatcher spawns is bounded by AGENT_TIMEOUT (default 1200000 ms = 20 min, per runner invocation). When a run overruns, the dispatcher SIGKILLs it. What happens next depends on how the run ended:

Scenario What the dispatcher does Where the card lands
Runner times out (1st time) Auto-requeues once — moves the card back to Queued, stamps an auto-retry tag, and pings Telegram ⏱ Timed out … auto-requeued. For a PR task the half-finished worktree is discarded (no PR opened) so the retry starts clean. Queued (re-attempted)
Runner times out (2nd time) The auto-retry tag makes requeue one-shot — a second timeout is not retried. Review, flagged (human takes over)
Runner exits non-zero (genuine failure) No retry — a real error won't fix itself on a re-run. Review, flagged
Runner can't start (command missing, etc.) No retry; the start error is the result. Review, flagged
PR task, agent edited files Dispatcher commits → pushes → opens the PR. Review with a 🔗 PR link → merge-watcher moves it to Done on merge
PR task, no diff produced No PR opened (no file changes). Review / Done per the run's own error flag
Review gate enabled, cap reached unpassed PR still opens, but flagged (findings + confidence folded in). Review, marked ⚠ Flagged
Dispatcher throws unexpectedly safeProcess catches it and reports Dispatcher error: …. Review, flagged

Key properties:

  • One clean retry, never a loop. A timeout gets exactly one fresh attempt; the auto-retry tag (pure decision in shouldRequeue, unit-tested) guarantees a second timeout escalates to a human instead of retrying forever.
  • Timeouts don't leak partial PRs. Because the PR flow bails before committing when the implementer times out, the retry branches fresh off the default branch with no remote branch to collide with.
  • Only timeouts retry. Genuine failures (non-zero exit, start errors) go straight to Review — re-running them would just waste another AGENT_TIMEOUT.
  • The retry is FIFO-fair. A requeued card keeps its original createdAt, so claimNext (oldest-first) picks it up next.
  • Tune the budget with AGENT_TIMEOUT in .env. A task that's legitimately longer than 20 min should get a bigger budget or be split — a single very long agent run is also more likely to wander.
  • Restarts resume interrupted work. A control-plane restart (crash, reboot, launchctl kickstart, KeepAlive respawn) kills whatever the dispatcher was running mid-task. On the next boot agent/launch.mjs calls POST /api/reconcile before starting the dispatcher, which requeues every task stranded in Running back to Queued (pure requeueOrphaned, unit-tested — keeps createdAt for FIFO, clears the stale claim/start stamps). It's safe because a fresh boot has no live workers yet, so nothing in Running can be genuinely in-flight. Without this, an interrupted task would sit in Running forever (the dispatcher only ever claims Queued tasks).

Note: the review gate's reviewer/fixer runs and npm run checks have their own budgets (AGENT_TIMEOUT per claude run, REVIEW_CHECK_TIMEOUT per check, default 5 min), so a reviewed PR task's total wall-clock can exceed a single AGENT_TIMEOUT. Auto-requeue is scoped to the implementer run timing out, not the reviewer/fixer.

Cancel a running task (Telegram /cancel)

Changed your mind mid-run? Stop an in-flight task from Telegram:

  • /cancel — cancels the running task. If several are running, it shows an inline picker (one button per task: 🛑 title · repo · 4m).
  • /cancel <id> — cancel a specific one by id prefix.
  • /cancel all — stop everything that's running.

The cancelled card moves straight to Done with a 🛑 Cancelled by you marker (it is not auto-requeued). Under the hood the bot and dispatcher are separate processes, so /cancel writes a cancelRequestedAt flag to the board; the dispatcher polls for it (AGENT_CANCEL_POLL, default 3s) and process-group-kills the agent — so claude's child processes die too, not just the wrapper. For PR tasks the kill lands before anything is committed or pushed, so no half-finished branch leaks; the isolated worktree is torn down as usual. With concurrent agents the signal is per-task, so only the one you pick is stopped.

Improvement scout (auto-fills the queue)

The dispatcher consumes tasks; the scout (agent/scout.mjs, npm run scout) produces them. Every couple of hours it scans every repo under AGENT_REPO_BASE (~/code) for high-leverage improvements — infra, dev tooling, features, fixes, docs, even a brand-new project when the win is big — ranks them, and proposes only the single best one to you on Telegram with Yes/No buttons. Tap ✅ and it's queued for the dispatcher; tap ❌ (or ignore it) and nothing runs. You stay the gatekeeper — no backlog of suggestions, and nothing queued behind your back.

Excluding repos or subdirectories. Two env knobs:

  • AGENT_REPO_IGNORE — comma-separated list of whole repos to drop from scanning (names match separator-insensitively; a * glob matches the raw name, e.g. AGENT_REPO_IGNORE=archive, design-systems, third-party-repos). This is a hard exclude and is global — an ignored repo also disappears from the Telegram /slug//use menu (the dispatcher can still run a task that already carries an explicit #repo:<name> tag). Because the scan is one level deep, excluding a top-level folder keeps its whole subtree out — so ignore the parent (e.g. design-systems) rather than globbing the nested package names.
  • SCOUT_IGNORE_PATHS — comma-separated, repo-relative subdirectories to keep the scout out of inside repos you still scan (e.g. some-repo/vendor, some-repo/.next). This is a soft hint added to the scan prompt, not a hard fence.
scan only CHANGED repos  →  score + rank ideas into a backlog  →  propose the best on Telegram  →  ✅ → queue → dispatcher → PR → Review
                                          ↑ quiet day? skip the scan, propose the next backlog idea

Run it on demand from Telegram with /scout (or /scout full to force a full sweep) — you don't have to wait for the 2-hourly timer. Plain /scout is always the cheap incremental scan (changed repos only) — it passes --incremental so an on-demand tap never balloons into a ~30-min all-repo sweep; the periodic 24h full sweep stays owned by the scheduler and by explicit /scout full. The bot launches the same one-shot scout, which sends its own Yes/No proposal just like a scheduled run; if it finds nothing it says so. It refuses to start a second scan while one is running or while a proposal is still awaiting your answer. This only works when the control-plane bot owns Telegram inbound (the default) — if an external front door polls the same bot token (--no-telegram), add the command there too.

While a proposal is unanswered it pauses — the next scheduled run (or a /scout) skips scanning so ideas never pile up. If you don't reply for 24h the offer auto-expires and the next run scans afresh; a tap on a stale/superseded message is politely rejected. The pending offer lives in .data/scout-pending.json, and the control-plane Telegram bot is what acts on your tap — so keep the control plane running.

It remembers between runs (.data/scout-memory.json) so a 2-hourly scan isn't a cold re-read of all ~90 repos each time — which is most of the cost, since on a normal day only a few repos change:

  • Skips unchanged repos. Each run fingerprints every repo by its HEAD commit (+dirty when there are uncommitted changes) and deep-scans only the repos that moved (or are new). A repo whose SHA is unchanged is skipped entirely.
  • Keeps a backlog. A scan ranks many ideas but only the best is proposed each run; the rest are kept as a ranked backlog. So when nothing changed, the run skips the model entirely and still proposes a stored idea (the #2–#5 from an earlier scan) — you keep getting suggestions on quiet days at zero scan cost. Before each propose the backlog is re-filtered against the current repo list, so a stored idea for a repo you've since moved (e.g. into archive/), deleted, or added to AGENT_REPO_IGNORE is dropped instead of being re-pitched.
  • Retires only what you accept. Proposing an idea drops it from the backlog so a quiet run moves on to the next one, but only an idea you ✅ accept is permanently suppressed (deduped by a repo+title key, so reworded duplicates collapse). A ❌ or ignored idea is not retired — a later scan can resurface it. Fresh scans for a repo supersede that repo's stale backlog ideas, and the backlog + accepted titles are fed back into the prompt as a "don't repeat these" list.
  • Revisits everything periodically. A full sweep is forced every 24h (tunable via SCOUT_FULL_SCAN_MS, or --full) so an unchanged-but-improvable repo still gets a fresh look and a stale ledger can't permanently hide a repo.

It runs a fresh claude -p over the workspace and asks it to score each idea on three 1–10 axes — impact, confidence, ease — but the ranking is computed in code, not taken from the model: score = impact × confidence × ease (the classic ICE score), so it's deterministic and reproducible. The winner becomes a task three ways:

  • Existing repo (the idea's repo matches a folder under ~/code, separator-insensitively) → a repo:<name> tag, so it flows through the normal worktree → PR → Review path.
  • New project (category: new-project) → a prompt that scaffolds a fresh ~/code/<slug> (with git init); no PR, lands in Done for you to inspect.
  • Cross-repo / workspace idea → runs against the whole workspace; no PR.

Every scout task carries a scout tag for provenance.

Every scout task carries a scout tag for provenance. (With no Telegram configured the scout falls back to pushing the top idea straight to the board, the original behavior.)

npm run scout                 # scan → rank → propose the top idea on Telegram (board + bot must be up)
npm run scout -- --dry-run    # scan → rank → print the ranking + task input, propose nothing (memory untouched)
npm run scout -- --full       # ignore the memory ledger and scan every repo
npm run scout -- --print-prompt   # dump the scan prompt and exit (no model call)

Run it every 2 hours automatically with a LaunchAgent (macOS) — it fires at 08:00–22:00 and stays silent overnight:

npm run scout:install                          # every 2h, 08:00–22:00
npm run scout:install -- --start 9 --end 21 --every 3   # retune the window
npm run scout:install -- --minute 15           # fire at :15 past
npm run scout:uninstall                        # remove it

The scout runs no board of its own — on your ✅ the control-plane bot POSTs the task to whatever board is up, so keep the control plane running (npm run agents:install) to field the taps and accept the queue. Pure scan/rank/propose helpers are unit-tested in agent/lib/scout.test.mjs and the cross-run memory ledger in agent/lib/scout-memory.test.mjs; env knobs: SCOUT_MODEL, SCOUT_TIMEOUT (ms, default 30 min), AGENT_REPO_IGNORE, SCOUT_IGNORE_PATHS (see Excluding repos or subdirectories above).

MCP & Telegram

  • MCP server (agent/mcp-server.mjs) exposes add_task, list_tasks, get_board, claim_next, report_result, move_task. Point Claude Desktop / Claude Code at it (npm run mcp) and queue work by talking to an agent. Config example is in the file header.
  • Telegram bot (agent/telegram-bot.mjs) turns messages into tasks: "[Claude Code] fix the flaky test #bug" → a queued task tagged bug for Claude Code. Pick the repo a code task runs in with a slash command: a leading /<repo> (e.g. /my-app add a health endpoint) targets one task, and /use <repo> sets a sticky default for the chat so plain messages inherit it (/use shows it, /use off clears it). The repo list is read straight from the folders under AGENT_REPO_BASE and registered as a /-autocomplete menu on startup — create a new repo and it becomes a command automatically, nothing to maintain (slugs match separator-insensitively, so /democratizing_claude resolves democratizing-claude). A #repo:<name> tag still works as a fallback; precedence is /<repo> > #repo: tag > sticky default. /cancel stops a running task, /revise <id> <fix> sends a Review PR back for another pass, /scout kicks off the improvement scout on demand, and /pause / /resume stop and restart claiming of new work (see those sections above). Send /id to get your chat id for TELEGRAM_CHAT_ID (where the dispatcher posts notifications).

Wiring it to an existing Telegram agent

Already run a Telegram → claude -p bot (e.g. telegram-claude-agent)? Use it as the front door and let this board's dispatcher do the work — no edits to the bot needed if it runs claude --dangerously-skip-permissions --print:

  1. Register the MCP server for your bot's claude (once) so it can enqueue:
    claude mcp add -s user agent-task-board -e BOARD_URL=http://localhost:3000 \
      -- node /abs/path/agent-task-board/agent/mcp-server.mjs
    Any claude session now has add_task (and the bot auto-allows it under --dangerously-skip-permissions).
  2. Send dispatcher notifications to that bot's chat — in .env, set TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID to the same bot + chat, so "picked up" / results show up in your existing conversation.
  3. Run the board + dispatcher:
    npm run dev                       # board API + queue
    npm run dispatcher -- --execute   # claims → runs claude -p → reports to Telegram
  4. Tell your bot "queue a task for Claude Code to …" → its claude calls add_task → the dispatcher runs it and reports back. (Optionally add a line to the bot's persona telling it to use add_task when you say "queue / dispatch".)

The server-backed board persists to a JSON file (.data/board.json) and the agent layer needs a long-running process, so live mode is for local / self-hosted use — the public demo deployment stays local-first.

Contributing

Contributions are welcome! Please open an issue first to discuss what you'd like to change.

  1. Fork the repo
  2. Create a feature branch (git checkout -b feature/your-feature)
  3. Make your change, adding tests for any new logic in lib/
  4. Make sure npm run lint, npm run typecheck, npm run test, and npm run build all pass
  5. Commit (git commit -m 'feat: describe change'), push, and open a pull request

Code of Conduct

This project follows the Contributor Covenant v2.1. By participating you agree to uphold a welcoming, harassment-free environment.

License

Distributed under the MIT License. See LICENSE for details.

About

A local-first kanban for the tasks you delegate to AI coding agents — prompt-first cards, live timers, drag-and-drop.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages