tono
v0.3.6
Published
Self-hosted orchestrator for CLI agent tools (Claude Code, Codex CLI, OpenCode) triggered by GitHub issue labels.
Maintainers
Readme
tono
Self-hosted orchestrator for CLI agent tools (Claude Code, Codex CLI, OpenCode) triggered by GitHub issue labels.
Etymology: tono is the middle of au·tono·mous — fitting for a tool whose whole job is letting agents run unattended.
Heads up: this project was entirely vibe-coded by AI. Every line of code, every commit, every README revision (including this one). Read accordingly — there are no human-reviewed parts. Use at your own risk.
What it does
You add a label to a GitHub issue or PR. Tono notices, spins up the matching CLI agent (Claude Code, Codex CLI, or OpenCode) inside a fresh git worktree on your machine, points it at the issue or PR, and lets it open the PR / post the review when it's done. The browser UI streams the live terminal so you can watch the agent work or jump in.
It's built to run quietly in the background on a machine of your choice — a Mac Mini under your desk, an old laptop, whatever — and pick up work as it comes.
GitHub issue (label: tono-claude) GitHub PR (label: tono-claude-review)
│ │
▼ ▼
gh issue list gh pr list
│ │
▼ ▼
worktree off baseBranch worktree at PR head (detached)
│ │
▼ ▼
PTY: claude <prompt> PTY: claude <prompt>
│ │
▼ ▼
gh pr create gh pr review --comment
│
▼
gh pr view (poll for merge)The crucial design choice: tono does not implement an agent. It orchestrates external CLI agents — Claude Code, Codex CLI, OpenCode — by spawning them in a real PTY so their full TUI output is preserved.
Two kinds of work, one orchestrator
| Kind | Trigger | Worktree | Agent's job | Output |
|---|---|---|---|---|
| implement | label on issue | branch off baseBranch | implement the issue, push, run gh pr create | a new PR |
| review | label on PR | detached HEAD at PR's head | read the diff, run gh pr review --comment (or --approve / --request-changes) | a review on the PR |
Each agent has its own queue and its own concurrency cap per kind, so a slow implement doesn't starve fast reviews and vice versa.
Trigger labels (convention, not config)
Labels are fixed by convention. There are six total — apply whichever you want and tono picks the right agent + kind:
| Agent | Implement issues | Review PRs |
|---|---|---|
| claude-code | tono-claude | tono-claude-review |
| codex | tono-codex | tono-codex-review |
| opencode | tono-opencode | tono-opencode-review |
Run tono config labels to print the table for your current config.
Requirements
- macOS. Linux support is plausible but untested; the launchd integration is macOS-only.
- Node 20+.
ghinstalled and authenticated (gh auth login). Tono shells out toghfor issue polling, PR polling, and PR-merge tracking.- The agent CLI(s) you want to use, on
$PATH. At least one of:- Claude Code — install instructions. Verify
claude --version. - Codex CLI —
pnpm add -g @openai/codexor follow the Codex CLI repo. Verifycodex --version. - OpenCode — see opencode.ai. Verify
opencode --version.
- Claude Code — install instructions. Verify
Install
Tono is on npm. Install it globally with pnpm:
pnpm add -g tono
pnpm approve-builds -g # one-time: allow better-sqlite3 and node-pty to fetch prebuildsVerify it landed on your $PATH:
tono --version
which tono # → ~/.local/share/pnpm/tono (or your pnpm bin path)To upgrade later:
tono upgrade # one-shot: install the latest version + restart any loaded daemonstono upgrade auto-detects the package manager that installed the binary
(pnpm / npm / yarn / bun by inspecting the install path), runs the
equivalent of <pm> add -g tono@latest, and restarts whichever LaunchAgents
are currently loaded (com.tono.gateway, com.tono.worker). Pass
--check to see current vs latest without installing, --no-restart to
skip the daemon restart, or --pm <name> to force a specific package
manager.
You can still upgrade by hand if you prefer:
pnpm add -g tono@latest
tono gateway restart # pick up the new binary in the background daemonIf you have remote workers, run tono upgrade on each worker machine too
(or upgrade by hand and tono worker restart).
DB migrations (gateway-side) run automatically on the next tono start /
tono gateway restart. Tono backs up ~/.tono/tono.db to
~/.tono/tono.db.bak.v<N> before any version-stepped migration runs, so an
upgrade is safe to roll back: stop the gateway, copy the backup over the
live db, downgrade tono, and start.
Workers and gateways must run the same minor version of tono (e.g. all
on 0.3.x). Patch upgrades within a minor are guaranteed to interop. If a
worker's minor differs from the gateway's, the gateway logs a warning on
connect and may fail to dispatch tasks if the protocol shape has drifted.
To uninstall:
tono gateway uninstall # remove the LaunchAgent first
pnpm remove -g tono
rm -rf ~/.tono # optional — drops config, db, worktreesAbout native modules: tono pulls in
better-sqlite3andnode-pty, which need to compile or fetch a prebuilt binary. Both ship prebuilds for macOS arm64/x64. pnpm disables install scripts by default for safety, hence the extrapnpm approve-builds -gstep. If you prefer npm or yarn, the install commands work too — those run install scripts by default and skip the approve-builds step.
Get started
1. Configure tono
tono configureThe wizard walks you through:
- Bind host / port for the dashboard (defaults:
0.0.0.0:7040) - Poll interval
- Workspaces root (default:
~/.tono/workspaces) - Which agents to enable (claude-code, codex, opencode) — for each:
- Command name
- Per-kind concurrency caps (implement / review)
- Repos to watch — for each:
- GitHub slug (
owner/repo) - Path to your existing local clone, or leave blank to let tono bare-clone via
ghinto~/.tono/workspaces/.bare/ - Base branch
- Which of your enabled agents are enrolled on this repo
- GitHub slug (
Re-run tono configure later, edit ~/.tono/config.json directly, or use the Config page in the web UI.
2. Run the gateway
tono gateway start # installs + loads a macOS LaunchAgent
tono open # opens the dashboard in your browserThe gateway runs as a background daemon (com.tono.gateway), survives reboots, and auto-restarts on crash. Logs go to ~/.tono/logs/daemon.{out,err}.log.
3. Trigger your first agent
Two ways:
- From GitHub. Apply one of the convention labels (
tono-claude,tono-codex-review, etc.) to an issue or PR in a watched repo. WithinpollIntervalSeconds(default 60s), tono queues a task and dispatches the matching agent. - From the dashboard. Click + Start session, pick a repo, pick an agent, type the issue number. Tono fetches the issue body via
ghand queues an implement task immediately. (Manual review-task trigger isn't wired through the UI yet — apply the review label on the PR.)
Click into the task to watch the live terminal.
Reaching the dashboard
- From the gateway machine:
tono open(orhttp://localhost:7040). - From another device on your LAN:
http://<host-ip>:7040. Find your host IP withipconfig getifaddr en0on macOS. - From anywhere via Tailscale: install Tailscale on the gateway machine and any device you want to use, then visit
http://<gateway-tailnet-ip>:7040.
There is no auth in v1. Either bind to LAN-only or use Tailscale ACLs.
CLI reference
| Command | Purpose |
|---|---|
| tono init | Write default config + schema + database to ~/.tono/. |
| tono configure | Interactive setup wizard. Edits or creates the config. |
| tono start | Run the orchestrator in the foreground (dev mode). |
| tono gateway start | Install + load the macOS LaunchAgent so the orchestrator runs in the background and at login. |
| tono gateway stop | Unload the LaunchAgent (config kept). |
| tono gateway restart | Reload after upgrading or changing the plist. |
| tono gateway status | Show LaunchAgent state and HTTP health. |
| tono gateway uninstall | Unload and remove the LaunchAgent. |
| tono gateway logs [out\|err] | Tail the gateway log files. |
| tono open | Open the web UI in your default browser. |
| tono config validate | Validate ~/.tono/config.json against the schema. |
| tono config labels | Print the GitHub labels each configured agent listens for. |
| tono config path | Print the config file path. |
Configuration
Lives at ~/.tono/config.json, validated against ~/.tono/config.schema.json. Most fields are reachable from the Config page in the web UI; this is the underlying shape:
{
"$schema": "./config.schema.json",
"server": { "host": "0.0.0.0", "port": 7040 },
"github": { "pollIntervalSeconds": 60 },
"workspaces": { "root": "~/.tono/workspaces" },
"repos": [
{
"slug": "owner/repo",
"path": "/Users/me/code/repo", // optional; leave out to bare-clone via gh
"baseBranch": "main",
"agents": ["claude-code", "codex"] // optional; omit to enroll every declared agent
}
],
"agents": {
"claude-code": {
"command": "claude",
"args": ["--dangerously-skip-permissions"],
"concurrency": { "implement": 2, "review": 4 },
"promptTemplates": {
"implement": "...", // {issueNumber} {issueTitle} {issueBody} {repoSlug} {branch} {baseBranch}
"review": "..." // adds {prUrl} for review tasks
}
},
"codex": { /* same shape; command "codex", args [] */ },
"opencode": { /* same shape; command "opencode", args ["run", "{prompt}"] */ }
}
}Notes:
- Labels are convention. There is no
triggerLabelfield. Each enrolled agent listens fortono-<short>(implement) andtono-<short>-review(review), where<short>isclaudeforclaude-codeand the agent name otherwise. - The
{prompt}placeholder inargslets agents that take prompts as flags (likeopencode run "<prompt>") coexist with agents that take prompts as final positionals (likeclaudeandcodex). If{prompt}is absent fromargs, tono appends the rendered prompt as the last arg. - Per-(agent, kind) concurrency. A
concurrency: { implement: 2, review: 4 }block means up to 2 implement tasks and up to 4 review tasks of that agent run simultaneously. Set either to0to disable that kind for the agent. - Live config reloads (repo add/remove, prompt template edits, concurrency changes) take effect on the poller's next tick. Server host/port changes require
tono gateway restart.
Web UI
- Dashboard. Tasks grouped by phase: Running → Awaiting review (PR open) → Queued → Recent. Plus any live shells. Buttons:
+ Start session(manual trigger) and+ New terminal(free shell on the host). - Session view. Full xterm.js terminal connected to the live PTY over WebSocket. Resume-safe (close the tab, come back later). Buttons: Mark done (free the slot without killing the agent), Kill session, Cleanup worktree, Resume / Retry (uses
claude --resume <id>for Claude Code; codex / opencode start fresh on retry). - Config. Per-section forms (Server, GitHub, Workspaces, Repos, Agents). Add new agents from the Add agent card at the bottom. Each section has its own Save button. A "Show raw JSON" toggle reveals the unstructured editor.
Task lifecycle
implement task:
queued ──► running ──► (agent runs gh pr create)
│
├──► pr_open ──► merged (PR-watcher polls; auto-cleans worktree)
│ ├─► pr_closed
│
└──► completed (no PR; Mark done or session exited 0)
failed ← spawn / non-zero exit before any PR
cleaned ← worktree manually removed
review task:
queued ──► running ──► completed (agent posts review and exits)
failed ← spawn / non-zero exit
cleaned ← worktree manually removed (also drops refs/tono/pr-N)The PR watcher polls every pollIntervalSeconds and uses gh pr list --head <branch> to discover PRs even when our streaming detection misses the URL. Once a PR is merged, tono runs git worktree remove automatically. Review tasks have nothing to merge, so the watcher ignores them.
Files on disk
~/.tono/
├── config.json # JSON-Schema-validated config
├── config.schema.json
├── tono.db # SQLite: tasks (with kind), sessions, issues_seen
├── logs/
│ ├── daemon.out.log # gateway stdout
│ ├── daemon.err.log
│ ├── task-N.log # raw PTY bytes per task (4MB ring + tee to disk)
│ └── shell-XXXX.log # free shells from "+ New terminal"
└── workspaces/
├── .bare/<owner>__<repo>.git # only if you didn't supply a `path`
├── <owner>__<repo>__issue-N/ # implement worktree
└── <owner>__<repo>__pr-review-N/ # review worktree (detached HEAD)Stack
| Layer | Pick |
|---|---|
| HTTP | Hono on the Node adapter |
| WebSocket | ws |
| PTY | node-pty |
| DB | better-sqlite3 |
| Schema validation | ajv |
| Frontend | React 19 + Vite 6 + Tailwind v4 + xterm.js (WebGL renderer) |
A single Node process runs the HTTP server, GitHub poller (issues + PRs), task scheduler, PR-merge watcher, and owns every PTY.
Roadmap
What works today:
- Implement flow: GitHub issue → labeled trigger → agent runs in a worktree → PR opened → merge tracked → worktree cleaned.
- Review flow: GitHub PR → labeled trigger → agent runs against the PR's head in a detached worktree → review posted via
gh pr review. - Three agent types: Claude Code, Codex CLI, OpenCode.
- Per-(agent, kind) queues and concurrency, so reviews never starve implements (and vice versa).
- Manual implement triggering from the UI (no need to label first).
claude --resumefor Claude Code retry; codex / opencode retry runs fresh.- Live PTY streaming with WebGL-rendered xterm.js, scrollback ring buffer, reconnect-safe.
- Free-form
+ New terminalshells (browser-based SSH-lite to the gateway machine). - macOS launchd background gateway with
tono gatewaylifecycle commands. - Live config edits via the web UI without restarting.
What's next:
- Webhook triggers to drop polling latency from ~60s to sub-second (Tailscale Funnel or smee.io).
- Manual review-task trigger in the UI (today: label the PR).
- Cost / token tracking.
- Inline review comments posted by tono itself rather than asking the agent to call
gh api.
Distributed workers
Tono can be split across multiple machines: one gateway (always-on box —
e.g. your Mac mini) plus any number of workers (your MacBook, a Windows
laptop, …) that connect outbound over a tailnet and execute agent runs on
their own filesystem. Each connected worker adds capacity for the
(agent, kind) partitions it advertises.
Topology. The gateway owns the SQLite state, GitHub poller, PR watcher,
scheduler, browser UI, and config. Workers own a local node-pty and a
local .bare/ clone cache. Tasks flow:
poller (gateway) → scheduler picks an eligible worker
→ task.assign over WS → worker creates worktree, spawns agent
→ PTY data streams back to gateway → browser UItono start continues to work unchanged on a single box: it launches an
embedded local worker in the same process that connects to its own
gateway over 127.0.0.1. The embedded worker is fungible with remote ones —
the scheduler just sees more capacity once a remote worker connects.
Prerequisites (per worker machine):
- Tailscale with the worker on the same tailnet as
the gateway. There is no app-level auth — reachability over the tailnet IS
the trust boundary. The gateway should bind on its tailnet IP (or
0.0.0.0if the LAN is also trusted). ghCLI, authenticated (gh auth login). The worker bare-clones repos itself; it does not pull git data through the gateway.- The agent CLIs you want this worker to run (
claude,codex,opencode). The worker auto-detects which are on PATH and advertises those as its capabilities.
Run a remote worker (foreground):
tono worker run --gateway <gateway-host>:7040
# advertise specific agent commands and concurrency:
tono worker run \
--gateway my-mac-mini:7040 \
--agent claude-code=claude \
--concurrency claude-code=3/2Run a remote worker as a background daemon (macOS):
# first time: install + load the LaunchAgent (com.tono.worker)
tono worker start --gateway my-mac-mini:7040
# later: lifecycle commands mirror `tono gateway`
tono worker stop
tono worker restart # reload after settings changes
tono worker status # plist state + connection
tono worker logs # tail ~/.tono/logs/worker.out.log
tono worker uninstall # remove the LaunchAgentSettings persist in ~/.tono/worker.json (gateway URL, agent commands,
concurrency, paths). The first start / run writes them; later
invocations don't need flags. Pass new flags to override and re-persist
(tono worker restart --concurrency claude-code=4/2 updates the JSON and
reloads the daemon). The same file holds a stable worker UUID so reconnects
pick up the same identity.
Workers reconnect with exponential backoff; if the gateway is offline the worker sits idle until it returns.
Disconnect handling. When a worker drops mid-run (laptop sleeps, Wi-Fi
blip), the gateway holds the task as running for workers.graceMs
(default 60 000 ms). Reconnect within the window resumes the live stream.
After the grace expires the task is marked failed with exit_code = -3
and you can retry it. The setting lives in ~/.tono/config.json under
the optional workers block:
{
"workers": { "graceMs": 60000 }
}Caveats (v1):
- No worker affinity / pinning yet — workers are fungible by capability.
- Worktree cleanup runs on the gateway's filesystem only. For tasks that
ran on a remote worker, the worktree under that worker's
~/.tono/workspaces/is left in place; clean it up there manually withgit worktree remove. - Free-form shells in the UI are gateway-local (they always run in the gateway process, not on workers).
License
MIT.
