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.
Files changed (60) hide show
  1. package/README.md +125 -20
  2. package/dist/cli/checks.js +504 -0
  3. package/dist/cli/checks.js.map +1 -0
  4. package/dist/cli/commands/config.js +20 -3
  5. package/dist/cli/commands/config.js.map +1 -1
  6. package/dist/cli/commands/configure.js +1 -1
  7. package/dist/cli/commands/configure.js.map +1 -1
  8. package/dist/cli/commands/doctor.js +22 -0
  9. package/dist/cli/commands/doctor.js.map +1 -0
  10. package/dist/cli/commands/gateway.js +39 -14
  11. package/dist/cli/commands/gateway.js.map +1 -1
  12. package/dist/cli/commands/init.js +22 -3
  13. package/dist/cli/commands/init.js.map +1 -1
  14. package/dist/cli/commands/start.js +69 -19
  15. package/dist/cli/commands/start.js.map +1 -1
  16. package/dist/cli/commands/worker.js +500 -0
  17. package/dist/cli/commands/worker.js.map +1 -0
  18. package/dist/cli/index.js +28 -9
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/cli/launchd.js +5 -5
  21. package/dist/cli/launchd.js.map +1 -1
  22. package/dist/server/app.js +130 -35
  23. package/dist/server/app.js.map +1 -1
  24. package/dist/server/config/load.js +19 -1
  25. package/dist/server/config/load.js.map +1 -1
  26. package/dist/server/config/schema.json +19 -0
  27. package/dist/server/db/client.js +73 -2
  28. package/dist/server/db/client.js.map +1 -1
  29. package/dist/server/db/queries.js +126 -3
  30. package/dist/server/db/queries.js.map +1 -1
  31. package/dist/server/db/schema.sql +36 -21
  32. package/dist/server/events.js.map +1 -1
  33. package/dist/server/server.js +28 -11
  34. package/dist/server/server.js.map +1 -1
  35. package/dist/server/worker/agent.js +441 -0
  36. package/dist/server/worker/agent.js.map +1 -0
  37. package/dist/server/worker/pty-bridge.js +27 -0
  38. package/dist/server/worker/pty-bridge.js.map +1 -0
  39. package/dist/server/workers/github-poller.js +26 -8
  40. package/dist/server/workers/github-poller.js.map +1 -1
  41. package/dist/server/workers/reaper.js +44 -0
  42. package/dist/server/workers/reaper.js.map +1 -0
  43. package/dist/server/workers/registry.js +174 -0
  44. package/dist/server/workers/registry.js.map +1 -0
  45. package/dist/server/workers/scheduler.js +79 -130
  46. package/dist/server/workers/scheduler.js.map +1 -1
  47. package/dist/server/ws/pty.js +71 -54
  48. package/dist/server/ws/pty.js.map +1 -1
  49. package/dist/server/ws/workers.js +241 -0
  50. package/dist/server/ws/workers.js.map +1 -0
  51. package/dist/shared/types.js +4 -1
  52. package/dist/shared/types.js.map +1 -1
  53. package/dist/shared/worker-protocol.js +28 -0
  54. package/dist/shared/worker-protocol.js.map +1 -0
  55. package/dist/web/assets/index-BsiC8WMV.js +129 -0
  56. package/dist/web/assets/index-CjzMqxyT.css +1 -0
  57. package/dist/web/index.html +2 -2
  58. package/package.json +18 -26
  59. package/dist/web/assets/index-5VFn-lxF.js +0 -129
  60. package/dist/web/assets/index-CZHd5NaX.css +0 -1
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # tono
2
2
 
3
- A self-hosted gateway that turns labeled GitHub issues and PRs into running CLI agents.
3
+ Self-hosted orchestrator for CLI agent tools (Claude Code, Codex CLI, OpenCode) triggered by GitHub issue labels.
4
4
 
5
- > *The name is the middle of **au·tono·mous** — fitting for a tool whose whole job is letting agents run unattended.*
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** — `npm install -g @openai/codex` or follow the [Codex CLI repo](https://github.com/openai/codex). Verify `codex --version`.
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 install it globally with whichever package manager you use:
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 # → 0.2.0
89
- which tono # → /usr/local/bin/tono (or your manager's bin path)
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
- npm install -g tono@latest
96
- tono gateway restart # picks up the new binary in the background daemon
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 # remove the LaunchAgent first
103
- npm uninstall -g tono
104
- rm -rf ~/.tono # optional — drops config, db, worktrees
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; npm and yarn run their install scripts automatically. **pnpm v10 disables install scripts by default** for safety, hence the extra `pnpm approve-builds -g` step.
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