@staix/agent-hub 0.12.22 → 0.12.23
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/CHANGELOG.md +19 -0
- package/README.md +16 -8
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/daemon.md +8 -1
- package/docs/agent-notes/local-worker.md +3 -2
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +2 -0
- package/docs/events.md +7 -3
- package/docs/operations.md +232 -26
- package/docs/quickstart.md +9 -4
- package/docs/security.md +15 -6
- package/docs/smoke.md +72 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +77 -10
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verified.json +25 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +14 -3
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +95 -13
- package/src/adapters/codex-appserver.ts +43 -13
- package/src/cli/bench.ts +2 -1
- package/src/cli/console-state.ts +28 -22
- package/src/cli/console.ts +20 -10
- package/src/cli/help.ts +19 -14
- package/src/cli/identity.ts +2 -2
- package/src/cli/init.ts +1 -1
- package/src/cli/main.ts +272 -159
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +0 -28
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +24 -7
- package/src/cli/upgrade.ts +23 -5
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +10 -11
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +23 -11
- package/src/hub/config-trust.ts +16 -5
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +2 -1
- package/src/hub/daemon.ts +338 -23
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +2 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -0
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +5 -4
- package/src/hub/git.ts +32 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/manager.ts +18 -5
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +14 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/routing.ts +114 -13
- package/src/hub/settings.ts +392 -0
- package/src/hub/snapshots.ts +4 -2
- package/src/hub/start-mode.ts +48 -0
- package/src/hub/tasks.ts +129 -31
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +44 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/tool-receipts.ts +2 -1
- package/src/ui/index.html +157 -10
package/src/cli/main.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { isPermissionMode } from "../hub/permission-mode.ts";
|
|
3
|
+
import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
|
|
4
|
+
import { startModeOf } from "../hub/start-mode.ts";
|
|
3
5
|
import { currentRouting } from "../hub/routing.ts";
|
|
4
6
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
7
|
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -35,19 +37,19 @@ import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
|
35
37
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
36
38
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
37
39
|
|
|
38
|
-
import {
|
|
39
|
-
import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
40
|
+
import { contextLine, type PeerRow } from "./status-lines.ts";
|
|
40
41
|
import { parseSince, readEvents } from "../hub/events.ts";
|
|
41
42
|
import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, readRuns, runSummary, sameSuite } from "../hub/bench.ts";
|
|
42
43
|
import { benchPreflight, defaultIo, runBench } from "./bench.ts";
|
|
43
44
|
import { appendRecords, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
|
|
44
|
-
import {
|
|
45
|
+
import { summarize, summarizeByTask } from "../hub/report.ts";
|
|
45
46
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
46
47
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
47
48
|
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
|
|
48
49
|
import { recordCliAudit } from "./identity-audit.ts";
|
|
49
50
|
import { runConsole } from "./console.ts";
|
|
50
|
-
import { permissionBoundary, resolveColor } from "./console-state.ts";
|
|
51
|
+
import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
|
|
52
|
+
import { outputWidth, renderStatus, renderBoard, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
|
|
51
53
|
import { renderHelp } from "./help.ts";
|
|
52
54
|
import { renderTailEvent } from "./tail-render.ts";
|
|
53
55
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
@@ -107,6 +109,22 @@ if (identity.role === "tools" && commandAccess === "console") { audit("refused")
|
|
|
107
109
|
if (identity.role === "tools") audit("run");
|
|
108
110
|
const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
|
|
109
111
|
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
112
|
+
/** One-shot output flags do not alter the fetched JSON document. */
|
|
113
|
+
function outputOptions() {
|
|
114
|
+
const flags = args.filter(arg => arg.startsWith("--color="));
|
|
115
|
+
if (flags.length > 1 || args.includes("--color")) fail(`usage: ahub ${cmd} [--color=auto|always|never] [--json] [--full]`);
|
|
116
|
+
const color = resolveColor(flags[0]?.slice("--color=".length), { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
|
|
117
|
+
if (color instanceof Error) fail(`usage: ahub ${cmd} [--color=auto|always|never] [--json] [--full]`);
|
|
118
|
+
return { color: color === true, json: args.includes("--json"), full: args.includes("--full"),
|
|
119
|
+
columns: outputWidth({ isTTY: !!process.stdout.isTTY, columns: process.stdout.columns, COLUMNS: process.env.COLUMNS }) };
|
|
120
|
+
}
|
|
121
|
+
/** Remove only presentation flags from read-only command positional arguments. */
|
|
122
|
+
function outputArguments(): string[] {
|
|
123
|
+
return args.filter(arg => arg !== "--json" && arg !== "--full" && !arg.startsWith("--color="));
|
|
124
|
+
}
|
|
125
|
+
function printOutput(lines: Span[][], color: boolean): void {
|
|
126
|
+
for (const line of lines) console.log(paint(line, color));
|
|
127
|
+
}
|
|
110
128
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
111
129
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
112
130
|
const connect = () => ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd } : { role: "console", projectRoot: cwd });
|
|
@@ -174,12 +192,18 @@ function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): nev
|
|
|
174
192
|
process.exit(res.status ?? 1);
|
|
175
193
|
}
|
|
176
194
|
|
|
177
|
-
|
|
195
|
+
/** `mode` is absent when no flag chose one: the caller takes the configured start mode (#269). `--headless` is `--mode headless`. */
|
|
196
|
+
function piFlags(): { mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; model?: string; sessionId?: string; sessionFile?: string } {
|
|
197
|
+
const usage = "usage: ahub pi [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id> | --session-file <path>]";
|
|
198
|
+
if (args.includes("--headless")) {
|
|
199
|
+
if (args.includes("--mode")) fail(usage);
|
|
200
|
+
args.splice(args.indexOf("--headless"), 1, "--mode", "headless");
|
|
201
|
+
}
|
|
178
202
|
for (const flag of ["--mode", "--backend", "--model", "--session-id", "--session-file"]) {
|
|
179
203
|
const index = args.indexOf(flag);
|
|
180
204
|
if (index >= 0 && (!args[index + 1] || args[index + 1]!.startsWith("--"))) fail(`${flag} needs a value`);
|
|
181
205
|
}
|
|
182
|
-
const mode = (args.includes("--mode") ? args[args.indexOf("--mode") + 1] :
|
|
206
|
+
const mode = (args.includes("--mode") ? args[args.indexOf("--mode") + 1] : undefined) as string | undefined;
|
|
183
207
|
const backend = (args.includes("--backend") ? args[args.indexOf("--backend") + 1] : undefined) as string | undefined;
|
|
184
208
|
const model = args.includes("--model") ? args[args.indexOf("--model") + 1] : undefined;
|
|
185
209
|
const sessionId = args.includes("--session-id") ? args[args.indexOf("--session-id") + 1] : undefined;
|
|
@@ -189,8 +213,8 @@ function piFlags(): { mode: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"
|
|
|
189
213
|
if (!valueFlags.has(args[i]!)) fail(`unknown Pi option: ${args[i]}`);
|
|
190
214
|
i++;
|
|
191
215
|
}
|
|
192
|
-
if (!["headless", "tui"].includes(mode) || (backend !== undefined && !["auto", "dgx", "mlx"].includes(backend)) || (sessionId && sessionFile)) fail(
|
|
193
|
-
return { mode: mode as "headless" | "tui", ...(backend ? { backend: backend as "auto" | "dgx" | "mlx" } : {}), ...(model ? { model } : {}), ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}) };
|
|
216
|
+
if ((mode !== undefined && !["headless", "tui"].includes(mode)) || (backend !== undefined && !["auto", "dgx", "mlx"].includes(backend)) || (sessionId && sessionFile)) fail(usage);
|
|
217
|
+
return { ...(mode ? { mode: mode as "headless" | "tui" } : {}), ...(backend ? { backend: backend as "auto" | "dgx" | "mlx" } : {}), ...(model ? { model } : {}), ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}) };
|
|
194
218
|
}
|
|
195
219
|
|
|
196
220
|
async function projectRows() {
|
|
@@ -199,15 +223,10 @@ async function projectRows() {
|
|
|
199
223
|
finally { registry.close(); }
|
|
200
224
|
}
|
|
201
225
|
|
|
202
|
-
async function printProjects(
|
|
203
|
-
const
|
|
204
|
-
if (json) return console.log(JSON.stringify(
|
|
205
|
-
|
|
206
|
-
console.log(`${row.id} ${row.state.padEnd(12)} ${row.root}`);
|
|
207
|
-
if (row.status) console.log(` peers ${Object.keys(row.status.peers ?? {}).length}, control ${row.status.controlPort}, tasks ${JSON.stringify(row.status.tasks ?? {})}`);
|
|
208
|
-
if (row.error) console.log(` ${row.error}`);
|
|
209
|
-
}
|
|
210
|
-
if (!rows.length) console.log("No registered projects. Run ahub init in a project directory.");
|
|
226
|
+
async function printProjects(options: ReturnType<typeof outputOptions>) {
|
|
227
|
+
const projects = await projectRows();
|
|
228
|
+
if (options.json) return console.log(JSON.stringify(projects, null, 2));
|
|
229
|
+
printOutput(renderProjects(projects, options.columns, Date.now()), options.color);
|
|
211
230
|
}
|
|
212
231
|
|
|
213
232
|
/** Full command line of a live process, undefined once it is gone. */
|
|
@@ -250,43 +269,40 @@ function orphanPids(project: Project): number[] {
|
|
|
250
269
|
|
|
251
270
|
const processGone = (pid: number): boolean => !processCommandLine(pid);
|
|
252
271
|
|
|
253
|
-
async function killOrphan(pid: number, root: string): Promise<boolean> {
|
|
272
|
+
async function killOrphan(pid: number, root: string, diagnostic: (text: string) => void = text => console.log(text)): Promise<boolean> {
|
|
254
273
|
if (!hubDaemonCommand(pid, root)) {
|
|
255
|
-
|
|
274
|
+
diagnostic(` pid ${pid}: command line is not this project's hub daemon; refusing to kill`);
|
|
256
275
|
return false;
|
|
257
276
|
}
|
|
258
277
|
try { process.kill(pid, "SIGTERM"); } catch { return true; } // exited meanwhile
|
|
259
278
|
const graceful = Date.now() + 3_000;
|
|
260
279
|
while (Date.now() < graceful && !processGone(pid)) await Bun.sleep(100);
|
|
261
|
-
if (processGone(pid)) {
|
|
280
|
+
if (processGone(pid)) { diagnostic(` pid ${pid}: stopped with SIGTERM`); return true; }
|
|
262
281
|
// The PID may have been reused since SIGTERM; verify identity again before escalating.
|
|
263
282
|
if (!hubDaemonCommand(pid, root)) {
|
|
264
|
-
|
|
283
|
+
diagnostic(` pid ${pid}: identity changed after SIGTERM; refusing SIGKILL`);
|
|
265
284
|
return false;
|
|
266
285
|
}
|
|
267
286
|
try { process.kill(pid, "SIGKILL"); } catch { return true; }
|
|
268
287
|
const hard = Date.now() + 2_000;
|
|
269
288
|
while (Date.now() < hard && !processGone(pid)) await Bun.sleep(100);
|
|
270
289
|
if (!processGone(pid)) {
|
|
271
|
-
|
|
290
|
+
diagnostic(` pid ${pid}: still alive after SIGKILL`);
|
|
272
291
|
return false;
|
|
273
292
|
}
|
|
274
|
-
|
|
293
|
+
diagnostic(` pid ${pid}: killed with SIGKILL`);
|
|
275
294
|
return true;
|
|
276
295
|
}
|
|
277
296
|
|
|
278
|
-
async function orphanDoctor(kill: boolean): Promise<void> {
|
|
279
|
-
const orphans = registeredProjects().filter(
|
|
280
|
-
if (
|
|
281
|
-
|
|
297
|
+
async function orphanDoctor(kill: boolean, options: ReturnType<typeof outputOptions>): Promise<void> {
|
|
298
|
+
const orphans = registeredProjects().filter(project => !existsSync(project.root)).map(project => ({ project, pids: orphanPids(project) }));
|
|
299
|
+
if (options.json) console.log(JSON.stringify(orphans, null, 2));
|
|
300
|
+
else printOutput(renderOrphans(orphans, options.columns, Date.now(), kill), options.color);
|
|
282
301
|
let failed = 0;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
for (const pid of pids) if (!(await killOrphan(pid, project.root))) failed++;
|
|
288
|
-
}
|
|
289
|
-
if (!kill) console.log("kill live orphans with `ahub doctor --orphans --kill`, then forget each row with `ahub projects remove <id>`");
|
|
302
|
+
const diagnostic = (text: string) => options.json
|
|
303
|
+
? console.error(paint([{ text }], false))
|
|
304
|
+
: printOutput(wrap(text, options.columns ?? Infinity).map(text => [{ text }]), options.color);
|
|
305
|
+
if (kill) for (const { project, pids } of orphans) for (const pid of pids) if (!(await killOrphan(pid, project.root, diagnostic))) failed++;
|
|
290
306
|
if (failed) fail(`${failed} orphaned hub process(es) could not be stopped`);
|
|
291
307
|
}
|
|
292
308
|
|
|
@@ -295,12 +311,13 @@ function fail(message: string): never {
|
|
|
295
311
|
process.exit(1);
|
|
296
312
|
}
|
|
297
313
|
|
|
298
|
-
async function taskOp(op: string, a: Record<string, unknown
|
|
314
|
+
async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false): Promise<string> {
|
|
299
315
|
const hub = await connect();
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
316
|
+
try {
|
|
317
|
+
const res = await hub.request({ t: "task", op, args: a });
|
|
318
|
+
if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
|
|
319
|
+
return res.text;
|
|
320
|
+
} finally { hub.close(); }
|
|
304
321
|
}
|
|
305
322
|
|
|
306
323
|
/** Reads hub.db's turn records; `none` when the hub never kept one (snapshots off, no git work tree, an older hub). */
|
|
@@ -538,12 +555,16 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
538
555
|
console.log("registration removed; project files were kept");
|
|
539
556
|
return;
|
|
540
557
|
}
|
|
541
|
-
|
|
542
|
-
|
|
558
|
+
const options = outputOptions();
|
|
559
|
+
if (outputArguments().length) fail("usage: ahub projects [--json] [--full] [--color=auto|always|never]");
|
|
560
|
+
await printProjects(options);
|
|
543
561
|
},
|
|
544
562
|
|
|
545
563
|
ui: async () => {
|
|
546
|
-
if (args.some((arg) => !["--no-open", "--all", "--stop"].includes(arg))) fail("usage: ahub ui [--
|
|
564
|
+
if (args.some((arg) => !["--no-open", "--all", "--stop", "--settings"].includes(arg))) fail("usage: ahub ui [--settings] [--no-open] | --all [--no-open] | --all --stop");
|
|
565
|
+
// #269: a settings session belongs to one project's hub; the unified dashboard stays an ordinary session.
|
|
566
|
+
const settings = args.includes("--settings");
|
|
567
|
+
if (settings && args.includes("--all")) fail("ahub ui --settings opens one project's dashboard; run it without --all");
|
|
547
568
|
if (args.includes("--stop")) {
|
|
548
569
|
if (!args.includes("--all") || args.includes("--no-open")) fail("usage: ahub ui --all --stop");
|
|
549
570
|
await stopManager();
|
|
@@ -554,11 +575,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
554
575
|
else {
|
|
555
576
|
const hub = await connect();
|
|
556
577
|
try {
|
|
557
|
-
const res = await hub.request({ t: "ui" }, 10_000);
|
|
578
|
+
const res = await hub.request({ t: "ui", ...(settings ? { settings: true } : {}) }, 10_000);
|
|
558
579
|
if (!res.ok) fail(res.error);
|
|
580
|
+
// A hub older than #269 ignores the field and would hand out an ordinary session without saying so.
|
|
581
|
+
if (settings && res.settings !== true) fail("this hub does not open settings sessions; upgrade the running hub to use ahub ui --settings");
|
|
559
582
|
url = res.url;
|
|
560
583
|
} finally { hub.close(); }
|
|
561
584
|
}
|
|
585
|
+
if (settings) console.error("Settings session: for 15 minutes this dashboard session may change permission modes, routing and start settings.");
|
|
562
586
|
if (args.includes("--no-open")) return console.log(url);
|
|
563
587
|
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
564
588
|
const opened = spawnSync(opener, [url], { stdio: "ignore", timeout: 10_000 });
|
|
@@ -621,7 +645,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
621
645
|
console: async () => {
|
|
622
646
|
if (args.some(arg => arg !== "--panels" && !arg.startsWith("--color=")) || args.filter(arg => arg.startsWith("--color=")).length > 1) fail("usage: ahub console [--panels] [--color=auto|always|never]");
|
|
623
647
|
const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice("--color=".length), { isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
|
|
624
|
-
if (color instanceof Error) return fail(color
|
|
648
|
+
if (color instanceof Error) return fail("usage: ahub console [--panels] [--color=auto|always|never]");
|
|
625
649
|
await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), color });
|
|
626
650
|
},
|
|
627
651
|
|
|
@@ -718,11 +742,30 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
718
742
|
},
|
|
719
743
|
|
|
720
744
|
pi: async () => {
|
|
745
|
+
// --headless is --mode headless everywhere this command goes: the preview, the hub and the conductor path.
|
|
746
|
+
if (args.includes("--headless") && !args.includes("--mode")) args.splice(args.indexOf("--headless"), 1, "--mode", "headless");
|
|
721
747
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
722
748
|
try { return console.log(JSON.stringify(launcherPreview("pi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
723
749
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
724
750
|
}
|
|
725
|
-
const
|
|
751
|
+
const flags = piFlags();
|
|
752
|
+
// #269: with no flag, Pi's start mode decides, and that is its TUI unless a person set headless.
|
|
753
|
+
const options = { ...flags, mode: flags.mode ?? startModeOf(projectConfig().peers, "pi") };
|
|
754
|
+
if (flags.mode === undefined && options.mode === "tui" && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
755
|
+
// The default asked for the TUI and there is no terminal here (the console's command line, a script): the hub
|
|
756
|
+
// opens one, or says how to. It is never downgraded to headless. The fixed command carries no option, so one
|
|
757
|
+
// that was given needs a terminal of its own. An explicit --mode tui is taken at its word, as before.
|
|
758
|
+
if (Object.keys(flags).length) fail("Pi's TUI needs a terminal, and these options are not passed to one the hub opens: run this in a terminal, or add --mode headless");
|
|
759
|
+
const hub = await connect();
|
|
760
|
+
try {
|
|
761
|
+
const reply = await hub.request({ t: "peer_start", peer: "pi" }, 35_000);
|
|
762
|
+
if (reply.ok !== true) {
|
|
763
|
+
const error = String(reply.error ?? "pi start refused");
|
|
764
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_start"/i.test(error) ? `${error}; upgrade the running hub, or run ahub pi --mode headless` : error);
|
|
765
|
+
}
|
|
766
|
+
return console.log(reply.already ? "pi is already attached" : reply.opened ? `pi: opened a terminal running ${reply.command}; it attaches when its TUI is ready` : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
|
|
767
|
+
} finally { hub.close(); }
|
|
768
|
+
}
|
|
726
769
|
// #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
|
|
727
770
|
// trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
|
|
728
771
|
const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
|
|
@@ -758,17 +801,19 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
758
801
|
},
|
|
759
802
|
|
|
760
803
|
models: async () => {
|
|
761
|
-
const action =
|
|
804
|
+
const action = outputArguments()[0] ?? "status";
|
|
805
|
+
const options = action === "status" ? outputOptions() : undefined;
|
|
806
|
+
const showStatus = (data: unknown) => options!.json ? console.log(JSON.stringify(data, null, 2)) : printOutput(renderModelsStatus(data, options!.columns, Date.now()), options!.color);
|
|
762
807
|
const configured = projectConfig().mlx;
|
|
763
808
|
if (configured.enabled === false) {
|
|
764
|
-
if (action === "status") return
|
|
809
|
+
if (action === "status") return showStatus({ state: "disabled", enabled: false });
|
|
765
810
|
if (["setup", "start", "stop"].includes(action)) fail("MLX is disabled by mlx.enabled=false; models commands do not manage shared Ollama");
|
|
766
811
|
fail("usage: ahub models setup|status|start|stop");
|
|
767
812
|
}
|
|
768
813
|
const runtimeDir = configured.runtimeDir ? resolve(cwd, configured.runtimeDir) : join(homedir(), ".agenthub", "runtimes", "mlx");
|
|
769
814
|
const modelPath = configured.modelPath ? resolve(cwd, configured.modelPath) : join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
|
|
770
815
|
const mlxOptions = configured.provider === "ollama" ? configured : { ...configured, runtimeDir, modelPath };
|
|
771
|
-
if (action === "status") return
|
|
816
|
+
if (action === "status") return showStatus(await inspectMlx(mlxOptions));
|
|
772
817
|
if (action === "start") { const handle = await ensureMlx(mlxOptions); return console.log(JSON.stringify(handle.status(), null, 2)); }
|
|
773
818
|
if (action === "stop") { await stopMlx(mlxOptions); return console.log("MLX stopped"); }
|
|
774
819
|
if (action !== "setup") fail("usage: ahub models setup|status|start|stop");
|
|
@@ -798,17 +843,17 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
798
843
|
},
|
|
799
844
|
|
|
800
845
|
queue: async () => {
|
|
801
|
-
const
|
|
846
|
+
const options = outputOptions();
|
|
847
|
+
const [operation = "list", ...rest] = outputArguments();
|
|
802
848
|
const hub = await connect();
|
|
803
849
|
try {
|
|
804
850
|
if (operation === "list") {
|
|
805
851
|
const { one, rest: flags } = takeFlags(rest, ["--peer"], []);
|
|
806
|
-
if (flags.
|
|
852
|
+
if (flags.length) fail("usage: ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]");
|
|
807
853
|
const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}) });
|
|
808
854
|
if (!result.ok) fail(result.error);
|
|
809
|
-
if (
|
|
810
|
-
|
|
811
|
-
if (!result.deliveries.length) console.log("no retained deliveries");
|
|
855
|
+
if (options.json) return console.log(JSON.stringify(result.deliveries, null, 2));
|
|
856
|
+
printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
|
|
812
857
|
return;
|
|
813
858
|
}
|
|
814
859
|
const [id, ...flags] = rest;
|
|
@@ -817,9 +862,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
817
862
|
if (!shown.ok) fail(shown.error);
|
|
818
863
|
if (operation === "show") {
|
|
819
864
|
if (flags.length) fail("usage: ahub queue show <id>");
|
|
820
|
-
return console.log(JSON.stringify(shown.delivery, null, 2));
|
|
865
|
+
if (options.json) return console.log(JSON.stringify(shown.delivery, null, 2));
|
|
866
|
+
return printOutput(renderQueueShow(shown.delivery, options.columns, Date.now()), options.color);
|
|
821
867
|
}
|
|
822
|
-
const { one, rest: extra } = takeFlags(
|
|
868
|
+
const { one, rest: extra } = takeFlags(args.slice(2), ["--action", "--reason"], []);
|
|
823
869
|
if (extra.length || !["completed", "retry", "discard"].includes(one["--action"] ?? "") || !one["--reason"]?.trim()) fail("usage: ahub queue resolve <id> --action completed|retry|discard --reason <text>");
|
|
824
870
|
const result = await hub.request({ t: "queue", op: "resolve", id, revision: shown.delivery.revision, action: one["--action"], reason: one["--reason"] });
|
|
825
871
|
if (!result.ok) fail(result.error);
|
|
@@ -844,18 +890,21 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
844
890
|
},
|
|
845
891
|
|
|
846
892
|
budget: async () => {
|
|
893
|
+
const options = outputOptions();
|
|
847
894
|
const hub = await connect();
|
|
848
|
-
if (
|
|
849
|
-
const op =
|
|
850
|
-
let request: Record<string, unknown> = { t: "execution_budget", op, ...(
|
|
895
|
+
if (outputArguments()[0] === "execution") {
|
|
896
|
+
const op = outputArguments()[1] ?? "status";
|
|
897
|
+
let request: Record<string, unknown> = { t: "execution_budget", op, ...(outputArguments()[2] ? { id: outputArguments()[2] } : {}) };
|
|
851
898
|
if (op === "configure") {
|
|
852
|
-
if (!
|
|
853
|
-
try { request = { t: "execution_budget", op, config: JSON.parse(readFileSync(
|
|
899
|
+
if (!outputArguments()[2]) { hub.close(); fail("usage: ahub budget execution configure <config.json>"); }
|
|
900
|
+
try { request = { t: "execution_budget", op, config: JSON.parse(readFileSync(outputArguments()[2]!, "utf8")) }; }
|
|
854
901
|
catch { hub.close(); fail("cannot read execution budget JSON configuration"); }
|
|
855
902
|
}
|
|
856
903
|
const result = await hub.request(request); hub.close();
|
|
857
904
|
if (!result.ok) fail(result.error);
|
|
858
|
-
|
|
905
|
+
const data = result.budgets ?? result.budget ?? { disabled: result.disabled };
|
|
906
|
+
if (op !== "status" || options.json) return console.log(JSON.stringify(data, null, 2));
|
|
907
|
+
return printOutput(renderExecutionBudgetStatus(result.budgets, options.columns, Date.now()), options.color);
|
|
859
908
|
}
|
|
860
909
|
let set: Record<string, unknown> | undefined;
|
|
861
910
|
if (args[0] === "resume") {
|
|
@@ -876,21 +925,26 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
876
925
|
const res = await hub.request({ t: "budget", ...(set ? { set } : {}) });
|
|
877
926
|
hub.close();
|
|
878
927
|
if (!res.ok) fail(res.error);
|
|
879
|
-
const
|
|
880
|
-
if (
|
|
881
|
-
|
|
882
|
-
for (const [peer, b] of peers) {
|
|
883
|
-
console.log(`${peer}${b.paused ? ` PAUSED: ${b.paused.reason}, resumes in ${left(b.paused.resetsAt)}` : ""}`);
|
|
884
|
-
for (const w of b.windows) console.log(` ${String(w.id).padEnd(7)} ${String(Math.round(w.used * 100)).padStart(3)}%${w.resetsAt ? ` resets in ${left(w.resetsAt)}` : ""} [${w.source}, ${Math.round((Date.now() - w.at) / 1000)}s ago${w.stale ? ", STALE" : ""}]`);
|
|
885
|
-
}
|
|
928
|
+
const data = { budget: res.budget, gate: res.gate };
|
|
929
|
+
if (options.json) return console.log(JSON.stringify(data, null, 2));
|
|
930
|
+
printOutput(renderBudget(data, options.columns, Date.now(), options.full), options.color);
|
|
886
931
|
},
|
|
887
932
|
|
|
888
933
|
board: async () => {
|
|
934
|
+
const options = outputOptions();
|
|
889
935
|
const ready = args.includes("--ready");
|
|
890
936
|
const state = args.find((a) => !a.startsWith("--"));
|
|
891
937
|
const tasks = JSON.parse(await taskOp("hub_task_list", ready ? { ready: true } : state ? { state } : {})) as any[];
|
|
892
|
-
if (
|
|
893
|
-
|
|
938
|
+
if (options.json) return console.log(JSON.stringify(tasks, null, 2));
|
|
939
|
+
let allTasks: any[] | null = tasks;
|
|
940
|
+
if (ready || state) {
|
|
941
|
+
try { allTasks = JSON.parse(await taskOp("hub_task_list", {}, true)) as any[]; }
|
|
942
|
+
catch (error) {
|
|
943
|
+
allTasks = null;
|
|
944
|
+
console.error(paint([{ text: `ahub: board dependency stages unavailable; showing filtered rows without stages: ${String((error as Error).message).replace(/[\n\t]/g, " ")}` }], false));
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
printOutput(renderBoard(tasks, options.columns, Date.now(), options.full, allTasks), options.color);
|
|
894
948
|
},
|
|
895
949
|
|
|
896
950
|
task: async () => {
|
|
@@ -990,6 +1044,63 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
990
1044
|
} finally { hub.close(); }
|
|
991
1045
|
},
|
|
992
1046
|
|
|
1047
|
+
// #269: the terminal's side of the settings registry. Everything the dashboard's Settings section can do, and the
|
|
1048
|
+
// same refusals; it talks to the running hub, so a setting is validated and announced in one place.
|
|
1049
|
+
// ponytail: needs a running hub, also for a value read only at hub start; a file-only path if that gets in the way.
|
|
1050
|
+
settings: async () => {
|
|
1051
|
+
const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo";
|
|
1052
|
+
const flags = args.filter((arg) => arg.startsWith("--")), [sub = "list", key, text, ...extra] = args.filter((arg) => !arg.startsWith("--"));
|
|
1053
|
+
const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview"], undo: [] };
|
|
1054
|
+
if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
|
|
1055
|
+
if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo") && key !== undefined)) fail(usage);
|
|
1056
|
+
const def = key === undefined ? undefined : settingDef(key);
|
|
1057
|
+
if (typeof def === "string") fail(`${key}: ${def}`);
|
|
1058
|
+
const value = sub === "set" ? parseSettingText(def!, text!) : undefined;
|
|
1059
|
+
const peer = def?.group === "Permissions" ? def.path.at(-1)! : undefined;
|
|
1060
|
+
if (sub === "set" && value === "never-ask" && !flags.includes("--preview")) {
|
|
1061
|
+
if (!flags.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
|
|
1062
|
+
console.error(`never-ask: ${permissionBoundary(peer!)}; approval prompts are disabled`);
|
|
1063
|
+
}
|
|
1064
|
+
const hub = await connect();
|
|
1065
|
+
try {
|
|
1066
|
+
const request = async (message: Record<string, unknown>) => {
|
|
1067
|
+
const reply = await hub.request(message, 35_000);
|
|
1068
|
+
if (reply.ok !== true) {
|
|
1069
|
+
const error = String(reply.error ?? "settings request refused");
|
|
1070
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "settings_/i.test(error) ? `${error}; upgrade the running hub to use ahub settings` : error);
|
|
1071
|
+
}
|
|
1072
|
+
return reply;
|
|
1073
|
+
};
|
|
1074
|
+
if (sub === "undo") return console.log((await request({ t: "settings_undo" })).text);
|
|
1075
|
+
if (sub === "set" && flags.includes("--preview")) {
|
|
1076
|
+
const preview = await request({ t: "settings_preview", key, value });
|
|
1077
|
+
for (const line of preview.lines ?? []) console.log(line);
|
|
1078
|
+
// Each side is `route explain`: the task line, "if it were assigned now:", then the trace.
|
|
1079
|
+
for (const task of preview.tasks ?? []) console.log([task.before[0], " before:", ...task.before.slice(2).map((line: string) => ` ${line}`), " after:", ...task.after.slice(2).map((line: string) => ` ${line}`)].join("\n"));
|
|
1080
|
+
if (preview.hidden) console.log(`${preview.hidden} more open tasks are not shown`);
|
|
1081
|
+
return console.log("preview only; nothing was changed");
|
|
1082
|
+
}
|
|
1083
|
+
if (sub === "set") {
|
|
1084
|
+
const reply = await request({ t: "settings_set", key, value, ...(flags.includes("--yes") && peer ? { confirm: peer } : {}) });
|
|
1085
|
+
console.log(reply.text);
|
|
1086
|
+
if (typeof reply.note === "string" && reply.note) console.log(reply.note);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
const view = await request({ t: "settings_get" });
|
|
1090
|
+
const rows = (view.rows as SettingRow[]).filter((row) => sub === "list" || row.key === key);
|
|
1091
|
+
if (flags.includes("--json")) return console.log(JSON.stringify(sub === "get" ? rows[0] : { rows, ...(view.undo ? { undo: view.undo } : {}) }, null, 2));
|
|
1092
|
+
const width = Math.max(...rows.map((row) => row.key.length)), valueWidth = Math.max(...rows.map((row) => settingText(row.value).length));
|
|
1093
|
+
let group = "";
|
|
1094
|
+
for (const row of rows) {
|
|
1095
|
+
if (sub === "list" && row.group !== group) console.log(`${group ? "\n" : ""}${group = row.group}`);
|
|
1096
|
+
const facts = [`from ${row.source}`, row.applies === "live" ? "applies at once" : row.applies === "peer start" ? "applies at the peer's next start" : "read at hub start", ...(row.pending !== undefined ? [`${settingText(row.pending)} at the next hub start`] : []), ...(row.note ? [row.note] : [])];
|
|
1097
|
+
console.log(` ${row.key.padEnd(width)} ${settingText(row.value).padEnd(valueWidth)} ${facts.join("; ")}`);
|
|
1098
|
+
if (sub === "get") console.log(` ${row.label}; written to ${row.file}; takes ${row.type === "boolean" ? "true, false" : row.type === "peers" ? "a comma list of peer ids" : (row.values ?? []).join(", ")}${row.file === "this hub run" ? "" : ", or inherit to remove the machine-local value"}${row.risk === "raises" ? "; a dashboard needs a settings session (ahub ui --settings) to raise it" : ""}`);
|
|
1099
|
+
}
|
|
1100
|
+
if (sub === "list" && view.undo) console.log(`\nahub settings undo puts back the last write (${view.undo})`);
|
|
1101
|
+
} finally { hub.close(); }
|
|
1102
|
+
},
|
|
1103
|
+
|
|
993
1104
|
permit: async () => {
|
|
994
1105
|
const [id, option] = args;
|
|
995
1106
|
if (!id || !option) fail("usage: ahub permit <id> <option|deny>");
|
|
@@ -1000,23 +1111,15 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1000
1111
|
},
|
|
1001
1112
|
|
|
1002
1113
|
status: async () => {
|
|
1003
|
-
|
|
1114
|
+
const options = outputOptions();
|
|
1115
|
+
if (args.includes("--all")) return printProjects(options);
|
|
1004
1116
|
const hub = await connect();
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
|
|
1012
|
-
const peers = Object.entries(status.peers as Record<string, PeerRow>);
|
|
1013
|
-
for (const [id, p] of peers) console.log(peerLine(id, { ...p, context: p.context ?? unknownContext() }));
|
|
1014
|
-
const models = (status as any).models?.backends as BackendRow[] | undefined;
|
|
1015
|
-
if (models?.length) for (const backend of models) console.log(backendLine(backend));
|
|
1016
|
-
if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
|
|
1017
|
-
const counts = Object.entries(status.tasks ?? {}).map(([s, n]) => `${n} ${s}`).join(", ");
|
|
1018
|
-
if (counts) console.log(` tasks: ${counts} (ahub board)`);
|
|
1019
|
-
if (!peers.length) console.log(" no peers attached yet (ahub claude | ahub codex | ahub kimi)");
|
|
1117
|
+
try {
|
|
1118
|
+
const { status } = await hub.request({ t: "status" });
|
|
1119
|
+
if (options.json) return console.log(JSON.stringify(status, null, 2));
|
|
1120
|
+
const quota = await hub.request({ t: "budget" });
|
|
1121
|
+
printOutput(renderStatus({ ...status, budget: quota.budget ?? {} }, options.columns, Date.now(), options.full), options.color);
|
|
1122
|
+
} finally { hub.close(); }
|
|
1020
1123
|
},
|
|
1021
1124
|
|
|
1022
1125
|
logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
|
|
@@ -1107,15 +1210,13 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1107
1210
|
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
|
|
1108
1211
|
},
|
|
1109
1212
|
report: () => {
|
|
1213
|
+
const options = outputOptions();
|
|
1110
1214
|
const by = args.indexOf("--by");
|
|
1111
1215
|
if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
|
|
1112
1216
|
const events = readEvents(join(stateDir, "events.jsonl"), since());
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
}
|
|
1117
|
-
const r = summarize(events);
|
|
1118
|
-
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatReport(r).join("\n"));
|
|
1217
|
+
const data = by >= 0 ? summarizeByTask(events) : summarize(events);
|
|
1218
|
+
if (options.json) return console.log(JSON.stringify(data, null, 2));
|
|
1219
|
+
printOutput(renderReport(data, options.columns, Date.now(), by >= 0), options.color);
|
|
1119
1220
|
},
|
|
1120
1221
|
facts: async () => {
|
|
1121
1222
|
if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
|
|
@@ -1170,17 +1271,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1170
1271
|
}
|
|
1171
1272
|
},
|
|
1172
1273
|
turns: () => {
|
|
1173
|
-
const
|
|
1174
|
-
const
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
const files = r.changed.length ? `: ${shown}` : "";
|
|
1179
|
-
let status = "";
|
|
1180
|
-
if (!r.ended) status = " (running)";
|
|
1181
|
-
else if (!r.end_tree) status = " (no end snapshot)";
|
|
1182
|
-
console.log(`${r.id} ${new Date(r.started).toLocaleString()}${status} ${r.changed.length} files${files}`);
|
|
1183
|
-
}
|
|
1274
|
+
const options = outputOptions();
|
|
1275
|
+
const { one, rest } = takeFlags(outputArguments(), ["--limit"], []);
|
|
1276
|
+
const turns = turnRecords(t => t.list(rest[0], Number(one["--limit"]) || 20), []);
|
|
1277
|
+
if (options.json) return console.log(JSON.stringify(turns, null, 2));
|
|
1278
|
+
printOutput(renderTurns(turns, options.columns, Date.now()), options.color);
|
|
1184
1279
|
},
|
|
1185
1280
|
undo: async () => {
|
|
1186
1281
|
const id = args.find((a) => !a.startsWith("--")) ?? fail("usage: ahub undo <turn> [--yes] [--context]");
|
|
@@ -1312,67 +1407,83 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1312
1407
|
},
|
|
1313
1408
|
|
|
1314
1409
|
doctor: async () => {
|
|
1315
|
-
|
|
1410
|
+
const options = outputOptions();
|
|
1411
|
+
if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
|
|
1316
1412
|
const version = (bin: string) => {
|
|
1317
1413
|
const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
|
|
1318
1414
|
return res.status === 0 ? res.stdout.trim().split("\n")[0]! : undefined;
|
|
1319
1415
|
};
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1416
|
+
const checks: DoctorCheck[] = [];
|
|
1417
|
+
const row = (ok: boolean | undefined, name: string, detail: string) => {
|
|
1418
|
+
const section: DoctorCheck["section"] = ["bun", "claude", "codex", "kimi"].includes(name) ? "Tools"
|
|
1419
|
+
: name === "config" || name === "retired setting" ? "Config"
|
|
1420
|
+
: name.startsWith("memory") ? "Memory"
|
|
1421
|
+
: ["omniroute", "omniroute key", "local fixed_model", "switchyard", "pi mlx"].includes(name) ? "Models" : "Hub";
|
|
1422
|
+
const optional = ["claude", "codex", "kimi", "ahub daemon", "switchyard", "pi mlx"].includes(name) || section === "Memory";
|
|
1423
|
+
const level: DoctorCheck["level"] = ok === undefined ? (["switchyard", "omniroute"].includes(name) ? "warn" : "unknown") : ok ? "ok" : optional ? "warn" : "fail";
|
|
1424
|
+
checks.push({ section, level, name, detail });
|
|
1425
|
+
};
|
|
1322
1426
|
|
|
1323
|
-
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1427
|
+
try {
|
|
1428
|
+
for (const bin of ["bun", "claude", "codex", "kimi"]) {
|
|
1429
|
+
const v = version(bin);
|
|
1430
|
+
row(!!v, bin, v ?? "not found on PATH");
|
|
1431
|
+
}
|
|
1432
|
+
const up = await healthy();
|
|
1433
|
+
row(up, "ahub daemon", up ? readControl(stateDir)!.url : "not running (ahub up)");
|
|
1434
|
+
if (up) {
|
|
1435
|
+
const hub = await connect();
|
|
1436
|
+
try {
|
|
1437
|
+
const { status } = await hub.request({ t: "status" });
|
|
1438
|
+
const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
|
|
1439
|
+
row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
|
|
1440
|
+
const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
|
|
1441
|
+
for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
|
|
1442
|
+
if (!["claude", "codex"].includes(peer.id) || peer.state === "offline") continue;
|
|
1443
|
+
const identity = peer.id === "claude" ? peer.sessionId : peer.threadId;
|
|
1444
|
+
row(!!identity, `${peer.id} recovery ID`, identity ? "recorded" : `missing; verify the current conversation and reconnect with ahub ${peer.id} before upgrading`);
|
|
1445
|
+
}
|
|
1446
|
+
} finally { hub.close(); }
|
|
1447
|
+
}
|
|
1448
|
+
const plugin = pluginState(parseList<InstalledPlugin>(spawnSync("claude", ["plugin", "list", "--json"], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
|
|
1449
|
+
row(plugin.state === "current", "claude plugin", plugin.state === "missing" ? "missing: run ahub setup" : plugin.state === "current" ? `agent-hub@agent-hub ${plugin.version}` : `agent-hub@agent-hub is stale (${plugin.why}): run ahub setup`);
|
|
1450
|
+
|
|
1451
|
+
const config = loadConfig(cwd);
|
|
1452
|
+
row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
|
|
1453
|
+
for (const line of config.retired ?? []) row(false, "retired setting", line); // issue #83
|
|
1454
|
+
const omni = new OmniRoute(config.omniroute);
|
|
1455
|
+
const gateway = await omni.base();
|
|
1456
|
+
row(gateway ? true : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? false : undefined, "omniroute", gateway ? `${new URL(gateway).host} healthy` : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? "no candidate reachable (VPN off?); ahub local cannot run" : "not configured: set omniroute.urls in .agenthub/config.local.json (any OpenAI-compatible gateway); ahub local cannot run");
|
|
1457
|
+
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.local.json");
|
|
1458
|
+
if (gateway && omni.apiKey()) {
|
|
1459
|
+
try {
|
|
1460
|
+
const fixed = currentRouting(cwd).local.fixed_model;
|
|
1461
|
+
const served = (await omni.models()).includes(fixed);
|
|
1462
|
+
row(served, "local fixed_model", served ? `${fixed} served` : `${fixed} is not served by the gateway`);
|
|
1463
|
+
} catch { row(undefined, "local fixed_model", "could not read the gateway model inventory"); }
|
|
1464
|
+
}
|
|
1465
|
+
const sy = spawnSync(process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server", ["--version"], { encoding: "utf8" });
|
|
1466
|
+
row(sy.status === 0 ? true : undefined, "switchyard", sy.status === 0 ? sy.stdout.trim() : "not installed: ahub local uses fixed_model on OmniRoute (cargo install --locked switchyard-server)");
|
|
1467
|
+
const mlxConfig = config.mlx;
|
|
1468
|
+
if (mlxConfig.enabled === false) row(true, "pi mlx", "disabled (mlx.enabled=false; local endpoint not probed)");
|
|
1469
|
+
else {
|
|
1470
|
+
const mlx = await inspectMlx({ ...mlxConfig, runtimeDir: mlxConfig.runtimeDir ? resolve(cwd, mlxConfig.runtimeDir) : undefined, modelPath: mlxConfig.modelPath ? resolve(cwd, mlxConfig.modelPath) : undefined });
|
|
1471
|
+
row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
|
|
1472
|
+
}
|
|
1368
1473
|
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1474
|
+
const memory = new MemoryClient();
|
|
1475
|
+
const mem = await memory.health();
|
|
1476
|
+
row(mem.ok, "memory worker", mem.ok ? `claude-mem ${mem.version ?? ""} at ${memory.url}` : `unavailable at ${memory.url} (the hub works without it)`);
|
|
1477
|
+
const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
|
|
1478
|
+
const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
|
1479
|
+
for (const tool of ["codex", "kimi"]) {
|
|
1480
|
+
const ready = new RegExp(`✓\\s+${tool} ready`).test(out);
|
|
1481
|
+
row(bridge.error ? undefined : ready, `memory capture: ${tool}`, bridge.error ? "unknown (`dot ai memory status` not available)" : `${ready ? "ready" : "not ready"}, per ` + "`dot ai memory status`");
|
|
1482
|
+
}
|
|
1483
|
+
} finally {
|
|
1484
|
+
// Preserve findings already collected if a later probe (for example config parsing) throws.
|
|
1485
|
+
if (options.json) console.log(JSON.stringify(checks, null, 2));
|
|
1486
|
+
else printOutput(renderDoctor(checks, options.columns, Date.now(), options.full), options.color);
|
|
1376
1487
|
}
|
|
1377
1488
|
},
|
|
1378
1489
|
};
|
|
@@ -1390,9 +1501,11 @@ async function runConductorCommand(): Promise<void> {
|
|
|
1390
1501
|
input = { peer: args[0] };
|
|
1391
1502
|
} else {
|
|
1392
1503
|
// Native TUIs return a human launch command through the daemon; an agent shell never executes it.
|
|
1504
|
+
if (cmd === "pi" && args.length === 1 && args[0] === "--headless") args.splice(0, 1, "--mode", "headless");
|
|
1393
1505
|
if (args.length && !(cmd === "pi" && args.length === 2 && args[0] === "--mode" && ["headless", "tui"].includes(args[1] ?? ""))) fail(`conductor starts accept no launch overrides; use ahub ${cmd}${cmd === "pi" ? " [--mode headless|tui]" : ""}`);
|
|
1394
1506
|
op = "hub_peer_start";
|
|
1395
|
-
|
|
1507
|
+
// #269: a mode may only repeat the peer's start mode, which a person sets; without one the hub takes that mode.
|
|
1508
|
+
input = { peer: cmd, ...(cmd === "pi" && args[1] ? { mode: args[1] } : {}) };
|
|
1396
1509
|
}
|
|
1397
1510
|
console.log(await taskOp(op, input));
|
|
1398
1511
|
}
|