@staix/agent-hub 0.12.21 → 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 +29 -0
- package/README.md +27 -12
- package/docs/agent-notes/adapters.md +15 -7
- package/docs/agent-notes/benchmarks.md +4 -1
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +12 -1
- package/docs/agent-notes/local-worker.md +4 -1
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +4 -0
- package/docs/events.md +17 -4
- package/docs/operations.md +478 -41
- package/docs/quickstart.md +14 -6
- package/docs/security.md +23 -11
- package/docs/smoke.md +133 -7
- package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- 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 +32 -17
- 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 +105 -17
- package/src/adapters/codex-appserver.ts +70 -35
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- 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 +25 -19
- package/src/cli/identity.ts +3 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +409 -177
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/recovery-package.ts +17 -0
- 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-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +80 -9
- package/src/cli/upgrade.ts +50 -15
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +15 -14
- 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 +483 -83
- 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 +4 -1
- 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 +7 -5
- 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 +43 -4
- 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 +158 -43
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +71 -2
- package/src/local/sandbox.ts +47 -6
- package/src/local/tools.ts +25 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +19 -2
- package/src/ui/index.html +172 -21
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";
|
|
@@ -18,33 +20,36 @@ import { OmniRoute } from "../omniroute/client.ts";
|
|
|
18
20
|
import { MemoryClient } from "../memory/client.ts";
|
|
19
21
|
import { init, planInit } from "./init.ts";
|
|
20
22
|
import { launcherPreview } from "./preview.ts";
|
|
21
|
-
import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
|
|
23
|
+
import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
22
24
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
23
25
|
import { CLASSES } from "../hub/board.ts";
|
|
24
26
|
import { VERSION } from "../version.ts";
|
|
25
27
|
import { freeText } from "./free-text.ts";
|
|
26
28
|
import { createInterface } from "node:readline/promises";
|
|
27
|
-
import {
|
|
29
|
+
import { createInterface as lineReader } from "node:readline";
|
|
30
|
+
import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner } from "../hub/recovery-store.ts";
|
|
28
31
|
import { childEnv } from "../hub/child-process.ts";
|
|
29
32
|
import { abortRecovery, createOperation, disposeRecovery, liveProjects, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
|
|
30
|
-
import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
33
|
+
import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
34
|
+
import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
|
|
35
|
+
import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
|
|
31
36
|
import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
32
37
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
33
38
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
34
39
|
|
|
35
|
-
import {
|
|
36
|
-
import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
40
|
+
import { contextLine, type PeerRow } from "./status-lines.ts";
|
|
37
41
|
import { parseSince, readEvents } from "../hub/events.ts";
|
|
38
42
|
import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, readRuns, runSummary, sameSuite } from "../hub/bench.ts";
|
|
39
43
|
import { benchPreflight, defaultIo, runBench } from "./bench.ts";
|
|
40
44
|
import { appendRecords, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
|
|
41
|
-
import {
|
|
45
|
+
import { summarize, summarizeByTask } from "../hub/report.ts";
|
|
42
46
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
43
47
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
44
48
|
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
|
|
45
49
|
import { recordCliAudit } from "./identity-audit.ts";
|
|
46
50
|
import { runConsole } from "./console.ts";
|
|
47
|
-
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";
|
|
48
53
|
import { renderHelp } from "./help.ts";
|
|
49
54
|
import { renderTailEvent } from "./tail-render.ts";
|
|
50
55
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
@@ -104,6 +109,22 @@ if (identity.role === "tools" && commandAccess === "console") { audit("refused")
|
|
|
104
109
|
if (identity.role === "tools") audit("run");
|
|
105
110
|
const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
|
|
106
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
|
+
}
|
|
107
128
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
108
129
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
109
130
|
const connect = () => ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd } : { role: "console", projectRoot: cwd });
|
|
@@ -171,12 +192,18 @@ function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): nev
|
|
|
171
192
|
process.exit(res.status ?? 1);
|
|
172
193
|
}
|
|
173
194
|
|
|
174
|
-
|
|
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
|
+
}
|
|
175
202
|
for (const flag of ["--mode", "--backend", "--model", "--session-id", "--session-file"]) {
|
|
176
203
|
const index = args.indexOf(flag);
|
|
177
204
|
if (index >= 0 && (!args[index + 1] || args[index + 1]!.startsWith("--"))) fail(`${flag} needs a value`);
|
|
178
205
|
}
|
|
179
|
-
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;
|
|
180
207
|
const backend = (args.includes("--backend") ? args[args.indexOf("--backend") + 1] : undefined) as string | undefined;
|
|
181
208
|
const model = args.includes("--model") ? args[args.indexOf("--model") + 1] : undefined;
|
|
182
209
|
const sessionId = args.includes("--session-id") ? args[args.indexOf("--session-id") + 1] : undefined;
|
|
@@ -186,8 +213,8 @@ function piFlags(): { mode: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"
|
|
|
186
213
|
if (!valueFlags.has(args[i]!)) fail(`unknown Pi option: ${args[i]}`);
|
|
187
214
|
i++;
|
|
188
215
|
}
|
|
189
|
-
if (!["headless", "tui"].includes(mode) || (backend !== undefined && !["auto", "dgx", "mlx"].includes(backend)) || (sessionId && sessionFile)) fail(
|
|
190
|
-
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 } : {}) };
|
|
191
218
|
}
|
|
192
219
|
|
|
193
220
|
async function projectRows() {
|
|
@@ -196,15 +223,10 @@ async function projectRows() {
|
|
|
196
223
|
finally { registry.close(); }
|
|
197
224
|
}
|
|
198
225
|
|
|
199
|
-
async function printProjects(
|
|
200
|
-
const
|
|
201
|
-
if (json) return console.log(JSON.stringify(
|
|
202
|
-
|
|
203
|
-
console.log(`${row.id} ${row.state.padEnd(12)} ${row.root}`);
|
|
204
|
-
if (row.status) console.log(` peers ${Object.keys(row.status.peers ?? {}).length}, control ${row.status.controlPort}, tasks ${JSON.stringify(row.status.tasks ?? {})}`);
|
|
205
|
-
if (row.error) console.log(` ${row.error}`);
|
|
206
|
-
}
|
|
207
|
-
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);
|
|
208
230
|
}
|
|
209
231
|
|
|
210
232
|
/** Full command line of a live process, undefined once it is gone. */
|
|
@@ -247,43 +269,40 @@ function orphanPids(project: Project): number[] {
|
|
|
247
269
|
|
|
248
270
|
const processGone = (pid: number): boolean => !processCommandLine(pid);
|
|
249
271
|
|
|
250
|
-
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> {
|
|
251
273
|
if (!hubDaemonCommand(pid, root)) {
|
|
252
|
-
|
|
274
|
+
diagnostic(` pid ${pid}: command line is not this project's hub daemon; refusing to kill`);
|
|
253
275
|
return false;
|
|
254
276
|
}
|
|
255
277
|
try { process.kill(pid, "SIGTERM"); } catch { return true; } // exited meanwhile
|
|
256
278
|
const graceful = Date.now() + 3_000;
|
|
257
279
|
while (Date.now() < graceful && !processGone(pid)) await Bun.sleep(100);
|
|
258
|
-
if (processGone(pid)) {
|
|
280
|
+
if (processGone(pid)) { diagnostic(` pid ${pid}: stopped with SIGTERM`); return true; }
|
|
259
281
|
// The PID may have been reused since SIGTERM; verify identity again before escalating.
|
|
260
282
|
if (!hubDaemonCommand(pid, root)) {
|
|
261
|
-
|
|
283
|
+
diagnostic(` pid ${pid}: identity changed after SIGTERM; refusing SIGKILL`);
|
|
262
284
|
return false;
|
|
263
285
|
}
|
|
264
286
|
try { process.kill(pid, "SIGKILL"); } catch { return true; }
|
|
265
287
|
const hard = Date.now() + 2_000;
|
|
266
288
|
while (Date.now() < hard && !processGone(pid)) await Bun.sleep(100);
|
|
267
289
|
if (!processGone(pid)) {
|
|
268
|
-
|
|
290
|
+
diagnostic(` pid ${pid}: still alive after SIGKILL`);
|
|
269
291
|
return false;
|
|
270
292
|
}
|
|
271
|
-
|
|
293
|
+
diagnostic(` pid ${pid}: killed with SIGKILL`);
|
|
272
294
|
return true;
|
|
273
295
|
}
|
|
274
296
|
|
|
275
|
-
async function orphanDoctor(kill: boolean): Promise<void> {
|
|
276
|
-
const orphans = registeredProjects().filter(
|
|
277
|
-
if (
|
|
278
|
-
|
|
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);
|
|
279
301
|
let failed = 0;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
for (const pid of pids) if (!(await killOrphan(pid, project.root))) failed++;
|
|
285
|
-
}
|
|
286
|
-
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++;
|
|
287
306
|
if (failed) fail(`${failed} orphaned hub process(es) could not be stopped`);
|
|
288
307
|
}
|
|
289
308
|
|
|
@@ -292,12 +311,13 @@ function fail(message: string): never {
|
|
|
292
311
|
process.exit(1);
|
|
293
312
|
}
|
|
294
313
|
|
|
295
|
-
async function taskOp(op: string, a: Record<string, unknown
|
|
314
|
+
async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false): Promise<string> {
|
|
296
315
|
const hub = await connect();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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(); }
|
|
301
321
|
}
|
|
302
322
|
|
|
303
323
|
/** Reads hub.db's turn records; `none` when the hub never kept one (snapshots off, no git work tree, an older hub). */
|
|
@@ -339,32 +359,124 @@ async function hold(t: "pause" | "resume"): Promise<void> {
|
|
|
339
359
|
console.log(`${args[0]} is ${res.state}`);
|
|
340
360
|
}
|
|
341
361
|
|
|
342
|
-
function spawnRecovery(operation: RecoveryOperation): void {
|
|
362
|
+
function spawnRecovery(operation: RecoveryOperation, quiet = false): void {
|
|
343
363
|
const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
|
|
344
364
|
cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
|
|
345
365
|
});
|
|
346
366
|
child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
|
|
347
367
|
child.unref();
|
|
348
368
|
// #215: the operation's own coordinator, which has every recovery command; the global ahub may be older mid-upgrade.
|
|
349
|
-
console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
|
|
369
|
+
if (!quiet) console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** #272: a terminal session for the interactive screens. One line reader for the whole session, in the terminal's own
|
|
373
|
+
* line mode, so Ctrl+C stays a signal: from then on every question answers null and the screens leave. The terminal
|
|
374
|
+
* is not read again after it (a parent that died on the same Ctrl+C may have handed it back to the shell), and no
|
|
375
|
+
* runner is ever stopped by it. */
|
|
376
|
+
async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<void> {
|
|
377
|
+
const rl = lineReader({ input: process.stdin, terminal: false });
|
|
378
|
+
const lines: string[] = [], waiters: ((line: string | null) => void)[] = [];
|
|
379
|
+
let ended = false, interrupted = false;
|
|
380
|
+
rl.on("line", (line) => { const next = waiters.shift(); if (next) next(line.trim()); else lines.push(line.trim()); });
|
|
381
|
+
rl.on("close", () => { ended = true; for (const waiter of waiters.splice(0)) waiter(null); });
|
|
382
|
+
const onInterrupt = () => { interrupted = true; for (const waiter of waiters.splice(0)) waiter(null); };
|
|
383
|
+
process.on("SIGINT", onInterrupt);
|
|
384
|
+
try {
|
|
385
|
+
await screens({
|
|
386
|
+
ask: (question) => {
|
|
387
|
+
if (interrupted) return Promise.resolve(null);
|
|
388
|
+
process.stdout.write(question);
|
|
389
|
+
const typed = lines.shift();
|
|
390
|
+
return typed !== undefined ? Promise.resolve(typed) : ended ? Promise.resolve(null) : new Promise((resolve) => waiters.push(resolve));
|
|
391
|
+
},
|
|
392
|
+
out: (line) => console.log(line),
|
|
393
|
+
run: (argv) => new Promise((resolve) => {
|
|
394
|
+
// The command owns the terminal while it runs; a reader whose input already ended has nothing to pause.
|
|
395
|
+
if (!ended) rl.pause();
|
|
396
|
+
const done = (code: number) => { if (!ended) rl.resume(); resolve(code); };
|
|
397
|
+
const child = spawn(process.execPath, argv, { cwd, stdio: "inherit" });
|
|
398
|
+
child.on("error", () => done(1));
|
|
399
|
+
child.on("close", (code) => done(code ?? 1));
|
|
400
|
+
}),
|
|
401
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
402
|
+
interrupted: () => interrupted,
|
|
403
|
+
// Scripted input (a pipe) is answers typed ahead for the next question; only a terminal's line is a key press.
|
|
404
|
+
typed: () => !ended && lines.splice(0).length > 0,
|
|
405
|
+
});
|
|
406
|
+
} finally { process.off("SIGINT", onInterrupt); rl.close(); }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost {
|
|
410
|
+
const scope = kind === "restart" ? cwd : undefined;
|
|
411
|
+
return {
|
|
412
|
+
plan: () => makeUpgradePlan(kind, version, scope),
|
|
413
|
+
apply: async (plan, interrupted) => {
|
|
414
|
+
assertLifecycleAvailable();
|
|
415
|
+
const current = await makeUpgradePlan(kind, plan.version, scope);
|
|
416
|
+
if (current.fingerprint !== plan.fingerprint) throw new Error("the plan changed during review; review it again");
|
|
417
|
+
// The check above takes seconds: a Ctrl+C in that time is still a no.
|
|
418
|
+
if (interrupted()) throw new Error("interrupted; nothing was started");
|
|
419
|
+
const operation = createOperation(plan, preserveSource(plan));
|
|
420
|
+
spawnRecovery(operation, true);
|
|
421
|
+
return operation;
|
|
422
|
+
},
|
|
423
|
+
lock: recoveryLock,
|
|
424
|
+
read: (id) => readOperation<RecoveryOperation>(id),
|
|
425
|
+
runner: recoveryRunner,
|
|
426
|
+
live: (op) => liveProjects(op, makeRecoveryDriver().inspect),
|
|
427
|
+
endPeer: (planned, peer) => endPlannedPeer(planned, peer),
|
|
428
|
+
runnerStoppable: (id) => signedRunner(id) !== undefined,
|
|
429
|
+
stopRunner: (id) => stopSignedRunner(id),
|
|
430
|
+
entry: join(import.meta.dir, "main.js"),
|
|
431
|
+
version: VERSION,
|
|
432
|
+
};
|
|
350
433
|
}
|
|
351
434
|
|
|
352
435
|
async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
353
436
|
const { one, rest } = takeFlags(args, ["--to"], []);
|
|
354
|
-
if (rest.some((a) => !["--dry-run", "--yes"].includes(a)) || (kind === "restart" && one["--to"])) fail("usage: ahub upgrade --to <version> [--dry-run] [--yes] | ahub restart [--dry-run] [--yes]");
|
|
355
|
-
if (kind === "upgrade" && !one["--to"]) fail("upgrade requires --to <exact-version>");
|
|
437
|
+
if (rest.some((a) => !["--dry-run", "--yes"].includes(a)) || (kind === "restart" && one["--to"])) fail("usage: ahub upgrade [--to <version>] [--dry-run] [--yes] | ahub restart [--dry-run] [--yes]");
|
|
356
438
|
if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
|
|
357
|
-
const
|
|
439
|
+
const dry = rest.includes("--dry-run"), yes = rest.includes("--yes");
|
|
440
|
+
// #272: on a terminal, with neither flag, the plan is reviewed, applied and followed in one session.
|
|
441
|
+
const interactive = !dry && !yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
442
|
+
let to = one["--to"];
|
|
443
|
+
if (kind === "upgrade" && !to) {
|
|
444
|
+
if (yes || (!dry && !interactive)) fail("upgrade requires --to <exact-version>");
|
|
445
|
+
to = await latestRelease();
|
|
446
|
+
console.error(`ahub: the latest release is ${to}`);
|
|
447
|
+
}
|
|
448
|
+
// Before the value reaches a package spec or the registry: a range such as 0.13 would resolve to whatever matches.
|
|
449
|
+
if (to !== undefined) { try { exactVersion(to); } catch (error) { fail((error as Error).message); } }
|
|
450
|
+
// An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
|
|
451
|
+
if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
|
|
452
|
+
const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", "upgrade", "--to", to!, ...rest];
|
|
453
|
+
console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
|
|
454
|
+
if (interactive) {
|
|
455
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
456
|
+
try { if (!/^(|y|yes)$/i.test((await rl.question("Continue? [Y/n] ")).trim())) return; } finally { rl.close(); }
|
|
457
|
+
}
|
|
458
|
+
// The coordinator owns the terminal from here. This process only waits, and a Ctrl+C meant for that session must not
|
|
459
|
+
// end it first and hand the terminal back to the shell under it.
|
|
460
|
+
process.on("SIGINT", () => {});
|
|
461
|
+
const code = await new Promise<number>((resolve) => {
|
|
462
|
+
const child = spawn(process.execPath, argv, { cwd, stdio: "inherit", env: { ...process.env, AGENTHUB_UPGRADE_HANDOVER: "1" } });
|
|
463
|
+
child.on("error", (error) => { console.error(`ahub: cannot run bun x: ${error.message}`); resolve(1); });
|
|
464
|
+
child.on("close", (status) => resolve(status ?? 1));
|
|
465
|
+
});
|
|
466
|
+
process.exit(code);
|
|
467
|
+
}
|
|
468
|
+
if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io));
|
|
469
|
+
const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
|
|
358
470
|
console.log(JSON.stringify(plan, null, 2));
|
|
359
471
|
// #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
|
|
360
472
|
for (const p of plan.projects) for (const peer of p.reconnectOnly ?? []) console.error(`ahub: ${p.project.id}: ${peer} is reconnect-only (unmanaged session): its plugin reattaches to the new hub; no terminal is closed or relaunched`);
|
|
361
473
|
for (const p of plan.projects) for (const peer of p.freshStart ?? []) console.error(`ahub: ${p.project.id}: ${peer} restarts as a new session: its thread has no rollout and the hub recorded no turn on it, so nothing is lost`);
|
|
362
474
|
const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
|
|
363
475
|
for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
|
|
364
|
-
if (
|
|
476
|
+
if (dry) return;
|
|
365
477
|
if (blockers.length) fail("plan has blockers; no runtime was changed");
|
|
366
478
|
assertLifecycleAvailable();
|
|
367
|
-
if (!
|
|
479
|
+
if (!yes) {
|
|
368
480
|
if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
|
|
369
481
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
370
482
|
try {
|
|
@@ -382,9 +494,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
382
494
|
upgrade: () => upgrade("upgrade"),
|
|
383
495
|
restart: () => upgrade("restart"),
|
|
384
496
|
recovery: async () => {
|
|
385
|
-
const [action,
|
|
386
|
-
const usage = "usage: ahub recovery status|resume|abort <operation-id> | ahub recovery dispose <operation-id> --fresh-session <peer>|--stop-and-archive --reason <text>";
|
|
387
|
-
|
|
497
|
+
const [action, given, ...more] = args;
|
|
498
|
+
const usage = "usage: ahub recovery [status|resume|abort] [<operation-id>] | ahub recovery dispose [<operation-id>] --fresh-session <peer>|--stop-and-archive --reason <text>";
|
|
499
|
+
// #272: without an id, the operation that holds the machine's lock; bare on a terminal, its screen.
|
|
500
|
+
const named = !!given && operationIdPattern.test(given);
|
|
501
|
+
const id = named ? given : recoveryLock();
|
|
502
|
+
const rest = named || given === undefined ? more : [given, ...more];
|
|
503
|
+
if (action === undefined && process.stdin.isTTY && process.stdout.isTTY) {
|
|
504
|
+
if (!id) return console.log("no recovery operation is open");
|
|
505
|
+
return onTerminal(async (io) => { await operationScreen(upgradeHost("restart", VERSION), io, id); });
|
|
506
|
+
}
|
|
507
|
+
if (!["status", "resume", "abort", "dispose"].includes(action ?? "") || (action !== "dispose" && rest.length)) fail(usage);
|
|
508
|
+
if (!id) fail("no recovery operation holds the lock; name one by its id");
|
|
388
509
|
const operation = readOperation<RecoveryOperation>(id);
|
|
389
510
|
const runner = recoveryRunner(id);
|
|
390
511
|
if (action === "status") {
|
|
@@ -434,12 +555,16 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
434
555
|
console.log("registration removed; project files were kept");
|
|
435
556
|
return;
|
|
436
557
|
}
|
|
437
|
-
|
|
438
|
-
|
|
558
|
+
const options = outputOptions();
|
|
559
|
+
if (outputArguments().length) fail("usage: ahub projects [--json] [--full] [--color=auto|always|never]");
|
|
560
|
+
await printProjects(options);
|
|
439
561
|
},
|
|
440
562
|
|
|
441
563
|
ui: async () => {
|
|
442
|
-
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");
|
|
443
568
|
if (args.includes("--stop")) {
|
|
444
569
|
if (!args.includes("--all") || args.includes("--no-open")) fail("usage: ahub ui --all --stop");
|
|
445
570
|
await stopManager();
|
|
@@ -450,11 +575,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
450
575
|
else {
|
|
451
576
|
const hub = await connect();
|
|
452
577
|
try {
|
|
453
|
-
const res = await hub.request({ t: "ui" }, 10_000);
|
|
578
|
+
const res = await hub.request({ t: "ui", ...(settings ? { settings: true } : {}) }, 10_000);
|
|
454
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");
|
|
455
582
|
url = res.url;
|
|
456
583
|
} finally { hub.close(); }
|
|
457
584
|
}
|
|
585
|
+
if (settings) console.error("Settings session: for 15 minutes this dashboard session may change permission modes, routing and start settings.");
|
|
458
586
|
if (args.includes("--no-open")) return console.log(url);
|
|
459
587
|
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
460
588
|
const opened = spawnSync(opener, [url], { stdio: "ignore", timeout: 10_000 });
|
|
@@ -517,7 +645,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
517
645
|
console: async () => {
|
|
518
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]");
|
|
519
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 });
|
|
520
|
-
if (color instanceof Error) return fail(color
|
|
648
|
+
if (color instanceof Error) return fail("usage: ahub console [--panels] [--color=auto|always|never]");
|
|
521
649
|
await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), color });
|
|
522
650
|
},
|
|
523
651
|
|
|
@@ -556,18 +684,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
556
684
|
let previousLaunch: unknown;
|
|
557
685
|
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")); } catch { /* no previous managed launch */ }
|
|
558
686
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
559
|
-
let result: ReturnType<typeof
|
|
687
|
+
let result: Awaited<ReturnType<typeof runClaudeLaunch>>;
|
|
560
688
|
try {
|
|
689
|
+
let launchRecord: ClaudeLaunchRecord | undefined;
|
|
561
690
|
if (control?.instanceId) {
|
|
562
691
|
// Publish only the final launch's installed hook and effective permission flags.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
|
|
692
|
+
launchRecord = { instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
|
|
693
|
+
recordClaudeLaunch(stateDir, launchRecord);
|
|
566
694
|
cleanupStaleClaudeSettings(stateDir, previousLaunch);
|
|
567
695
|
}
|
|
568
696
|
if (launch.warning) console.error(launch.warning);
|
|
569
697
|
const env = nativeLaunchEnv("claude", childEnv());
|
|
570
|
-
result =
|
|
698
|
+
result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
|
|
571
699
|
} finally {
|
|
572
700
|
cleanupClaudeSettings(stateDir, launch.settingsFile);
|
|
573
701
|
}
|
|
@@ -614,11 +742,30 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
614
742
|
},
|
|
615
743
|
|
|
616
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");
|
|
617
747
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
618
748
|
try { return console.log(JSON.stringify(launcherPreview("pi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
619
749
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
620
750
|
}
|
|
621
|
-
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
|
+
}
|
|
622
769
|
// #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
|
|
623
770
|
// trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
|
|
624
771
|
const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
|
|
@@ -654,17 +801,19 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
654
801
|
},
|
|
655
802
|
|
|
656
803
|
models: async () => {
|
|
657
|
-
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);
|
|
658
807
|
const configured = projectConfig().mlx;
|
|
659
808
|
if (configured.enabled === false) {
|
|
660
|
-
if (action === "status") return
|
|
809
|
+
if (action === "status") return showStatus({ state: "disabled", enabled: false });
|
|
661
810
|
if (["setup", "start", "stop"].includes(action)) fail("MLX is disabled by mlx.enabled=false; models commands do not manage shared Ollama");
|
|
662
811
|
fail("usage: ahub models setup|status|start|stop");
|
|
663
812
|
}
|
|
664
813
|
const runtimeDir = configured.runtimeDir ? resolve(cwd, configured.runtimeDir) : join(homedir(), ".agenthub", "runtimes", "mlx");
|
|
665
814
|
const modelPath = configured.modelPath ? resolve(cwd, configured.modelPath) : join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
|
|
666
815
|
const mlxOptions = configured.provider === "ollama" ? configured : { ...configured, runtimeDir, modelPath };
|
|
667
|
-
if (action === "status") return
|
|
816
|
+
if (action === "status") return showStatus(await inspectMlx(mlxOptions));
|
|
668
817
|
if (action === "start") { const handle = await ensureMlx(mlxOptions); return console.log(JSON.stringify(handle.status(), null, 2)); }
|
|
669
818
|
if (action === "stop") { await stopMlx(mlxOptions); return console.log("MLX stopped"); }
|
|
670
819
|
if (action !== "setup") fail("usage: ahub models setup|status|start|stop");
|
|
@@ -694,17 +843,17 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
694
843
|
},
|
|
695
844
|
|
|
696
845
|
queue: async () => {
|
|
697
|
-
const
|
|
846
|
+
const options = outputOptions();
|
|
847
|
+
const [operation = "list", ...rest] = outputArguments();
|
|
698
848
|
const hub = await connect();
|
|
699
849
|
try {
|
|
700
850
|
if (operation === "list") {
|
|
701
851
|
const { one, rest: flags } = takeFlags(rest, ["--peer"], []);
|
|
702
|
-
if (flags.
|
|
852
|
+
if (flags.length) fail("usage: ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]");
|
|
703
853
|
const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}) });
|
|
704
854
|
if (!result.ok) fail(result.error);
|
|
705
|
-
if (
|
|
706
|
-
|
|
707
|
-
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);
|
|
708
857
|
return;
|
|
709
858
|
}
|
|
710
859
|
const [id, ...flags] = rest;
|
|
@@ -713,9 +862,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
713
862
|
if (!shown.ok) fail(shown.error);
|
|
714
863
|
if (operation === "show") {
|
|
715
864
|
if (flags.length) fail("usage: ahub queue show <id>");
|
|
716
|
-
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);
|
|
717
867
|
}
|
|
718
|
-
const { one, rest: extra } = takeFlags(
|
|
868
|
+
const { one, rest: extra } = takeFlags(args.slice(2), ["--action", "--reason"], []);
|
|
719
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>");
|
|
720
870
|
const result = await hub.request({ t: "queue", op: "resolve", id, revision: shown.delivery.revision, action: one["--action"], reason: one["--reason"] });
|
|
721
871
|
if (!result.ok) fail(result.error);
|
|
@@ -740,18 +890,21 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
740
890
|
},
|
|
741
891
|
|
|
742
892
|
budget: async () => {
|
|
893
|
+
const options = outputOptions();
|
|
743
894
|
const hub = await connect();
|
|
744
|
-
if (
|
|
745
|
-
const op =
|
|
746
|
-
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] } : {}) };
|
|
747
898
|
if (op === "configure") {
|
|
748
|
-
if (!
|
|
749
|
-
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")) }; }
|
|
750
901
|
catch { hub.close(); fail("cannot read execution budget JSON configuration"); }
|
|
751
902
|
}
|
|
752
903
|
const result = await hub.request(request); hub.close();
|
|
753
904
|
if (!result.ok) fail(result.error);
|
|
754
|
-
|
|
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);
|
|
755
908
|
}
|
|
756
909
|
let set: Record<string, unknown> | undefined;
|
|
757
910
|
if (args[0] === "resume") {
|
|
@@ -772,21 +925,26 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
772
925
|
const res = await hub.request({ t: "budget", ...(set ? { set } : {}) });
|
|
773
926
|
hub.close();
|
|
774
927
|
if (!res.ok) fail(res.error);
|
|
775
|
-
const
|
|
776
|
-
if (
|
|
777
|
-
|
|
778
|
-
for (const [peer, b] of peers) {
|
|
779
|
-
console.log(`${peer}${b.paused ? ` PAUSED: ${b.paused.reason}, resumes in ${left(b.paused.resetsAt)}` : ""}`);
|
|
780
|
-
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" : ""}]`);
|
|
781
|
-
}
|
|
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);
|
|
782
931
|
},
|
|
783
932
|
|
|
784
933
|
board: async () => {
|
|
934
|
+
const options = outputOptions();
|
|
785
935
|
const ready = args.includes("--ready");
|
|
786
936
|
const state = args.find((a) => !a.startsWith("--"));
|
|
787
937
|
const tasks = JSON.parse(await taskOp("hub_task_list", ready ? { ready: true } : state ? { state } : {})) as any[];
|
|
788
|
-
if (
|
|
789
|
-
|
|
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);
|
|
790
948
|
},
|
|
791
949
|
|
|
792
950
|
task: async () => {
|
|
@@ -853,6 +1011,20 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
853
1011
|
pause: () => hold("pause"),
|
|
854
1012
|
resume: () => hold("resume"),
|
|
855
1013
|
|
|
1014
|
+
stop: async () => {
|
|
1015
|
+
const peer = args[0];
|
|
1016
|
+
if (args.length !== 1 || !peer || !/^[a-z][a-z0-9-]{0,31}$/.test(peer)) fail("usage: ahub stop <peer>");
|
|
1017
|
+
const hub = await connect();
|
|
1018
|
+
try {
|
|
1019
|
+
const reply = await hub.request({ t: "peer_stop", peer }, 35_000);
|
|
1020
|
+
if (reply.ok !== true) {
|
|
1021
|
+
const error = String(reply.error ?? "peer stop request refused");
|
|
1022
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error);
|
|
1023
|
+
}
|
|
1024
|
+
console.log(`${peer}: ${reply.state}`);
|
|
1025
|
+
} finally { hub.close(); }
|
|
1026
|
+
},
|
|
1027
|
+
|
|
856
1028
|
permission: async () => {
|
|
857
1029
|
const positional = args.filter(arg => arg !== "--yes");
|
|
858
1030
|
const [peer, mode] = positional;
|
|
@@ -868,6 +1040,64 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
868
1040
|
}
|
|
869
1041
|
if (reply.peers) for (const [id, value] of Object.entries(reply.peers)) console.log(`${id}: ${value}`);
|
|
870
1042
|
else console.log(`${peer}: ${reply.permissionMode}`);
|
|
1043
|
+
if (typeof reply.note === "string" && reply.note) console.log(reply.note);
|
|
1044
|
+
} finally { hub.close(); }
|
|
1045
|
+
},
|
|
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})`);
|
|
871
1101
|
} finally { hub.close(); }
|
|
872
1102
|
},
|
|
873
1103
|
|
|
@@ -881,23 +1111,15 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
881
1111
|
},
|
|
882
1112
|
|
|
883
1113
|
status: async () => {
|
|
884
|
-
|
|
1114
|
+
const options = outputOptions();
|
|
1115
|
+
if (args.includes("--all")) return printProjects(options);
|
|
885
1116
|
const hub = await connect();
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
|
|
893
|
-
const peers = Object.entries(status.peers as Record<string, PeerRow>);
|
|
894
|
-
for (const [id, p] of peers) console.log(peerLine(id, { ...p, context: p.context ?? unknownContext() }));
|
|
895
|
-
const models = (status as any).models?.backends as BackendRow[] | undefined;
|
|
896
|
-
if (models?.length) for (const backend of models) console.log(backendLine(backend));
|
|
897
|
-
if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
|
|
898
|
-
const counts = Object.entries(status.tasks ?? {}).map(([s, n]) => `${n} ${s}`).join(", ");
|
|
899
|
-
if (counts) console.log(` tasks: ${counts} (ahub board)`);
|
|
900
|
-
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(); }
|
|
901
1123
|
},
|
|
902
1124
|
|
|
903
1125
|
logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
|
|
@@ -988,15 +1210,13 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
988
1210
|
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
|
|
989
1211
|
},
|
|
990
1212
|
report: () => {
|
|
1213
|
+
const options = outputOptions();
|
|
991
1214
|
const by = args.indexOf("--by");
|
|
992
1215
|
if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
|
|
993
1216
|
const events = readEvents(join(stateDir, "events.jsonl"), since());
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
}
|
|
998
|
-
const r = summarize(events);
|
|
999
|
-
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);
|
|
1000
1220
|
},
|
|
1001
1221
|
facts: async () => {
|
|
1002
1222
|
if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
|
|
@@ -1051,17 +1271,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1051
1271
|
}
|
|
1052
1272
|
},
|
|
1053
1273
|
turns: () => {
|
|
1054
|
-
const
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
const files = r.changed.length ? `: ${shown}` : "";
|
|
1060
|
-
let status = "";
|
|
1061
|
-
if (!r.ended) status = " (running)";
|
|
1062
|
-
else if (!r.end_tree) status = " (no end snapshot)";
|
|
1063
|
-
console.log(`${r.id} ${new Date(r.started).toLocaleString()}${status} ${r.changed.length} files${files}`);
|
|
1064
|
-
}
|
|
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);
|
|
1065
1279
|
},
|
|
1066
1280
|
undo: async () => {
|
|
1067
1281
|
const id = args.find((a) => !a.startsWith("--")) ?? fail("usage: ahub undo <turn> [--yes] [--context]");
|
|
@@ -1193,67 +1407,83 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1193
1407
|
},
|
|
1194
1408
|
|
|
1195
1409
|
doctor: async () => {
|
|
1196
|
-
|
|
1410
|
+
const options = outputOptions();
|
|
1411
|
+
if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
|
|
1197
1412
|
const version = (bin: string) => {
|
|
1198
1413
|
const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
|
|
1199
1414
|
return res.status === 0 ? res.stdout.trim().split("\n")[0]! : undefined;
|
|
1200
1415
|
};
|
|
1201
|
-
const
|
|
1202
|
-
|
|
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
|
+
};
|
|
1203
1426
|
|
|
1204
|
-
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
+
}
|
|
1249
1473
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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);
|
|
1257
1487
|
}
|
|
1258
1488
|
},
|
|
1259
1489
|
};
|
|
@@ -1271,9 +1501,11 @@ async function runConductorCommand(): Promise<void> {
|
|
|
1271
1501
|
input = { peer: args[0] };
|
|
1272
1502
|
} else {
|
|
1273
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");
|
|
1274
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]" : ""}`);
|
|
1275
1506
|
op = "hub_peer_start";
|
|
1276
|
-
|
|
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] } : {}) };
|
|
1277
1509
|
}
|
|
1278
1510
|
console.log(await taskOp(op, input));
|
|
1279
1511
|
}
|