tono 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -20
- package/dist/cli/checks.js +504 -0
- package/dist/cli/checks.js.map +1 -0
- package/dist/cli/commands/config.js +20 -3
- package/dist/cli/commands/config.js.map +1 -1
- package/dist/cli/commands/configure.js +1 -1
- package/dist/cli/commands/configure.js.map +1 -1
- package/dist/cli/commands/doctor.js +22 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/gateway.js +39 -14
- package/dist/cli/commands/gateway.js.map +1 -1
- package/dist/cli/commands/init.js +22 -3
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/start.js +69 -19
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/commands/worker.js +500 -0
- package/dist/cli/commands/worker.js.map +1 -0
- package/dist/cli/index.js +28 -9
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/launchd.js +5 -5
- package/dist/cli/launchd.js.map +1 -1
- package/dist/server/app.js +130 -35
- package/dist/server/app.js.map +1 -1
- package/dist/server/config/load.js +19 -1
- package/dist/server/config/load.js.map +1 -1
- package/dist/server/config/schema.json +19 -0
- package/dist/server/db/client.js +73 -2
- package/dist/server/db/client.js.map +1 -1
- package/dist/server/db/queries.js +126 -3
- package/dist/server/db/queries.js.map +1 -1
- package/dist/server/db/schema.sql +36 -21
- package/dist/server/events.js.map +1 -1
- package/dist/server/server.js +28 -11
- package/dist/server/server.js.map +1 -1
- package/dist/server/worker/agent.js +441 -0
- package/dist/server/worker/agent.js.map +1 -0
- package/dist/server/worker/pty-bridge.js +27 -0
- package/dist/server/worker/pty-bridge.js.map +1 -0
- package/dist/server/workers/github-poller.js +26 -8
- package/dist/server/workers/github-poller.js.map +1 -1
- package/dist/server/workers/reaper.js +44 -0
- package/dist/server/workers/reaper.js.map +1 -0
- package/dist/server/workers/registry.js +174 -0
- package/dist/server/workers/registry.js.map +1 -0
- package/dist/server/workers/scheduler.js +79 -130
- package/dist/server/workers/scheduler.js.map +1 -1
- package/dist/server/ws/pty.js +71 -54
- package/dist/server/ws/pty.js.map +1 -1
- package/dist/server/ws/workers.js +241 -0
- package/dist/server/ws/workers.js.map +1 -0
- package/dist/shared/types.js +4 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/shared/worker-protocol.js +28 -0
- package/dist/shared/worker-protocol.js.map +1 -0
- package/dist/web/assets/index-BsiC8WMV.js +129 -0
- package/dist/web/assets/index-CjzMqxyT.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +18 -26
- package/dist/web/assets/index-5VFn-lxF.js +0 -129
- package/dist/web/assets/index-CZHd5NaX.css +0 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# tono
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Self-hosted orchestrator for CLI agent tools (Claude Code, Codex CLI, OpenCode) triggered by GitHub issue labels.
|
|
4
4
|
|
|
5
|
-
> *
|
|
5
|
+
> *Etymology: **tono** is the middle of **au·tono·mous** — fitting for a tool whose whole job is letting agents run unattended.*
|
|
6
6
|
|
|
7
7
|
> **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.
|
|
8
8
|
|
|
@@ -63,21 +63,14 @@ Run `tono config labels` to print the table for your current config.
|
|
|
63
63
|
- **[`gh`](https://cli.github.com)** installed and authenticated (`gh auth login`). Tono shells out to `gh` for issue polling, PR polling, and PR-merge tracking.
|
|
64
64
|
- **The agent CLI(s) you want to use, on `$PATH`.** At least one of:
|
|
65
65
|
- **Claude Code** — [install instructions](https://docs.claude.com/en/docs/claude-code/quickstart). Verify `claude --version`.
|
|
66
|
-
- **Codex CLI** — `
|
|
66
|
+
- **Codex CLI** — `pnpm add -g @openai/codex` or follow the [Codex CLI repo](https://github.com/openai/codex). Verify `codex --version`.
|
|
67
67
|
- **OpenCode** — see [opencode.ai](https://opencode.ai). Verify `opencode --version`.
|
|
68
68
|
|
|
69
69
|
## Install
|
|
70
70
|
|
|
71
|
-
Tono is on npm
|
|
71
|
+
Tono is on npm. Install it globally with [pnpm](https://pnpm.io):
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
|
-
# npm (recommended — runs install scripts by default, so native modules just work)
|
|
75
|
-
npm install -g tono
|
|
76
|
-
|
|
77
|
-
# yarn
|
|
78
|
-
yarn global add tono
|
|
79
|
-
|
|
80
|
-
# pnpm
|
|
81
74
|
pnpm add -g tono
|
|
82
75
|
pnpm approve-builds -g # one-time: allow better-sqlite3 and node-pty to fetch prebuilds
|
|
83
76
|
```
|
|
@@ -85,26 +78,46 @@ pnpm approve-builds -g # one-time: allow better-sqlite3 and node-pty to fetch
|
|
|
85
78
|
Verify it landed on your `$PATH`:
|
|
86
79
|
|
|
87
80
|
```bash
|
|
88
|
-
tono --version
|
|
89
|
-
which tono
|
|
81
|
+
tono --version
|
|
82
|
+
which tono # → ~/.local/share/pnpm/tono (or your pnpm bin path)
|
|
90
83
|
```
|
|
91
84
|
|
|
92
85
|
To upgrade later:
|
|
93
86
|
|
|
94
87
|
```bash
|
|
95
|
-
|
|
96
|
-
tono gateway restart
|
|
88
|
+
pnpm add -g tono@latest
|
|
89
|
+
tono gateway restart # pick up the new binary in the background daemon
|
|
97
90
|
```
|
|
98
91
|
|
|
92
|
+
If you have remote workers, upgrade each worker machine the same way and
|
|
93
|
+
restart the worker daemon:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# on each worker machine
|
|
97
|
+
pnpm add -g tono@latest
|
|
98
|
+
tono worker restart
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
DB migrations (gateway-side) run automatically on the next `tono start` /
|
|
102
|
+
`tono gateway restart`. Tono backs up `~/.tono/tono.db` to
|
|
103
|
+
`~/.tono/tono.db.bak.v<N>` before any version-stepped migration runs, so an
|
|
104
|
+
upgrade is safe to roll back: stop the gateway, copy the backup over the
|
|
105
|
+
live db, downgrade `tono`, and start.
|
|
106
|
+
|
|
107
|
+
Workers and gateways must run the **same minor** version of tono (e.g. all
|
|
108
|
+
on `0.3.x`). Patch upgrades within a minor are guaranteed to interop. If a
|
|
109
|
+
worker's minor differs from the gateway's, the gateway logs a warning on
|
|
110
|
+
connect and may fail to dispatch tasks if the protocol shape has drifted.
|
|
111
|
+
|
|
99
112
|
To uninstall:
|
|
100
113
|
|
|
101
114
|
```bash
|
|
102
|
-
tono gateway uninstall
|
|
103
|
-
|
|
104
|
-
rm -rf ~/.tono
|
|
115
|
+
tono gateway uninstall # remove the LaunchAgent first
|
|
116
|
+
pnpm remove -g tono
|
|
117
|
+
rm -rf ~/.tono # optional — drops config, db, worktrees
|
|
105
118
|
```
|
|
106
119
|
|
|
107
|
-
> **About native modules:** tono pulls in `better-sqlite3` and `node-pty`, which need to compile or fetch a prebuilt binary. Both ship prebuilds for macOS arm64/x64
|
|
120
|
+
> **About native modules:** tono pulls in `better-sqlite3` and `node-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 extra `pnpm approve-builds -g` step. If you prefer npm or yarn, the install commands work too — those run install scripts by default and skip the approve-builds step.
|
|
108
121
|
|
|
109
122
|
## Get started
|
|
110
123
|
|
|
@@ -291,10 +304,102 @@ What's next:
|
|
|
291
304
|
|
|
292
305
|
- **Webhook triggers** to drop polling latency from ~60s to sub-second (Tailscale Funnel or smee.io).
|
|
293
306
|
- **Manual review-task trigger** in the UI (today: label the PR).
|
|
294
|
-
- **Distributed workers.** Designed but unbuilt. The gateway becomes a coordinator; workers connect over WebSocket and run agents on their own filesystems. Tailscale required, no app-level auth.
|
|
295
307
|
- **Cost / token tracking.**
|
|
296
308
|
- **Inline review comments** posted by tono itself rather than asking the agent to call `gh api`.
|
|
297
309
|
|
|
310
|
+
## Distributed workers
|
|
311
|
+
|
|
312
|
+
Tono can be split across multiple machines: one **gateway** (always-on box —
|
|
313
|
+
e.g. your Mac mini) plus any number of **workers** (your MacBook, a Windows
|
|
314
|
+
laptop, …) that connect outbound over a tailnet and execute agent runs on
|
|
315
|
+
their own filesystem. Each connected worker adds capacity for the
|
|
316
|
+
`(agent, kind)` partitions it advertises.
|
|
317
|
+
|
|
318
|
+
**Topology.** The gateway owns the SQLite state, GitHub poller, PR watcher,
|
|
319
|
+
scheduler, browser UI, and config. Workers own a local `node-pty` and a
|
|
320
|
+
local `.bare/` clone cache. Tasks flow:
|
|
321
|
+
|
|
322
|
+
```
|
|
323
|
+
poller (gateway) → scheduler picks an eligible worker
|
|
324
|
+
→ task.assign over WS → worker creates worktree, spawns agent
|
|
325
|
+
→ PTY data streams back to gateway → browser UI
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`tono start` continues to work unchanged on a single box: it launches an
|
|
329
|
+
**embedded local worker** in the same process that connects to its own
|
|
330
|
+
gateway over `127.0.0.1`. The embedded worker is fungible with remote ones —
|
|
331
|
+
the scheduler just sees more capacity once a remote worker connects.
|
|
332
|
+
|
|
333
|
+
**Prerequisites (per worker machine):**
|
|
334
|
+
|
|
335
|
+
- [Tailscale](https://tailscale.com) with the worker on the same tailnet as
|
|
336
|
+
the gateway. There is no app-level auth — reachability over the tailnet IS
|
|
337
|
+
the trust boundary. The gateway should bind on its tailnet IP (or
|
|
338
|
+
`0.0.0.0` if the LAN is also trusted).
|
|
339
|
+
- `gh` CLI, authenticated (`gh auth login`). The worker bare-clones repos
|
|
340
|
+
itself; it does not pull git data through the gateway.
|
|
341
|
+
- The agent CLIs you want this worker to run (`claude`, `codex`,
|
|
342
|
+
`opencode`). The worker auto-detects which are on PATH and advertises
|
|
343
|
+
those as its capabilities.
|
|
344
|
+
|
|
345
|
+
**Run a remote worker (foreground):**
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
tono worker run --gateway <gateway-host>:7040
|
|
349
|
+
# advertise specific agent commands and concurrency:
|
|
350
|
+
tono worker run \
|
|
351
|
+
--gateway my-mac-mini:7040 \
|
|
352
|
+
--agent claude-code=claude \
|
|
353
|
+
--concurrency claude-code=3/2
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**Run a remote worker as a background daemon (macOS):**
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
# first time: install + load the LaunchAgent (com.tono.worker)
|
|
360
|
+
tono worker start --gateway my-mac-mini:7040
|
|
361
|
+
|
|
362
|
+
# later: lifecycle commands mirror `tono gateway`
|
|
363
|
+
tono worker stop
|
|
364
|
+
tono worker restart # reload after settings changes
|
|
365
|
+
tono worker status # plist state + connection
|
|
366
|
+
tono worker logs # tail ~/.tono/logs/worker.out.log
|
|
367
|
+
tono worker uninstall # remove the LaunchAgent
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Settings persist in `~/.tono/worker.json` (gateway URL, agent commands,
|
|
371
|
+
concurrency, paths). The first `start` / `run` writes them; later
|
|
372
|
+
invocations don't need flags. Pass new flags to override and re-persist
|
|
373
|
+
(`tono worker restart --concurrency claude-code=4/2` updates the JSON and
|
|
374
|
+
reloads the daemon). The same file holds a stable worker UUID so reconnects
|
|
375
|
+
pick up the same identity.
|
|
376
|
+
|
|
377
|
+
Workers reconnect with exponential backoff; if the gateway is offline the
|
|
378
|
+
worker sits idle until it returns.
|
|
379
|
+
|
|
380
|
+
**Disconnect handling.** When a worker drops mid-run (laptop sleeps, Wi-Fi
|
|
381
|
+
blip), the gateway holds the task as `running` for `workers.graceMs`
|
|
382
|
+
(default 60 000 ms). Reconnect within the window resumes the live stream.
|
|
383
|
+
After the grace expires the task is marked `failed` with `exit_code = -3`
|
|
384
|
+
and you can retry it. The setting lives in `~/.tono/config.json` under
|
|
385
|
+
the optional `workers` block:
|
|
386
|
+
|
|
387
|
+
```json
|
|
388
|
+
{
|
|
389
|
+
"workers": { "graceMs": 60000 }
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
**Caveats (v1):**
|
|
394
|
+
|
|
395
|
+
- No worker affinity / pinning yet — workers are fungible by capability.
|
|
396
|
+
- Worktree cleanup runs on the gateway's filesystem only. For tasks that
|
|
397
|
+
ran on a remote worker, the worktree under that worker's
|
|
398
|
+
`~/.tono/workspaces/` is left in place; clean it up there manually with
|
|
399
|
+
`git worktree remove`.
|
|
400
|
+
- Free-form shells in the UI are gateway-local (they always run in the
|
|
401
|
+
gateway process, not on workers).
|
|
402
|
+
|
|
298
403
|
## License
|
|
299
404
|
|
|
300
405
|
[MIT](./LICENSE).
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, existsSync, statSync } from "node:fs";
|
|
3
|
+
import * as gh from "../server/github/gh.js";
|
|
4
|
+
import { ConfigError, configPath, expandHome, loadConfig, } from "../server/config/load.js";
|
|
5
|
+
import { dbPath, openDb } from "../server/db/client.js";
|
|
6
|
+
import { GATEWAY_LABEL, plistPath } from "./launchd.js";
|
|
7
|
+
/** Run every check in a sensible order, returning a flat list of results. */
|
|
8
|
+
export async function runChecks(opts = {}) {
|
|
9
|
+
const out = [];
|
|
10
|
+
// External tools — always required.
|
|
11
|
+
out.push(await checkGitInstalled());
|
|
12
|
+
out.push(await checkGhInstalled());
|
|
13
|
+
// Config — try to load. If we can't, downstream checks (agents, repos) are skipped.
|
|
14
|
+
const cfgResult = await checkConfig();
|
|
15
|
+
out.push(cfgResult.result);
|
|
16
|
+
const cfg = cfgResult.cfg;
|
|
17
|
+
// gh auth — only meaningful if gh is installed. We still attempt the check
|
|
18
|
+
// (it'll just report the same error) so the user sees a clear "log in" hint.
|
|
19
|
+
out.push(await checkGhAuth());
|
|
20
|
+
if (cfg) {
|
|
21
|
+
out.push(await checkAgentsDeclared(cfg));
|
|
22
|
+
for (const agent of Object.keys(cfg.agents)) {
|
|
23
|
+
out.push(await checkAgentInstalled(cfg, agent));
|
|
24
|
+
}
|
|
25
|
+
out.push(checkWorkspaces(cfg));
|
|
26
|
+
out.push(checkRepoPaths(cfg));
|
|
27
|
+
}
|
|
28
|
+
out.push(checkSchemaVersion());
|
|
29
|
+
if (!opts.skipGateway) {
|
|
30
|
+
out.push(checkGatewayPlist());
|
|
31
|
+
if (cfg)
|
|
32
|
+
out.push(await checkGatewayHealthy(cfg));
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function which(cmd) {
|
|
37
|
+
const r = spawnSync("/usr/bin/env", ["sh", "-c", `command -v ${cmd}`], { encoding: "utf8" });
|
|
38
|
+
if (r.status === 0)
|
|
39
|
+
return r.stdout.trim() || null;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function tryRun(cmd, args, timeoutMs = 5000) {
|
|
43
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: timeoutMs });
|
|
44
|
+
return {
|
|
45
|
+
ok: r.status === 0,
|
|
46
|
+
stdout: (r.stdout ?? "").trim(),
|
|
47
|
+
stderr: (r.stderr ?? "").trim(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// — Individual checks —————————————————————————————————————————————————————
|
|
51
|
+
async function checkGitInstalled() {
|
|
52
|
+
const path = which("git");
|
|
53
|
+
if (!path) {
|
|
54
|
+
return {
|
|
55
|
+
id: "git.installed",
|
|
56
|
+
category: "required",
|
|
57
|
+
status: "fail",
|
|
58
|
+
title: "git",
|
|
59
|
+
detail: "not found on $PATH",
|
|
60
|
+
hint: "install git via Xcode Command Line Tools (`xcode-select --install`) or Homebrew (`brew install git`).",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const v = tryRun("git", ["--version"]);
|
|
64
|
+
return {
|
|
65
|
+
id: "git.installed",
|
|
66
|
+
category: "required",
|
|
67
|
+
status: "ok",
|
|
68
|
+
title: "git",
|
|
69
|
+
detail: v.stdout || path,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function checkGhInstalled() {
|
|
73
|
+
const path = which("gh");
|
|
74
|
+
if (!path) {
|
|
75
|
+
return {
|
|
76
|
+
id: "gh.installed",
|
|
77
|
+
category: "required",
|
|
78
|
+
status: "fail",
|
|
79
|
+
title: "gh (GitHub CLI)",
|
|
80
|
+
detail: "not found on $PATH",
|
|
81
|
+
hint: "install via Homebrew (`brew install gh`) or from https://cli.github.com.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const v = tryRun("gh", ["--version"]);
|
|
85
|
+
// First line of `gh --version` is "gh version X.Y.Z (...)". Keep only that.
|
|
86
|
+
const firstLine = v.stdout.split("\n")[0] ?? path;
|
|
87
|
+
return {
|
|
88
|
+
id: "gh.installed",
|
|
89
|
+
category: "required",
|
|
90
|
+
status: "ok",
|
|
91
|
+
title: "gh (GitHub CLI)",
|
|
92
|
+
detail: firstLine,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function checkGhAuth() {
|
|
96
|
+
if (!which("gh")) {
|
|
97
|
+
return {
|
|
98
|
+
id: "gh.authenticated",
|
|
99
|
+
category: "required",
|
|
100
|
+
status: "fail",
|
|
101
|
+
title: "gh authentication",
|
|
102
|
+
detail: "skipped — gh is not installed",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const r = await gh.authStatus();
|
|
107
|
+
if (r.ok) {
|
|
108
|
+
// `gh auth status` lists each host; keep it concise — first non-empty line.
|
|
109
|
+
const summary = r.detail.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "authenticated";
|
|
110
|
+
return {
|
|
111
|
+
id: "gh.authenticated",
|
|
112
|
+
category: "required",
|
|
113
|
+
status: "ok",
|
|
114
|
+
title: "gh authentication",
|
|
115
|
+
detail: summary.length > 80 ? summary.slice(0, 77) + "…" : summary,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
id: "gh.authenticated",
|
|
120
|
+
category: "required",
|
|
121
|
+
status: "fail",
|
|
122
|
+
title: "gh authentication",
|
|
123
|
+
detail: "not authenticated",
|
|
124
|
+
hint: "run `gh auth login` and grant access to your repos.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return {
|
|
129
|
+
id: "gh.authenticated",
|
|
130
|
+
category: "required",
|
|
131
|
+
status: "fail",
|
|
132
|
+
title: "gh authentication",
|
|
133
|
+
detail: err.message,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function checkConfig() {
|
|
138
|
+
const path = configPath();
|
|
139
|
+
if (!existsSync(path)) {
|
|
140
|
+
return {
|
|
141
|
+
cfg: null,
|
|
142
|
+
result: {
|
|
143
|
+
id: "config.exists",
|
|
144
|
+
category: "required",
|
|
145
|
+
status: "fail",
|
|
146
|
+
title: "config",
|
|
147
|
+
detail: `not found at ${path}`,
|
|
148
|
+
hint: "run `tono init` (or `tono configure`) to create one.",
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const cfg = loadConfig();
|
|
154
|
+
return {
|
|
155
|
+
cfg,
|
|
156
|
+
result: {
|
|
157
|
+
id: "config.exists",
|
|
158
|
+
category: "required",
|
|
159
|
+
status: "ok",
|
|
160
|
+
title: "config",
|
|
161
|
+
detail: `valid · ${path}`,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const message = err instanceof ConfigError ? err.message.split("\n")[0] : err.message;
|
|
167
|
+
return {
|
|
168
|
+
cfg: null,
|
|
169
|
+
result: {
|
|
170
|
+
id: "config.exists",
|
|
171
|
+
category: "required",
|
|
172
|
+
status: "fail",
|
|
173
|
+
title: "config",
|
|
174
|
+
detail: message,
|
|
175
|
+
hint: "fix the config file or re-run `tono configure`.",
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function checkAgentsDeclared(cfg) {
|
|
181
|
+
const declared = Object.keys(cfg.agents);
|
|
182
|
+
if (declared.length === 0) {
|
|
183
|
+
return {
|
|
184
|
+
id: "agents.declared",
|
|
185
|
+
category: "required",
|
|
186
|
+
status: "fail",
|
|
187
|
+
title: "agents declared",
|
|
188
|
+
detail: "no agents declared in config",
|
|
189
|
+
hint: "declare at least one of claude-code, codex, opencode in `agents` (or run `tono configure`).",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
id: "agents.declared",
|
|
194
|
+
category: "required",
|
|
195
|
+
status: "ok",
|
|
196
|
+
title: "agents declared",
|
|
197
|
+
detail: declared.join(", "),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
async function checkAgentInstalled(cfg, agent) {
|
|
201
|
+
const a = cfg.agents[agent];
|
|
202
|
+
if (!a) {
|
|
203
|
+
// Shouldn't happen — checkAgentsDeclared already filters — but defensive.
|
|
204
|
+
return {
|
|
205
|
+
id: `agent.${agent}.installed`,
|
|
206
|
+
category: "required",
|
|
207
|
+
status: "fail",
|
|
208
|
+
title: `agent: ${agent}`,
|
|
209
|
+
detail: "no config block",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const path = which(a.command);
|
|
213
|
+
if (!path) {
|
|
214
|
+
return {
|
|
215
|
+
id: `agent.${agent}.installed`,
|
|
216
|
+
category: "required",
|
|
217
|
+
status: "fail",
|
|
218
|
+
title: `agent: ${agent}`,
|
|
219
|
+
detail: `\`${a.command}\` not found on $PATH`,
|
|
220
|
+
hint: hintForAgent(agent),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const v = tryRun(a.command, ["--version"]);
|
|
224
|
+
const firstLine = v.stdout.split("\n")[0] ?? path;
|
|
225
|
+
return {
|
|
226
|
+
id: `agent.${agent}.installed`,
|
|
227
|
+
category: "required",
|
|
228
|
+
status: "ok",
|
|
229
|
+
title: `agent: ${agent}`,
|
|
230
|
+
detail: firstLine,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function hintForAgent(agent) {
|
|
234
|
+
switch (agent) {
|
|
235
|
+
case "claude-code":
|
|
236
|
+
return "install Claude Code: https://docs.claude.com/en/docs/claude-code/quickstart";
|
|
237
|
+
case "codex":
|
|
238
|
+
return "install Codex CLI: `npm install -g @openai/codex` or https://github.com/openai/codex";
|
|
239
|
+
case "opencode":
|
|
240
|
+
return "install OpenCode: https://opencode.ai";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function checkWorkspaces(cfg) {
|
|
244
|
+
const root = cfg.workspaces.root;
|
|
245
|
+
if (!existsSync(root)) {
|
|
246
|
+
return {
|
|
247
|
+
id: "workspaces.exists",
|
|
248
|
+
category: "required",
|
|
249
|
+
status: "warn",
|
|
250
|
+
title: "workspaces root",
|
|
251
|
+
detail: `${root} does not exist (will be created on first task)`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
accessSync(root, constants.W_OK);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return {
|
|
259
|
+
id: "workspaces.exists",
|
|
260
|
+
category: "required",
|
|
261
|
+
status: "fail",
|
|
262
|
+
title: "workspaces root",
|
|
263
|
+
detail: `${root} is not writable`,
|
|
264
|
+
hint: "fix the directory permissions or change `workspaces.root` in the config.",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
id: "workspaces.exists",
|
|
269
|
+
category: "required",
|
|
270
|
+
status: "ok",
|
|
271
|
+
title: "workspaces root",
|
|
272
|
+
detail: root,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function checkRepoPaths(cfg) {
|
|
276
|
+
const withPaths = cfg.repos.filter((r) => r.path && r.path.trim().length > 0);
|
|
277
|
+
if (withPaths.length === 0) {
|
|
278
|
+
return {
|
|
279
|
+
id: "repos.paths",
|
|
280
|
+
category: "optional",
|
|
281
|
+
status: "ok",
|
|
282
|
+
title: "repo paths",
|
|
283
|
+
detail: cfg.repos.length === 0
|
|
284
|
+
? "no repos configured"
|
|
285
|
+
: `${cfg.repos.length} repo(s); none use a custom path (tono will bare-clone via gh)`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const broken = [];
|
|
289
|
+
for (const r of withPaths) {
|
|
290
|
+
const p = expandHome(r.path);
|
|
291
|
+
if (!existsSync(p)) {
|
|
292
|
+
broken.push(`${r.slug} → ${p} (missing)`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
if (!statSync(p).isDirectory())
|
|
297
|
+
broken.push(`${r.slug} → ${p} (not a directory)`);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
broken.push(`${r.slug} → ${p} (unreadable)`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (broken.length > 0) {
|
|
304
|
+
return {
|
|
305
|
+
id: "repos.paths",
|
|
306
|
+
category: "required",
|
|
307
|
+
status: "fail",
|
|
308
|
+
title: "repo paths",
|
|
309
|
+
detail: broken.join("; "),
|
|
310
|
+
hint: "either fix the path on disk, set `path` to a valid clone, or remove the field to bare-clone via gh.",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
id: "repos.paths",
|
|
315
|
+
category: "required",
|
|
316
|
+
status: "ok",
|
|
317
|
+
title: "repo paths",
|
|
318
|
+
detail: `${withPaths.length}/${cfg.repos.length} repo(s) use a custom path; all valid`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function checkSchemaVersion() {
|
|
322
|
+
const path = dbPath();
|
|
323
|
+
if (!existsSync(path)) {
|
|
324
|
+
return {
|
|
325
|
+
id: "db.schema",
|
|
326
|
+
category: "optional",
|
|
327
|
+
status: "ok",
|
|
328
|
+
title: "database schema",
|
|
329
|
+
detail: "database not yet created (will be on first run)",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const db = openDb(path);
|
|
334
|
+
const row = db.prepare("SELECT version FROM schema_version LIMIT 1").get();
|
|
335
|
+
db.close();
|
|
336
|
+
return {
|
|
337
|
+
id: "db.schema",
|
|
338
|
+
category: "optional",
|
|
339
|
+
status: "ok",
|
|
340
|
+
title: "database schema",
|
|
341
|
+
detail: row ? `v${row.version}` : "uninitialized (will be set on next run)",
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
return {
|
|
346
|
+
id: "db.schema",
|
|
347
|
+
category: "required",
|
|
348
|
+
status: "fail",
|
|
349
|
+
title: "database schema",
|
|
350
|
+
detail: err.message,
|
|
351
|
+
hint: "if the DB is corrupted, remove ~/.tono/tono.db* and re-run; tasks history will be lost.",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function checkGatewayPlist() {
|
|
356
|
+
const file = plistPath();
|
|
357
|
+
const r = spawnSync("launchctl", ["list", GATEWAY_LABEL], { encoding: "utf8" });
|
|
358
|
+
const loaded = r.status === 0;
|
|
359
|
+
if (!existsSync(file) && !loaded) {
|
|
360
|
+
return {
|
|
361
|
+
id: "gateway.plist",
|
|
362
|
+
category: "optional",
|
|
363
|
+
status: "ok",
|
|
364
|
+
title: "gateway",
|
|
365
|
+
detail: "not installed (run `tono gateway start` to install)",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (!existsSync(file)) {
|
|
369
|
+
return {
|
|
370
|
+
id: "gateway.plist",
|
|
371
|
+
category: "optional",
|
|
372
|
+
status: "warn",
|
|
373
|
+
title: "gateway",
|
|
374
|
+
detail: "loaded by launchctl but plist file missing — re-run `tono gateway start` to fix",
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (!loaded) {
|
|
378
|
+
return {
|
|
379
|
+
id: "gateway.plist",
|
|
380
|
+
category: "optional",
|
|
381
|
+
status: "warn",
|
|
382
|
+
title: "gateway",
|
|
383
|
+
detail: "plist exists but is not loaded",
|
|
384
|
+
hint: "run `tono gateway start` (or `restart`) to load it.",
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
id: "gateway.plist",
|
|
389
|
+
category: "optional",
|
|
390
|
+
status: "ok",
|
|
391
|
+
title: "gateway",
|
|
392
|
+
detail: "installed and loaded",
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
async function checkGatewayHealthy(cfg) {
|
|
396
|
+
const host = cfg.server.host === "0.0.0.0" ? "127.0.0.1" : cfg.server.host;
|
|
397
|
+
const url = `http://${host}:${cfg.server.port}/api/health`;
|
|
398
|
+
try {
|
|
399
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
400
|
+
if (!res.ok) {
|
|
401
|
+
return {
|
|
402
|
+
id: "gateway.healthy",
|
|
403
|
+
category: "optional",
|
|
404
|
+
status: "warn",
|
|
405
|
+
title: "gateway HTTP",
|
|
406
|
+
detail: `${url} → HTTP ${res.status}`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const j = (await res.json());
|
|
410
|
+
return {
|
|
411
|
+
id: "gateway.healthy",
|
|
412
|
+
category: "optional",
|
|
413
|
+
status: "ok",
|
|
414
|
+
title: "gateway HTTP",
|
|
415
|
+
detail: `${url} OK${j.version ? ` (v${j.version})` : ""}`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
return {
|
|
420
|
+
id: "gateway.healthy",
|
|
421
|
+
category: "optional",
|
|
422
|
+
status: "warn",
|
|
423
|
+
title: "gateway HTTP",
|
|
424
|
+
detail: `${url} unreachable`,
|
|
425
|
+
hint: (err.name === "AbortError" || err.message.includes("ECONNREFUSED"))
|
|
426
|
+
? "if you expect tono to be running, check `tono gateway status` and `tono gateway logs err`."
|
|
427
|
+
: err.message,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// — Formatting helpers ————————————————————————————————————————————————————
|
|
432
|
+
const COLOR = {
|
|
433
|
+
reset: "\x1b[0m",
|
|
434
|
+
dim: "\x1b[2m",
|
|
435
|
+
bold: "\x1b[1m",
|
|
436
|
+
green: "\x1b[32m",
|
|
437
|
+
yellow: "\x1b[33m",
|
|
438
|
+
red: "\x1b[31m",
|
|
439
|
+
blue: "\x1b[34m",
|
|
440
|
+
};
|
|
441
|
+
function symbolFor(status) {
|
|
442
|
+
switch (status) {
|
|
443
|
+
case "ok":
|
|
444
|
+
return `${COLOR.green}✓${COLOR.reset}`;
|
|
445
|
+
case "warn":
|
|
446
|
+
return `${COLOR.yellow}⚠${COLOR.reset}`;
|
|
447
|
+
case "fail":
|
|
448
|
+
return `${COLOR.red}✗${COLOR.reset}`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function categoryTag(c) {
|
|
452
|
+
return c === "required"
|
|
453
|
+
? `${COLOR.dim}[required]${COLOR.reset}`
|
|
454
|
+
: `${COLOR.dim}[optional]${COLOR.reset}`;
|
|
455
|
+
}
|
|
456
|
+
/** Format a list of check results as a human-readable report (with ANSI colors). */
|
|
457
|
+
export function formatReport(results) {
|
|
458
|
+
const lines = [];
|
|
459
|
+
for (const r of results) {
|
|
460
|
+
lines.push(`${symbolFor(r.status)} ${r.title.padEnd(20)} ${categoryTag(r.category)} ${COLOR.dim}${r.detail}${COLOR.reset}`);
|
|
461
|
+
if (r.status !== "ok" && r.hint) {
|
|
462
|
+
lines.push(` ${COLOR.dim}↳ ${r.hint}${COLOR.reset}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return lines.join("\n");
|
|
466
|
+
}
|
|
467
|
+
export function tally(results) {
|
|
468
|
+
const t = { total: results.length, ok: 0, warnings: 0, failures: 0, requiredFailures: 0 };
|
|
469
|
+
for (const r of results) {
|
|
470
|
+
if (r.status === "ok")
|
|
471
|
+
t.ok++;
|
|
472
|
+
else if (r.status === "warn")
|
|
473
|
+
t.warnings++;
|
|
474
|
+
else {
|
|
475
|
+
t.failures++;
|
|
476
|
+
if (r.category === "required")
|
|
477
|
+
t.requiredFailures++;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return t;
|
|
481
|
+
}
|
|
482
|
+
export function summarize(t) {
|
|
483
|
+
const parts = [`${t.ok} ok`];
|
|
484
|
+
if (t.warnings)
|
|
485
|
+
parts.push(`${COLOR.yellow}${t.warnings} warning${t.warnings === 1 ? "" : "s"}${COLOR.reset}`);
|
|
486
|
+
if (t.failures)
|
|
487
|
+
parts.push(`${COLOR.red}${t.failures} failure${t.failures === 1 ? "" : "s"}${COLOR.reset}`);
|
|
488
|
+
if (t.requiredFailures)
|
|
489
|
+
parts.push(`${COLOR.bold}${COLOR.red}${t.requiredFailures} required${COLOR.reset}`);
|
|
490
|
+
return parts.join(", ");
|
|
491
|
+
}
|
|
492
|
+
/** Hint string for the requirements section that init/doctor both print. */
|
|
493
|
+
export function requirementsBanner() {
|
|
494
|
+
return [
|
|
495
|
+
`${COLOR.bold}Required tools:${COLOR.reset}`,
|
|
496
|
+
` • ${COLOR.bold}gh${COLOR.reset} (GitHub CLI, authenticated)`,
|
|
497
|
+
` • ${COLOR.bold}git${COLOR.reset}`,
|
|
498
|
+
` • At least one of these agent CLIs (configure the ones you have):`,
|
|
499
|
+
` - ${COLOR.bold}claude${COLOR.reset} — Claude Code (https://docs.claude.com/en/docs/claude-code/quickstart)`,
|
|
500
|
+
` - ${COLOR.bold}codex${COLOR.reset} — Codex CLI (\`npm install -g @openai/codex\`)`,
|
|
501
|
+
` - ${COLOR.bold}opencode${COLOR.reset} — OpenCode (https://opencode.ai)`,
|
|
502
|
+
].join("\n");
|
|
503
|
+
}
|
|
504
|
+
//# sourceMappingURL=checks.js.map
|