@staix/agent-hub 0.12.22 → 0.12.24
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 +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- 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/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- 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/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/cli/main.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
|
|
3
|
+
import { matchesPolicy } from "../hub/path-policy.ts";
|
|
4
|
+
import { projectedDiff } from "../hub/projected-diff.ts";
|
|
5
|
+
import { TextProjections } from "../hub/text-projection.ts";
|
|
6
|
+
import { terminalText } from "./console-state.ts";
|
|
7
|
+
import { petView, readPet, renderPet } from "./pet.ts";
|
|
8
|
+
import { loadPetConfig } from "./pet-config.ts";
|
|
9
|
+
import { profileName, projectProfile, resolveProfile } from "../hub/profiles.ts";
|
|
10
|
+
import { catalogAssignments, loadRoleCatalog, selectedRoles } from "../hub/roles.ts";
|
|
11
|
+
import { attachWatch } from "./watch.ts";
|
|
12
|
+
import { verifiedJcodeCommand } from "../adapters/jcode.ts";
|
|
13
|
+
import { configureJcodeMcp } from "./jcode-mcp.ts";
|
|
14
|
+
import { qwenSessionId } from "../hub/crash.ts";
|
|
2
15
|
import { isPermissionMode } from "../hub/permission-mode.ts";
|
|
16
|
+
import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
|
|
17
|
+
import { isSupportedPeer } from "../hub/peer-catalog.ts";
|
|
18
|
+
import { startModeOf } from "../hub/start-mode.ts";
|
|
3
19
|
import { currentRouting } from "../hub/routing.ts";
|
|
4
20
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
21
|
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -9,7 +25,7 @@ import { basename, dirname, join, relative, resolve } from "node:path";
|
|
|
9
25
|
import { ControlClient, readControl } from "../hub/control-client.ts";
|
|
10
26
|
import { loadConfig } from "../hub/daemon.ts";
|
|
11
27
|
import { factsHook } from "./facts-hook.ts";
|
|
12
|
-
import { projectContext, realPath } from "../hub/project.ts";
|
|
28
|
+
import { hubHome, projectContext, realPath } from "../hub/project.ts";
|
|
13
29
|
import { Registry, type Project } from "../hub/registry.ts";
|
|
14
30
|
import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
|
|
15
31
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
@@ -18,7 +34,8 @@ import { OmniRoute } from "../omniroute/client.ts";
|
|
|
18
34
|
import { MemoryClient } from "../memory/client.ts";
|
|
19
35
|
import { init, planInit } from "./init.ts";
|
|
20
36
|
import { launcherPreview } from "./preview.ts";
|
|
21
|
-
import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
37
|
+
import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, buildKimiTuiLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
38
|
+
import { configureKimiMcp } from "./kimi-mcp.ts";
|
|
22
39
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
23
40
|
import { CLASSES } from "../hub/board.ts";
|
|
24
41
|
import { VERSION } from "../version.ts";
|
|
@@ -35,19 +52,21 @@ import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
|
35
52
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
36
53
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
37
54
|
|
|
38
|
-
import {
|
|
39
|
-
import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
55
|
+
import { contextLine, type PeerRow } from "./status-lines.ts";
|
|
40
56
|
import { parseSince, readEvents } from "../hub/events.ts";
|
|
41
|
-
import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, readRuns, runSummary, sameSuite } from "../hub/bench.ts";
|
|
57
|
+
import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, MEMORY_MODES, readRuns, runSummary, sameSuite, type MemoryMode } from "../hub/bench.ts";
|
|
42
58
|
import { benchPreflight, defaultIo, runBench } from "./bench.ts";
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
59
|
+
import { ADDONS, isAddon, type AddonName } from "../hub/tooling.ts";
|
|
60
|
+
import { goalCommand, memoryCommand } from "./memory.ts";
|
|
61
|
+
import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
|
|
62
|
+
import { summarize, summarizeByTask } from "../hub/report.ts";
|
|
45
63
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
46
64
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
47
65
|
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
|
|
48
66
|
import { recordCliAudit } from "./identity-audit.ts";
|
|
49
67
|
import { runConsole } from "./console.ts";
|
|
50
|
-
import { permissionBoundary, resolveColor } from "./console-state.ts";
|
|
68
|
+
import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
|
|
69
|
+
import { outputWidth, renderRoster, renderStatus, renderBoard, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, type DoctorCheck } from "./output.ts";
|
|
51
70
|
import { renderHelp } from "./help.ts";
|
|
52
71
|
import { renderTailEvent } from "./tail-render.ts";
|
|
53
72
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
@@ -107,6 +126,22 @@ if (identity.role === "tools" && commandAccess === "console") { audit("refused")
|
|
|
107
126
|
if (identity.role === "tools") audit("run");
|
|
108
127
|
const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
|
|
109
128
|
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
129
|
+
/** One-shot output flags do not alter the fetched JSON document. */
|
|
130
|
+
function outputOptions() {
|
|
131
|
+
const flags = args.filter(arg => arg.startsWith("--color="));
|
|
132
|
+
if (flags.length > 1 || args.includes("--color")) fail(`usage: ahub ${cmd} [--color=auto|always|never] [--json] [--full]`);
|
|
133
|
+
const color = resolveColor(flags[0]?.slice("--color=".length), { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
|
|
134
|
+
if (color instanceof Error) fail(`usage: ahub ${cmd} [--color=auto|always|never] [--json] [--full]`);
|
|
135
|
+
return { color: color === true, json: args.includes("--json"), full: args.includes("--full"),
|
|
136
|
+
columns: outputWidth({ isTTY: !!process.stdout.isTTY, columns: process.stdout.columns, COLUMNS: process.env.COLUMNS }) };
|
|
137
|
+
}
|
|
138
|
+
/** Remove only presentation flags from read-only command positional arguments. */
|
|
139
|
+
function outputArguments(): string[] {
|
|
140
|
+
return args.filter(arg => arg !== "--json" && arg !== "--full" && !arg.startsWith("--color="));
|
|
141
|
+
}
|
|
142
|
+
function printOutput(lines: Span[][], color: boolean): void {
|
|
143
|
+
for (const line of lines) console.log(paint(line, color));
|
|
144
|
+
}
|
|
110
145
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
111
146
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
112
147
|
const connect = () => ControlClient.connect(stateDir, identity.role === "tools" ? { role: "tools", peer: identity.peer, projectRoot: cwd } : { role: "console", projectRoot: cwd });
|
|
@@ -158,7 +193,7 @@ async function healthy(): Promise<boolean> {
|
|
|
158
193
|
finally { hub?.close(); }
|
|
159
194
|
}
|
|
160
195
|
|
|
161
|
-
function exec(bin: string, argv: string[], tool?: "claude" | "codex"): never {
|
|
196
|
+
function exec(bin: string, argv: string[], tool?: "claude" | "codex" | "kimi"): never {
|
|
162
197
|
const env = tool ? nativeLaunchEnv(tool, childEnv()) : childEnv();
|
|
163
198
|
const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
164
199
|
if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
|
|
@@ -174,12 +209,18 @@ function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): nev
|
|
|
174
209
|
process.exit(res.status ?? 1);
|
|
175
210
|
}
|
|
176
211
|
|
|
177
|
-
|
|
212
|
+
/** `mode` is absent when no flag chose one: the caller takes the configured start mode (#269). `--headless` is `--mode headless`. */
|
|
213
|
+
function piFlags(): { mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; model?: string; sessionId?: string; sessionFile?: string } {
|
|
214
|
+
const usage = "usage: ahub pi [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id> | --session-file <path>]";
|
|
215
|
+
if (args.includes("--headless")) {
|
|
216
|
+
if (args.includes("--mode")) fail(usage);
|
|
217
|
+
args.splice(args.indexOf("--headless"), 1, "--mode", "headless");
|
|
218
|
+
}
|
|
178
219
|
for (const flag of ["--mode", "--backend", "--model", "--session-id", "--session-file"]) {
|
|
179
220
|
const index = args.indexOf(flag);
|
|
180
221
|
if (index >= 0 && (!args[index + 1] || args[index + 1]!.startsWith("--"))) fail(`${flag} needs a value`);
|
|
181
222
|
}
|
|
182
|
-
const mode = (args.includes("--mode") ? args[args.indexOf("--mode") + 1] :
|
|
223
|
+
const mode = (args.includes("--mode") ? args[args.indexOf("--mode") + 1] : undefined) as string | undefined;
|
|
183
224
|
const backend = (args.includes("--backend") ? args[args.indexOf("--backend") + 1] : undefined) as string | undefined;
|
|
184
225
|
const model = args.includes("--model") ? args[args.indexOf("--model") + 1] : undefined;
|
|
185
226
|
const sessionId = args.includes("--session-id") ? args[args.indexOf("--session-id") + 1] : undefined;
|
|
@@ -189,8 +230,8 @@ function piFlags(): { mode: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"
|
|
|
189
230
|
if (!valueFlags.has(args[i]!)) fail(`unknown Pi option: ${args[i]}`);
|
|
190
231
|
i++;
|
|
191
232
|
}
|
|
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 } : {}) };
|
|
233
|
+
if ((mode !== undefined && !["headless", "tui"].includes(mode)) || (backend !== undefined && !["auto", "dgx", "mlx"].includes(backend)) || (sessionId && sessionFile)) fail(usage);
|
|
234
|
+
return { ...(mode ? { mode: mode as "headless" | "tui" } : {}), ...(backend ? { backend: backend as "auto" | "dgx" | "mlx" } : {}), ...(model ? { model } : {}), ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}) };
|
|
194
235
|
}
|
|
195
236
|
|
|
196
237
|
async function projectRows() {
|
|
@@ -199,15 +240,10 @@ async function projectRows() {
|
|
|
199
240
|
finally { registry.close(); }
|
|
200
241
|
}
|
|
201
242
|
|
|
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.");
|
|
243
|
+
async function printProjects(options: ReturnType<typeof outputOptions>) {
|
|
244
|
+
const projects = await projectRows();
|
|
245
|
+
if (options.json) return console.log(JSON.stringify(projects, null, 2));
|
|
246
|
+
printOutput(renderProjects(projects, options.columns, Date.now()), options.color);
|
|
211
247
|
}
|
|
212
248
|
|
|
213
249
|
/** Full command line of a live process, undefined once it is gone. */
|
|
@@ -250,43 +286,40 @@ function orphanPids(project: Project): number[] {
|
|
|
250
286
|
|
|
251
287
|
const processGone = (pid: number): boolean => !processCommandLine(pid);
|
|
252
288
|
|
|
253
|
-
async function killOrphan(pid: number, root: string): Promise<boolean> {
|
|
289
|
+
async function killOrphan(pid: number, root: string, diagnostic: (text: string) => void = text => console.log(text)): Promise<boolean> {
|
|
254
290
|
if (!hubDaemonCommand(pid, root)) {
|
|
255
|
-
|
|
291
|
+
diagnostic(` pid ${pid}: command line is not this project's hub daemon; refusing to kill`);
|
|
256
292
|
return false;
|
|
257
293
|
}
|
|
258
294
|
try { process.kill(pid, "SIGTERM"); } catch { return true; } // exited meanwhile
|
|
259
295
|
const graceful = Date.now() + 3_000;
|
|
260
296
|
while (Date.now() < graceful && !processGone(pid)) await Bun.sleep(100);
|
|
261
|
-
if (processGone(pid)) {
|
|
297
|
+
if (processGone(pid)) { diagnostic(` pid ${pid}: stopped with SIGTERM`); return true; }
|
|
262
298
|
// The PID may have been reused since SIGTERM; verify identity again before escalating.
|
|
263
299
|
if (!hubDaemonCommand(pid, root)) {
|
|
264
|
-
|
|
300
|
+
diagnostic(` pid ${pid}: identity changed after SIGTERM; refusing SIGKILL`);
|
|
265
301
|
return false;
|
|
266
302
|
}
|
|
267
303
|
try { process.kill(pid, "SIGKILL"); } catch { return true; }
|
|
268
304
|
const hard = Date.now() + 2_000;
|
|
269
305
|
while (Date.now() < hard && !processGone(pid)) await Bun.sleep(100);
|
|
270
306
|
if (!processGone(pid)) {
|
|
271
|
-
|
|
307
|
+
diagnostic(` pid ${pid}: still alive after SIGKILL`);
|
|
272
308
|
return false;
|
|
273
309
|
}
|
|
274
|
-
|
|
310
|
+
diagnostic(` pid ${pid}: killed with SIGKILL`);
|
|
275
311
|
return true;
|
|
276
312
|
}
|
|
277
313
|
|
|
278
|
-
async function orphanDoctor(kill: boolean): Promise<void> {
|
|
279
|
-
const orphans = registeredProjects().filter(
|
|
280
|
-
if (
|
|
281
|
-
|
|
314
|
+
async function orphanDoctor(kill: boolean, options: ReturnType<typeof outputOptions>): Promise<void> {
|
|
315
|
+
const orphans = registeredProjects().filter(project => !existsSync(project.root)).map(project => ({ project, pids: orphanPids(project) }));
|
|
316
|
+
if (options.json) console.log(JSON.stringify(orphans, null, 2));
|
|
317
|
+
else printOutput(renderOrphans(orphans, options.columns, Date.now(), kill), options.color);
|
|
282
318
|
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>`");
|
|
319
|
+
const diagnostic = (text: string) => options.json
|
|
320
|
+
? console.error(paint([{ text }], false))
|
|
321
|
+
: printOutput(wrap(text, options.columns ?? Infinity).map(text => [{ text }]), options.color);
|
|
322
|
+
if (kill) for (const { project, pids } of orphans) for (const pid of pids) if (!(await killOrphan(pid, project.root, diagnostic))) failed++;
|
|
290
323
|
if (failed) fail(`${failed} orphaned hub process(es) could not be stopped`);
|
|
291
324
|
}
|
|
292
325
|
|
|
@@ -295,12 +328,13 @@ function fail(message: string): never {
|
|
|
295
328
|
process.exit(1);
|
|
296
329
|
}
|
|
297
330
|
|
|
298
|
-
async function taskOp(op: string, a: Record<string, unknown
|
|
299
|
-
const hub = await connect();
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
331
|
+
async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient): Promise<string> {
|
|
332
|
+
const hub = connection ?? await connect();
|
|
333
|
+
try {
|
|
334
|
+
const res = await hub.request({ t: "task", op, args: a }, op === "github_import" ? 60_000 : 30_000);
|
|
335
|
+
if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
|
|
336
|
+
return res.text;
|
|
337
|
+
} finally { if (!connection) hub.close(); }
|
|
304
338
|
}
|
|
305
339
|
|
|
306
340
|
/** Reads hub.db's turn records; `none` when the hub never kept one (snapshots off, no git work tree, an older hub). */
|
|
@@ -448,6 +482,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
|
448
482
|
});
|
|
449
483
|
process.exit(code);
|
|
450
484
|
}
|
|
485
|
+
if (kind === "upgrade") console.error("ahub: Kimi now defaults to its own TUI (pull-only) in projects without peers.kimi.start_mode. To keep ACP: ahub settings set peers.kimi.start_mode headless");
|
|
451
486
|
if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io));
|
|
452
487
|
const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
|
|
453
488
|
console.log(JSON.stringify(plan, null, 2));
|
|
@@ -538,12 +573,16 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
538
573
|
console.log("registration removed; project files were kept");
|
|
539
574
|
return;
|
|
540
575
|
}
|
|
541
|
-
|
|
542
|
-
|
|
576
|
+
const options = outputOptions();
|
|
577
|
+
if (outputArguments().length) fail("usage: ahub projects [--json] [--full] [--color=auto|always|never]");
|
|
578
|
+
await printProjects(options);
|
|
543
579
|
},
|
|
544
580
|
|
|
545
581
|
ui: async () => {
|
|
546
|
-
if (args.some((arg) => !["--no-open", "--all", "--stop"].includes(arg))) fail("usage: ahub ui [--
|
|
582
|
+
if (args.some((arg) => !["--no-open", "--all", "--stop", "--settings"].includes(arg))) fail("usage: ahub ui [--settings] [--no-open] | --all [--no-open] | --all --stop");
|
|
583
|
+
// #269: a settings session belongs to one project's hub; the unified dashboard stays an ordinary session.
|
|
584
|
+
const settings = args.includes("--settings");
|
|
585
|
+
if (settings && args.includes("--all")) fail("ahub ui --settings opens one project's dashboard; run it without --all");
|
|
547
586
|
if (args.includes("--stop")) {
|
|
548
587
|
if (!args.includes("--all") || args.includes("--no-open")) fail("usage: ahub ui --all --stop");
|
|
549
588
|
await stopManager();
|
|
@@ -554,11 +593,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
554
593
|
else {
|
|
555
594
|
const hub = await connect();
|
|
556
595
|
try {
|
|
557
|
-
const res = await hub.request({ t: "ui" }, 10_000);
|
|
596
|
+
const res = await hub.request({ t: "ui", ...(settings ? { settings: true } : {}) }, 10_000);
|
|
558
597
|
if (!res.ok) fail(res.error);
|
|
598
|
+
// A hub older than #269 ignores the field and would hand out an ordinary session without saying so.
|
|
599
|
+
if (settings && res.settings !== true) fail("this hub does not open settings sessions; upgrade the running hub to use ahub ui --settings");
|
|
559
600
|
url = res.url;
|
|
560
601
|
} finally { hub.close(); }
|
|
561
602
|
}
|
|
603
|
+
if (settings) console.error("Settings session: for 15 minutes this dashboard session may change permission modes, routing and start settings.");
|
|
562
604
|
if (args.includes("--no-open")) return console.log(url);
|
|
563
605
|
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
564
606
|
const opened = spawnSync(opener, [url], { stdio: "ignore", timeout: 10_000 });
|
|
@@ -601,14 +643,19 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
601
643
|
},
|
|
602
644
|
|
|
603
645
|
init: () => {
|
|
646
|
+
const flags = takeFlags(args.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow"), ["--profile"], []);
|
|
647
|
+
if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general] [--shadow] [--dry-run --json]");
|
|
648
|
+
const requestedProfile = flags.one["--profile"] === undefined ? undefined : profileName(flags.one["--profile"]);
|
|
604
649
|
if (args.includes("--dry-run")) {
|
|
605
|
-
const plan = planInit(cwd);
|
|
650
|
+
const plan = planInit(cwd, requestedProfile, args.includes("--shadow"));
|
|
606
651
|
return console.log(args.includes("--json") ? JSON.stringify(plan, null, 2) : plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`).join("\n") || "already up to date");
|
|
607
652
|
}
|
|
608
653
|
assertLifecycleAvailable();
|
|
609
|
-
const changed = init(cwd);
|
|
654
|
+
const changed = init(cwd, requestedProfile, args.includes("--shadow"));
|
|
610
655
|
registeredProject();
|
|
611
656
|
console.log(changed.length ? changed.map((p) => `${existsSync(p) ? "wrote" : "removed"} ${p}`).join("\n") : "already up to date");
|
|
657
|
+
const config = projectConfig(), examples = resolveProfile(config.profile, config.profile_overrides).example_checks;
|
|
658
|
+
if (examples.length) console.log(terminalText("Example checks (advisory):\n" + examples.map(example => "- " + example).join("\n")));
|
|
612
659
|
},
|
|
613
660
|
|
|
614
661
|
// Internal: the detached daemon process started by ahub up or the manager.
|
|
@@ -619,10 +666,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
619
666
|
},
|
|
620
667
|
|
|
621
668
|
console: async () => {
|
|
622
|
-
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]");
|
|
669
|
+
if (args.some(arg => arg !== "--panels" && arg !== "--no-pet" && !arg.startsWith("--color=")) || args.filter(arg => arg.startsWith("--color=")).length > 1) fail("usage: ahub console [--panels] [--no-pet] [--color=auto|always|never]");
|
|
623
670
|
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
|
|
625
|
-
await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), color });
|
|
671
|
+
if (color instanceof Error) return fail("usage: ahub console [--panels] [--no-pet] [--color=auto|always|never]");
|
|
672
|
+
await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), noPet: args.includes("--no-pet"), color });
|
|
626
673
|
},
|
|
627
674
|
|
|
628
675
|
up: async () => {
|
|
@@ -703,26 +750,116 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
703
750
|
exec(launch.cmd, launch.args, "codex");
|
|
704
751
|
},
|
|
705
752
|
|
|
753
|
+
jcode: async () => {
|
|
754
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
755
|
+
try { return console.log(JSON.stringify(launcherPreview("jcode", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
756
|
+
catch { fail("cannot preview Jcode launch: invalid arguments or configuration (details withheld)"); }
|
|
757
|
+
}
|
|
758
|
+
assertLifecycleAvailable();
|
|
759
|
+
const { one, rest } = takeFlags(args, ["--mode", "--model", "--provider-profile", "--session-id"], []);
|
|
760
|
+
if (rest.length || (one["--mode"] !== undefined && one["--mode"] !== "headless")) fail("usage: ahub jcode [--mode headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>]");
|
|
761
|
+
if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Jcode session identity");
|
|
762
|
+
const hub = await connect();
|
|
763
|
+
try {
|
|
764
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
765
|
+
if (snapshot?.roster?.find((row: any) => row.peer === "jcode")?.on !== true) fail("jcode is off roster; add it with ahub roster add jcode");
|
|
766
|
+
const written = configureJcodeMcp(cwd, stateDir, resolve(import.meta.dir, "../../plugins/agent-hub/server.js"));
|
|
767
|
+
if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
|
|
768
|
+
const reply = await hub.request({ t: "start", peer: "jcode", args: { mode: "headless", model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
|
|
769
|
+
if (!reply.ok) fail(reply.error);
|
|
770
|
+
console.log(reply.already ? "jcode is already attached" : "jcode attached (headless); native policy unmanaged, NO hub sandbox");
|
|
771
|
+
} finally { hub.close(); }
|
|
772
|
+
},
|
|
773
|
+
|
|
774
|
+
qwen: async () => {
|
|
775
|
+
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
776
|
+
try { return console.log(JSON.stringify(launcherPreview("qwen", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
777
|
+
catch { fail("cannot preview Qwen launch: invalid arguments or configuration (details withheld)"); }
|
|
778
|
+
}
|
|
779
|
+
assertLifecycleAvailable();
|
|
780
|
+
const { one, rest } = takeFlags(args, ["--mode", "--model", "--session-id"], []);
|
|
781
|
+
if (one["--session-id"] !== undefined && !qwenSessionId(one["--session-id"])) fail("invalid Qwen session identity");
|
|
782
|
+
if (rest.length || (one["--mode"] !== undefined && one["--mode"] !== "headless")) fail("Qwen TUI is not offered before verification; usage: ahub qwen [--mode headless] [--model <alias>] [--session-id <id>]");
|
|
783
|
+
const hub = await connect();
|
|
784
|
+
try {
|
|
785
|
+
const reply = await hub.request({ t: "start", peer: "qwen", args: { mode: "headless", ...(one["--model"] ? { model: one["--model"] } : {}), ...(one["--session-id"] ? { sessionId: one["--session-id"] } : {}) }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 35_000);
|
|
786
|
+
if (!reply.ok) fail(reply.error);
|
|
787
|
+
console.log(reply.already ? "qwen is already attached" : "qwen attached (headless); use ahub say @qwen and the console for approvals");
|
|
788
|
+
} finally { hub.close(); }
|
|
789
|
+
},
|
|
706
790
|
kimi: async () => {
|
|
707
791
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
708
792
|
try { return console.log(JSON.stringify(launcherPreview("kimi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
709
793
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
710
794
|
}
|
|
711
|
-
|
|
712
|
-
const
|
|
795
|
+
assertLifecycleAvailable();
|
|
796
|
+
const { one, rest } = takeFlags(args, ["--mode", "--model"], []);
|
|
797
|
+
const mode = one["--mode"] ?? startModeOf(projectConfig().peers, "kimi"), model = one["--model"];
|
|
798
|
+
if (rest.length || !["tui", "headless"].includes(mode)) fail("usage: ahub kimi [--mode tui|headless] [--model <alias>]");
|
|
799
|
+
if (mode === "tui" && one["--mode"] === undefined && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
800
|
+
if (model) fail("Kimi's TUI needs a terminal for these options; run in a terminal or add --mode headless");
|
|
801
|
+
const hub = await connect();
|
|
802
|
+
try {
|
|
803
|
+
const reply = await hub.request({ t: "peer_start", peer: "kimi" }, 35_000);
|
|
804
|
+
if (!reply.ok) fail(reply.error);
|
|
805
|
+
return console.log(reply.already ? "kimi is already attached" : `kimi: opened a terminal running ${reply.command}; it attaches pull-only when its TUI is ready`);
|
|
806
|
+
} finally { hub.close(); }
|
|
807
|
+
}
|
|
713
808
|
const hub = await connect();
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
809
|
+
try {
|
|
810
|
+
if (mode === "tui") {
|
|
811
|
+
const launch = buildKimiTuiLaunch(projectConfig().kimi_cmd, model);
|
|
812
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
813
|
+
if (snapshot?.roster?.find((row: any) => row.peer === "kimi")?.on === false) fail("kimi is off roster; add it with ahub roster add kimi");
|
|
814
|
+
const peer = snapshot?.peers?.kimi;
|
|
815
|
+
if (peer && !["offline"].includes(peer.state) && !peer.pullOnly) fail("Kimi is running headless; run ahub stop kimi before starting its TUI");
|
|
816
|
+
const admission = await hub.request({ t: "start", peer: "kimi", args: { mode: "tui" }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
817
|
+
if (!admission.ok) fail(admission.error);
|
|
818
|
+
let result: ReturnType<typeof spawnSync>;
|
|
819
|
+
try {
|
|
820
|
+
const written = configureKimiMcp(cwd, stateDir, resolve(import.meta.dir, "../../plugins/agent-hub/server.js"), "tui");
|
|
821
|
+
if (written.tracked) console.error(".kimi-code/mcp.json is tracked; the agent-hub entry contains machine paths. Review this local change before committing it.");
|
|
822
|
+
result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...nativeLaunchEnv("kimi", childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
823
|
+
} finally {
|
|
824
|
+
if (admission.admissionId) await hub.request({ t: "kimi_launch_end", admissionId: admission.admissionId }).catch(() => {});
|
|
825
|
+
}
|
|
826
|
+
if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
|
|
827
|
+
process.exit(result.status ?? 1);
|
|
828
|
+
}
|
|
829
|
+
const peer = (await hub.request({ t: "status" })).status?.peers?.kimi;
|
|
830
|
+
if (peer?.pullOnly && peer.state !== "offline") fail("Kimi runs in a terminal; end it there before starting a headless one");
|
|
831
|
+
configureKimiMcp(cwd, stateDir, resolve(import.meta.dir, "../../plugins/agent-hub/server.js"), "headless");
|
|
832
|
+
const res = await hub.request({ t: "start", peer: "kimi", args: { mode: "headless", model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
833
|
+
if (!res.ok) fail(res.error);
|
|
834
|
+
console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
|
|
835
|
+
} finally { hub.close(); }
|
|
718
836
|
},
|
|
719
837
|
|
|
720
838
|
pi: async () => {
|
|
839
|
+
// --headless is --mode headless everywhere this command goes: the preview, the hub and the conductor path.
|
|
840
|
+
if (args.includes("--headless") && !args.includes("--mode")) args.splice(args.indexOf("--headless"), 1, "--mode", "headless");
|
|
721
841
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
722
842
|
try { return console.log(JSON.stringify(launcherPreview("pi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
723
843
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
724
844
|
}
|
|
725
|
-
const
|
|
845
|
+
const flags = piFlags();
|
|
846
|
+
// #269: with no flag, Pi's start mode decides, and that is its TUI unless a person set headless.
|
|
847
|
+
const options = { ...flags, mode: flags.mode ?? startModeOf(projectConfig().peers, "pi") };
|
|
848
|
+
if (flags.mode === undefined && options.mode === "tui" && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
849
|
+
// The default asked for the TUI and there is no terminal here (the console's command line, a script): the hub
|
|
850
|
+
// opens one, or says how to. It is never downgraded to headless. The fixed command carries no option, so one
|
|
851
|
+
// that was given needs a terminal of its own. An explicit --mode tui is taken at its word, as before.
|
|
852
|
+
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");
|
|
853
|
+
const hub = await connect();
|
|
854
|
+
try {
|
|
855
|
+
const reply = await hub.request({ t: "peer_start", peer: "pi" }, 35_000);
|
|
856
|
+
if (reply.ok !== true) {
|
|
857
|
+
const error = String(reply.error ?? "pi start refused");
|
|
858
|
+
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);
|
|
859
|
+
}
|
|
860
|
+
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 "..."');
|
|
861
|
+
} finally { hub.close(); }
|
|
862
|
+
}
|
|
726
863
|
// #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
|
|
727
864
|
// trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
|
|
728
865
|
const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
|
|
@@ -758,17 +895,19 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
758
895
|
},
|
|
759
896
|
|
|
760
897
|
models: async () => {
|
|
761
|
-
const action =
|
|
898
|
+
const action = outputArguments()[0] ?? "status";
|
|
899
|
+
const options = action === "status" ? outputOptions() : undefined;
|
|
900
|
+
const showStatus = (data: unknown) => options!.json ? console.log(JSON.stringify(data, null, 2)) : printOutput(renderModelsStatus(data, options!.columns, Date.now()), options!.color);
|
|
762
901
|
const configured = projectConfig().mlx;
|
|
763
902
|
if (configured.enabled === false) {
|
|
764
|
-
if (action === "status") return
|
|
903
|
+
if (action === "status") return showStatus({ state: "disabled", enabled: false });
|
|
765
904
|
if (["setup", "start", "stop"].includes(action)) fail("MLX is disabled by mlx.enabled=false; models commands do not manage shared Ollama");
|
|
766
905
|
fail("usage: ahub models setup|status|start|stop");
|
|
767
906
|
}
|
|
768
907
|
const runtimeDir = configured.runtimeDir ? resolve(cwd, configured.runtimeDir) : join(homedir(), ".agenthub", "runtimes", "mlx");
|
|
769
908
|
const modelPath = configured.modelPath ? resolve(cwd, configured.modelPath) : join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
|
|
770
909
|
const mlxOptions = configured.provider === "ollama" ? configured : { ...configured, runtimeDir, modelPath };
|
|
771
|
-
if (action === "status") return
|
|
910
|
+
if (action === "status") return showStatus(await inspectMlx(mlxOptions));
|
|
772
911
|
if (action === "start") { const handle = await ensureMlx(mlxOptions); return console.log(JSON.stringify(handle.status(), null, 2)); }
|
|
773
912
|
if (action === "stop") { await stopMlx(mlxOptions); return console.log("MLX stopped"); }
|
|
774
913
|
if (action !== "setup") fail("usage: ahub models setup|status|start|stop");
|
|
@@ -794,21 +933,22 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
794
933
|
hub.close();
|
|
795
934
|
if (!res.ok) fail(res.error);
|
|
796
935
|
if (res.recorded) return console.log("recorded only ([FYI]); no peer was interrupted");
|
|
797
|
-
console.log(res.targets.length ? `queued for: ${res.targets.join(", ")}` : "no peers attached; nothing delivered");
|
|
936
|
+
console.log(paint([{ text: res.targets.length ? `queued for: ${res.targets.join(", ")}` : "no peers attached; nothing delivered" }], false));
|
|
937
|
+
if (typeof res.notice === "string" && res.notice) console.log(paint([{ text: res.notice }], false));
|
|
798
938
|
},
|
|
799
939
|
|
|
800
940
|
queue: async () => {
|
|
801
|
-
const
|
|
941
|
+
const options = outputOptions();
|
|
942
|
+
const [operation = "list", ...rest] = outputArguments();
|
|
802
943
|
const hub = await connect();
|
|
803
944
|
try {
|
|
804
945
|
if (operation === "list") {
|
|
805
946
|
const { one, rest: flags } = takeFlags(rest, ["--peer"], []);
|
|
806
|
-
if (flags.
|
|
947
|
+
if (flags.length) fail("usage: ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]");
|
|
807
948
|
const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}) });
|
|
808
949
|
if (!result.ok) fail(result.error);
|
|
809
|
-
if (
|
|
810
|
-
|
|
811
|
-
if (!result.deliveries.length) console.log("no retained deliveries");
|
|
950
|
+
if (options.json) return console.log(JSON.stringify(result.deliveries, null, 2));
|
|
951
|
+
printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
|
|
812
952
|
return;
|
|
813
953
|
}
|
|
814
954
|
const [id, ...flags] = rest;
|
|
@@ -817,9 +957,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
817
957
|
if (!shown.ok) fail(shown.error);
|
|
818
958
|
if (operation === "show") {
|
|
819
959
|
if (flags.length) fail("usage: ahub queue show <id>");
|
|
820
|
-
return console.log(JSON.stringify(shown.delivery, null, 2));
|
|
960
|
+
if (options.json) return console.log(JSON.stringify(shown.delivery, null, 2));
|
|
961
|
+
return printOutput(renderQueueShow(shown.delivery, options.columns, Date.now()), options.color);
|
|
821
962
|
}
|
|
822
|
-
const { one, rest: extra } = takeFlags(
|
|
963
|
+
const { one, rest: extra } = takeFlags(args.slice(2), ["--action", "--reason"], []);
|
|
823
964
|
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
965
|
const result = await hub.request({ t: "queue", op: "resolve", id, revision: shown.delivery.revision, action: one["--action"], reason: one["--reason"] });
|
|
825
966
|
if (!result.ok) fail(result.error);
|
|
@@ -827,6 +968,30 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
827
968
|
} finally { hub.close(); }
|
|
828
969
|
},
|
|
829
970
|
|
|
971
|
+
watch: async () => {
|
|
972
|
+
const peer = args[0];
|
|
973
|
+
if (peer !== "kimi" && peer !== "qwen") fail("usage: ahub watch kimi|qwen [--no-thoughts] [--color=auto|always|never]");
|
|
974
|
+
if (args.slice(1).some(arg => arg !== "--no-thoughts" && !/^--color=(auto|always|never)$/.test(arg))) fail("unsupported watch option");
|
|
975
|
+
const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice(8), { isTTY: !!process.stdout.isTTY, ...process.env });
|
|
976
|
+
if (color instanceof Error) fail(color.message);
|
|
977
|
+
const hub = await connect();
|
|
978
|
+
const input = process.stdin;
|
|
979
|
+
const raw = !!input.isRaw;
|
|
980
|
+
let leave = () => {};
|
|
981
|
+
const closed = new Promise<void>(resolve => { leave = resolve; });
|
|
982
|
+
const key = (chunk: Buffer) => { if (chunk.includes(3) || chunk.toString().includes("q")) leave(); };
|
|
983
|
+
hub.onClose = leave;
|
|
984
|
+
process.on("SIGINT", leave);
|
|
985
|
+
try {
|
|
986
|
+
await attachWatch(hub, peer, line => console.log(line), { columns: () => process.stdout.columns || 80, color, noThoughts: args.includes("--no-thoughts") });
|
|
987
|
+
if (input.isTTY) { input.setRawMode(true); input.on("data", key); input.resume(); }
|
|
988
|
+
await closed;
|
|
989
|
+
} finally {
|
|
990
|
+
input.off("data", key); process.off("SIGINT", leave);
|
|
991
|
+
if (input.isTTY) input.setRawMode(raw);
|
|
992
|
+
input.pause(); hub.close();
|
|
993
|
+
}
|
|
994
|
+
},
|
|
830
995
|
tail: async () => {
|
|
831
996
|
const hub = await connect();
|
|
832
997
|
hub.onPush = (msg) => {
|
|
@@ -844,18 +1009,21 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
844
1009
|
},
|
|
845
1010
|
|
|
846
1011
|
budget: async () => {
|
|
1012
|
+
const options = outputOptions();
|
|
847
1013
|
const hub = await connect();
|
|
848
|
-
if (
|
|
849
|
-
const op =
|
|
850
|
-
let request: Record<string, unknown> = { t: "execution_budget", op, ...(
|
|
1014
|
+
if (outputArguments()[0] === "execution") {
|
|
1015
|
+
const op = outputArguments()[1] ?? "status";
|
|
1016
|
+
let request: Record<string, unknown> = { t: "execution_budget", op, ...(outputArguments()[2] ? { id: outputArguments()[2] } : {}) };
|
|
851
1017
|
if (op === "configure") {
|
|
852
|
-
if (!
|
|
853
|
-
try { request = { t: "execution_budget", op, config: JSON.parse(readFileSync(
|
|
1018
|
+
if (!outputArguments()[2]) { hub.close(); fail("usage: ahub budget execution configure <config.json>"); }
|
|
1019
|
+
try { request = { t: "execution_budget", op, config: JSON.parse(readFileSync(outputArguments()[2]!, "utf8")) }; }
|
|
854
1020
|
catch { hub.close(); fail("cannot read execution budget JSON configuration"); }
|
|
855
1021
|
}
|
|
856
1022
|
const result = await hub.request(request); hub.close();
|
|
857
1023
|
if (!result.ok) fail(result.error);
|
|
858
|
-
|
|
1024
|
+
const data = result.budgets ?? result.budget ?? { disabled: result.disabled };
|
|
1025
|
+
if (op !== "status" || options.json) return console.log(JSON.stringify(data, null, 2));
|
|
1026
|
+
return printOutput(renderExecutionBudgetStatus(result.budgets, options.columns, Date.now()), options.color);
|
|
859
1027
|
}
|
|
860
1028
|
let set: Record<string, unknown> | undefined;
|
|
861
1029
|
if (args[0] === "resume") {
|
|
@@ -876,28 +1044,127 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
876
1044
|
const res = await hub.request({ t: "budget", ...(set ? { set } : {}) });
|
|
877
1045
|
hub.close();
|
|
878
1046
|
if (!res.ok) fail(res.error);
|
|
879
|
-
const
|
|
880
|
-
if (
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1047
|
+
const data = { budget: res.budget, gate: res.gate };
|
|
1048
|
+
if (options.json) return console.log(JSON.stringify(data, null, 2));
|
|
1049
|
+
printOutput(renderBudget(data, options.columns, Date.now(), options.full), options.color);
|
|
1050
|
+
},
|
|
1051
|
+
|
|
1052
|
+
backlog: async () => {
|
|
1053
|
+
const [sub, id, revision, value, ...reason] = args;
|
|
1054
|
+
if (sub === "list" && args.length === 1) return console.log(await taskOp("hub_backlog_list", {}));
|
|
1055
|
+
if (sub === "add" && args.length >= 3) return console.log(await taskOp("hub_task_propose", { class: id, title: args.slice(2).join(" "), backlog: true }));
|
|
1056
|
+
if (!/^[1-9]\d*$/.test(id ?? "")) fail("usage: ahub backlog list|add <class> <title...>|pull|return <id>|plan|pin <id> <revision> <value> <reason...>|undo <id> <revision>");
|
|
1057
|
+
if (["pull", "return"].includes(sub ?? "") && args.length === 2) return console.log(await taskOp(sub === "pull" ? "backlog_pull" : "backlog_return", { id }));
|
|
1058
|
+
if (!/^\d+$/.test(revision ?? "")) fail("planning revision is required");
|
|
1059
|
+
if (sub === "pull-ready" && args.length >= 4) return console.log(await taskOp("hub_backlog_pull", { id: Number(id), revision: Number(revision), reason: args.slice(3).join(" ") }));
|
|
1060
|
+
if (sub === "split" && value && reason.length) {
|
|
1061
|
+
let parts: unknown;try { parts=JSON.parse(value); } catch { return fail("split parts must be JSON"); }
|
|
1062
|
+
return console.log(await taskOp("hub_backlog_split", { id: Number(id), revision: Number(revision), parts, reason: reason.join(" ") }));
|
|
885
1063
|
}
|
|
1064
|
+
if (sub === "undo" && args.length === 3) return console.log(await taskOp("hub_backlog_undo", { id: Number(id), revision: Number(revision) }));
|
|
1065
|
+
if (!["plan", "pin"].includes(sub ?? "") || !value || !reason.length) fail("planning needs fields and a reason");
|
|
1066
|
+
let fields: unknown;
|
|
1067
|
+
try { fields = sub === "pin" ? { pinned: value === "true" ? true : value === "false" ? false : undefined } : JSON.parse(value); }
|
|
1068
|
+
catch { return fail("planning fields must be JSON"); }
|
|
1069
|
+
if (sub === "pin" && value !== "true" && value !== "false") fail("pin must be true or false");
|
|
1070
|
+
console.log(await taskOp("hub_backlog_plan", { id: Number(id), revision: Number(revision), fields, reason: reason.join(" ") }));
|
|
1071
|
+
},
|
|
1072
|
+
|
|
1073
|
+
release: async () => {
|
|
1074
|
+
const [sub, name, revision, value] = args;
|
|
1075
|
+
if (sub === "list" && args.length === 1) return console.log(await taskOp("hub_release_list", {}));
|
|
1076
|
+
if (sub === "show" && name && args.length === 2) {
|
|
1077
|
+
return console.log(await taskOp("release_show", { name }));
|
|
1078
|
+
}
|
|
1079
|
+
if (sub === "new" && name && revision && args.length === 4 && /^\d+$/.test(value ?? "")) return console.log(await taskOp("release_new", { name, goal: revision, order: Number(value) }));
|
|
1080
|
+
if (!name || !/^\d+$/.test(revision ?? "")) fail("usage: ahub release new <name> <goal> <order>|list|show <name>|plan <name> <revision> <dates-json>|activate|freeze|ship|drop <name> <revision>");
|
|
1081
|
+
const states: Record<string, string> = { activate: "active", freeze: "frozen", ship: "shipped", drop: "dropped" };
|
|
1082
|
+
let fields: unknown;
|
|
1083
|
+
if (sub === "plan" && value && args.length === 4) {
|
|
1084
|
+
try { fields = JSON.parse(value); } catch { return fail("release dates must be JSON"); }
|
|
1085
|
+
} else if (sub && Object.hasOwn(states, sub) && args.length === 3) fields = { state: states[sub] };
|
|
1086
|
+
else return fail("invalid release command");
|
|
1087
|
+
console.log(await taskOp("release_change", { name, revision: Number(revision), fields }));
|
|
886
1088
|
},
|
|
887
1089
|
|
|
888
1090
|
board: async () => {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1091
|
+
if (args[0] === "import") {
|
|
1092
|
+
if (args[1] !== "github" || (args.length - 2) % 2 !== 0) fail("usage: ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]");
|
|
1093
|
+
const selection: Record<string, string> = {};
|
|
1094
|
+
for (let i = 2; i < args.length; i += 2) { const key = args[i]!, value = args[i+1]; if (!["--repo", "--label", "--milestone"].includes(key) || !value || Object.hasOwn(selection, key.slice(2))) fail("invalid GitHub import options"); selection[key.slice(2)] = value; }
|
|
1095
|
+
if (!selection.label) fail("GitHub import requires --label");
|
|
1096
|
+
return console.log(await taskOp("github_import", selection));
|
|
1097
|
+
}
|
|
1098
|
+
const options = outputOptions();
|
|
1099
|
+
const { one, rest } = takeFlags(args, ["--release", "--release-id"], []);
|
|
1100
|
+
if (one["--release"] !== undefined && !one["--release"].trim() || one["--release"] !== undefined && one["--release-id"] !== undefined || one["--release-id"] !== undefined && !/^[1-9]\d*$/.test(one["--release-id"])) fail("choose --release <nonempty name> or --release-id <positive id>");
|
|
1101
|
+
const release = one["--release-id"] ? "#"+one["--release-id"] : one["--release"];
|
|
1102
|
+
const boardHub = await connect();
|
|
1103
|
+
try {
|
|
1104
|
+
let boardStatus: any;
|
|
1105
|
+
if (release) { const reply = await boardHub.request({ t: "status" }); boardStatus = reply.status; if (!supportsBoardReleaseFilter(boardStatus)) fail("running hub does not support release filters; use a compatible daemon"); }
|
|
1106
|
+
const ready = rest.includes("--ready"), byColumn = rest.includes("--columns");
|
|
1107
|
+
const state = rest.find((a) => !a.startsWith("--"));
|
|
1108
|
+
if (byColumn && !boardStatus) boardStatus = (await boardHub.request({ t: "status" })).status;
|
|
1109
|
+
if (byColumn && !supportsBoardColumnMetadata(boardStatus)) fail("running hub does not support accurate board column metadata; use a compatible daemon");
|
|
1110
|
+
const tasks = JSON.parse(await taskOp("hub_task_list", { ...(ready ? { ready: true } : state ? { state } : {}), ...(release ? { release } : {}), ...(byColumn ? { columnMetadata: true } : {}) }, false, boardHub)) as any[];
|
|
1111
|
+
if (options.json && !byColumn) return console.log(JSON.stringify(tasks, null, 2));
|
|
1112
|
+
let allTasks: any[] | null = tasks;
|
|
1113
|
+
if (ready || state || release) {
|
|
1114
|
+
try { allTasks = JSON.parse(await taskOp("hub_task_list", byColumn ? { columnMetadata: true } : {}, true, boardHub)) as any[]; }
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
if (byColumn) fail("board column dependency context unavailable; retry with a complete snapshot");
|
|
1117
|
+
allTasks = null;
|
|
1118
|
+
console.error(paint([{ text: `ahub: board dependency stages unavailable; showing filtered rows without stages: ${String((error as Error).message).replace(/[\n\t]/g, " ")}` }], false));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (byColumn) {
|
|
1122
|
+
const status = (await boardHub.request({ t: "status" })).status; if (!status?.peers) fail("peer status unavailable"); if (options.json) console.log(JSON.stringify({ tasks, ...boardColumnModel(tasks, status.peers, allTasks) }, null, 2)); else printOutput(renderBoardColumns(tasks, status.peers, options.columns, Date.now(), options.full, allTasks), options.color);
|
|
1123
|
+
} else printOutput(renderBoard(tasks, options.columns, Date.now(), options.full, allTasks), options.color);
|
|
1124
|
+
} finally { boardHub.close(); }
|
|
1125
|
+
},
|
|
1126
|
+
|
|
1127
|
+
sdlc: async () => {
|
|
1128
|
+
const sub = args[0], id = args[1] === undefined ? undefined : Number(args[1]);
|
|
1129
|
+
if (sub === "queue" || sub === "release") {
|
|
1130
|
+
const repo = args[1], liveIssues = args.slice(2).map(Number);
|
|
1131
|
+
if (!repo || args.length > 34 || (sub === "queue" && args.length !== 2) || liveIssues.some(id => !Number.isSafeInteger(id) || id <= 0)) fail("usage: ahub sdlc queue <owner/repository> | release <owner/repository> [live-issue...]");
|
|
1132
|
+
const hub = await connect();
|
|
1133
|
+
try { const reply = await hub.request({ t: "sdlc", op: sub, repo, liveIssues }, 30_000); if (!reply.ok) fail(reply.error); console.log(JSON.stringify(reply, null, 2)); }
|
|
1134
|
+
finally { hub.close(); }
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (sub === "record") {
|
|
1138
|
+
if (args.length !== 3 || !Number.isSafeInteger(id) || id! <= 0 || Buffer.byteLength(args[2]!) > 32768) fail("usage: ahub sdlc record <task> '<receipt JSON>'");
|
|
1139
|
+
let receipt: unknown; try { receipt = JSON.parse(args[2]!); } catch { fail("receipt must be valid JSON"); }
|
|
1140
|
+
return console.log(await taskOp("hub_sdlc_record", { id, receipt }));
|
|
1141
|
+
}
|
|
1142
|
+
if (!["status", "explain", "export"].includes(sub ?? "") || args.length > 2 || (["explain", "export"].includes(sub ?? "") && id === undefined) || (id !== undefined && (!Number.isSafeInteger(id) || id <= 0))) fail("usage: ahub sdlc status [task] | explain <task> | export <task>");
|
|
1143
|
+
const hub = await connect();
|
|
1144
|
+
try {
|
|
1145
|
+
const reply = await hub.request({ t: "sdlc", op: sub, ...(id === undefined ? {} : { id }) }, 30_000);
|
|
1146
|
+
if (!reply.ok) fail(reply.error);
|
|
1147
|
+
console.log(JSON.stringify(reply, null, 2));
|
|
1148
|
+
} finally { hub.close(); }
|
|
894
1149
|
},
|
|
895
1150
|
|
|
896
1151
|
task: async () => {
|
|
897
1152
|
const [sub, ...rest] = args;
|
|
1153
|
+
if (sub === "role") {
|
|
1154
|
+
if (rest.length !== 2 || !/^[1-9]\d*$/.test(rest[0]!)) fail("usage: ahub task role <id> <role|none>");
|
|
1155
|
+
const hub = await connect();
|
|
1156
|
+
try { const reply = await hub.request({ t: "task_catalog_role", id: Number(rest[0]), role: rest[1] === "none" ? null : rest[1] }); if (!reply.ok) fail(reply.error); console.log(reply.text); }
|
|
1157
|
+
finally { hub.close(); }
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
898
1160
|
if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
|
|
899
1161
|
if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
|
|
900
|
-
if (sub === "assign")
|
|
1162
|
+
if (sub === "assign") {
|
|
1163
|
+
const { one, rest: positional } = takeFlags(rest, ["--role"], []);
|
|
1164
|
+
const role = one["--role"] ?? "owner";
|
|
1165
|
+
if (positional.length !== 2 || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer]");
|
|
1166
|
+
return console.log(await taskOp("task_assign", { id: positional[0], peer: positional[1], ...(role === "reviewer" ? { role } : {}) }));
|
|
1167
|
+
}
|
|
901
1168
|
if (sub === "label") {
|
|
902
1169
|
// #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
|
|
903
1170
|
const { one, rest: positional } = takeFlags(rest, ["--created"], []);
|
|
@@ -912,20 +1179,20 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
912
1179
|
if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
|
|
913
1180
|
// `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
|
|
914
1181
|
// short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
|
|
915
|
-
const urgent = rest.includes("--urgent");
|
|
916
|
-
const flags = takeFlags(rest.filter((a) => a !== "--urgent"), ["--owner", "--detail", "--class"], ["--path", "--after"]);
|
|
917
|
-
const positional = !flags.one["--class"] && (CLASSES as readonly string[]).includes(flags.rest[0] ?? "") && flags.rest.length > 1;
|
|
1182
|
+
const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
|
|
1183
|
+
const flags = takeFlags(rest.filter((a) => a !== "--urgent" && a !== "--backlog"), ["--owner", "--detail", "--class"], ["--path", "--after"]);
|
|
1184
|
+
const positional = !flags.one["--class"] && ((CLASSES as readonly string[]).includes(flags.rest[0] ?? "") || Object.hasOwn((() => { const config = projectConfig(); return resolveProfile(config.profile, config.profile_overrides); })().classes, flags.rest[0] ?? "")) && flags.rest.length > 1;
|
|
918
1185
|
const cls = flags.one["--class"] ?? (positional ? flags.rest[0] : undefined);
|
|
919
1186
|
const title = (positional ? flags.rest.slice(1) : flags.rest).join(" ");
|
|
920
1187
|
if (positional) console.error(`note: "${cls}" was taken as the class and left out of the title; use --class <c> when the title itself starts with that word`);
|
|
921
|
-
console.log(await taskOp("hub_task_propose", { ...(cls ? { class: cls } : {}), title, owner: flags.one["--owner"], detail: flags.one["--detail"], ...(flags.many["--path"]?.length ? { refs: { paths: flags.many["--path"] } } : {}), ...(flags.many["--after"]?.length ? { after: flags.many["--after"].map(Number) } : {}), ...(urgent ? { urgent: true } : {}) }));
|
|
1188
|
+
console.log(await taskOp("hub_task_propose", { ...(cls ? { class: cls } : {}), title, owner: flags.one["--owner"], detail: flags.one["--detail"], ...(flags.many["--path"]?.length ? { refs: { paths: flags.many["--path"] } } : {}), ...(flags.many["--after"]?.length ? { after: flags.many["--after"].map(Number) } : {}), ...(urgent ? { urgent: true } : {}), ...(backlog ? { backlog: true } : {}) }));
|
|
922
1189
|
},
|
|
923
1190
|
|
|
924
1191
|
review: async () => {
|
|
925
|
-
const { many, rest } = takeFlags(args, [], ["--unmet"]);
|
|
1192
|
+
const { one, many, rest } = takeFlags(args, ["--head"], ["--unmet"]);
|
|
926
1193
|
const [id, verdict, ...note] = rest;
|
|
927
1194
|
if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]...");
|
|
928
|
-
console.log(await taskOp("hub_review", { id: Number(id), verdict, note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
|
|
1195
|
+
console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
|
|
929
1196
|
},
|
|
930
1197
|
|
|
931
1198
|
remember: async () => console.log(await taskOp("hub_remember", { text: freeText(args, "ahub remember <text>") })),
|
|
@@ -990,6 +1257,99 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
990
1257
|
} finally { hub.close(); }
|
|
991
1258
|
},
|
|
992
1259
|
|
|
1260
|
+
// #269: the terminal's side of the settings registry. Everything the dashboard's Settings section can do, and the
|
|
1261
|
+
// same refusals; it talks to the running hub, so a setting is validated and announced in one place.
|
|
1262
|
+
// ponytail: needs a running hub, also for a value read only at hub start; a file-only path if that gets in the way.
|
|
1263
|
+
roster: async () => {
|
|
1264
|
+
const options = outputOptions(), flags = args.filter(arg => arg.startsWith("--")), positional = outputArguments().filter(arg => arg !== "--yes" && arg !== "--now");
|
|
1265
|
+
const [sub = "list", peer, ...extra] = positional;
|
|
1266
|
+
if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !isSupportedPeer(peer)) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
|
|
1267
|
+
const hub = await connect();
|
|
1268
|
+
try {
|
|
1269
|
+
const request = async (message: Record<string, unknown>) => { const reply = await hub.request(message, 35_000); if (reply.ok !== true) fail(String(reply.error ?? "roster request refused")); return reply; };
|
|
1270
|
+
if (sub === "list") {
|
|
1271
|
+
const view = await request({ t: "settings_get" });
|
|
1272
|
+
if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
|
|
1273
|
+
return options.json ? console.log(JSON.stringify(view.roster, null, 2)) : printOutput(renderRoster(view.roster, options.columns), options.color);
|
|
1274
|
+
}
|
|
1275
|
+
const key = `roster.${peer}`, value = sub === "add";
|
|
1276
|
+
const preview = await request({ t: "settings_preview", key, value });
|
|
1277
|
+
printOutput((preview.lines ?? []).map((text: string) => [{ text }]), options.color);
|
|
1278
|
+
if (sub === "remove" && !flags.includes("--yes")) {
|
|
1279
|
+
if (!process.stdin.isTTY) fail("not a terminal: nothing was changed; rerun with --yes after reviewing the preview");
|
|
1280
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1281
|
+
let answer: string; try { answer = await rl.question(`Remove ${peer}? [y/N] `); } finally { rl.close(); }
|
|
1282
|
+
if (!/^(y|yes)$/i.test(answer.trim())) return console.log("nothing was changed");
|
|
1283
|
+
}
|
|
1284
|
+
const result = await request({ t: "settings_set", key, value, ...(sub === "remove" ? { confirm: peer, now: flags.includes("--now") } : {}) });
|
|
1285
|
+
printOutput([[{ text: result.text }]], options.color);
|
|
1286
|
+
} finally { hub.close(); }
|
|
1287
|
+
},
|
|
1288
|
+
settings: async () => {
|
|
1289
|
+
const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo";
|
|
1290
|
+
const flags = args.filter((arg) => arg.startsWith("--")), [sub = "list", key, text, ...extra] = args.filter((arg) => !arg.startsWith("--"));
|
|
1291
|
+
const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [] };
|
|
1292
|
+
if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
|
|
1293
|
+
if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo") && key !== undefined)) fail(usage);
|
|
1294
|
+
const def = key === undefined ? undefined : settingDef(key);
|
|
1295
|
+
if (typeof def === "string") fail(`${key}: ${def}`);
|
|
1296
|
+
if (flags.includes("--now") && (typeof def !== "object" || def.group !== "Roster")) fail(usage);
|
|
1297
|
+
const value = sub === "set" ? parseSettingText(def!, text!) : undefined;
|
|
1298
|
+
const peer = def?.group === "Permissions" || def?.group === "Roster" ? def.path.at(-1)! : undefined;
|
|
1299
|
+
if (sub === "set" && value === "never-ask" && !flags.includes("--preview")) {
|
|
1300
|
+
if (!flags.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
|
|
1301
|
+
console.error(`never-ask: ${permissionBoundary(peer!)}; approval prompts are disabled`);
|
|
1302
|
+
}
|
|
1303
|
+
const hub = await connect();
|
|
1304
|
+
try {
|
|
1305
|
+
const request = async (message: Record<string, unknown>) => {
|
|
1306
|
+
const reply = await hub.request(message, 35_000);
|
|
1307
|
+
if (reply.ok !== true) {
|
|
1308
|
+
const error = String(reply.error ?? "settings request refused");
|
|
1309
|
+
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);
|
|
1310
|
+
}
|
|
1311
|
+
return reply;
|
|
1312
|
+
};
|
|
1313
|
+
if (sub === "undo") {
|
|
1314
|
+
const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo);
|
|
1315
|
+
const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
|
|
1316
|
+
let confirm: string | undefined;
|
|
1317
|
+
if (rosterPeer) {
|
|
1318
|
+
if (!process.stdin.isTTY) fail("undoing a roster setting needs confirmation in a terminal; use ahub settings set for an explicit scripted change");
|
|
1319
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1320
|
+
try { confirm = (await rl.question(`Type ${rosterPeer} to confirm this roster undo: `)).trim(); } finally { rl.close(); }
|
|
1321
|
+
}
|
|
1322
|
+
return console.log(paint([{ text: (await request({ t: "settings_undo", ...(confirm ? { confirm } : {}) })).text }], false));
|
|
1323
|
+
}
|
|
1324
|
+
if (sub === "set" && flags.includes("--preview")) {
|
|
1325
|
+
const preview = await request({ t: "settings_preview", key, value });
|
|
1326
|
+
for (const line of preview.lines ?? []) console.log(paint([{ text: line }], false));
|
|
1327
|
+
// Each side is `route explain`: the task line, "if it were assigned now:", then the trace.
|
|
1328
|
+
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"));
|
|
1329
|
+
if (preview.hidden) console.log(`${preview.hidden} more open tasks are not shown`);
|
|
1330
|
+
return console.log("preview only; nothing was changed");
|
|
1331
|
+
}
|
|
1332
|
+
if (sub === "set") {
|
|
1333
|
+
const reply = await request({ t: "settings_set", key, value, now: flags.includes("--now"), ...(flags.includes("--yes") && peer ? { confirm: peer } : {}) });
|
|
1334
|
+
console.log(paint([{ text: reply.text }], false));
|
|
1335
|
+
if (typeof reply.note === "string" && reply.note) console.log(paint([{ text: reply.note }], false));
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const view = await request({ t: "settings_get" });
|
|
1339
|
+
const rows = (view.rows as SettingRow[]).filter((row) => sub === "list" || row.key === key);
|
|
1340
|
+
if (flags.includes("--json")) return console.log(JSON.stringify(sub === "get" ? rows[0] : { rows, ...(view.undo ? { undo: view.undo } : {}) }, null, 2));
|
|
1341
|
+
const width = Math.max(...rows.map((row) => row.key.length)), valueWidth = Math.max(...rows.map((row) => settingText(row.value).length));
|
|
1342
|
+
let group = "";
|
|
1343
|
+
for (const row of rows) {
|
|
1344
|
+
if (sub === "list" && row.group !== group) console.log(`${group ? "\n" : ""}${group = row.group}`);
|
|
1345
|
+
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 ${row.applies === "peer start" ? "peer" : "hub"} start`] : []), ...(row.note ? [row.note] : [])];
|
|
1346
|
+
console.log(` ${row.key.padEnd(width)} ${settingText(row.value).padEnd(valueWidth)} ${facts.join("; ")}`);
|
|
1347
|
+
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.type === "roles" ? "up to two comma-separated catalog 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" : ""}`);
|
|
1348
|
+
}
|
|
1349
|
+
if (sub === "list" && view.undo) console.log(`\nahub settings undo puts back the last write (${view.undo})`);
|
|
1350
|
+
} finally { hub.close(); }
|
|
1351
|
+
},
|
|
1352
|
+
|
|
993
1353
|
permit: async () => {
|
|
994
1354
|
const [id, option] = args;
|
|
995
1355
|
if (!id || !option) fail("usage: ahub permit <id> <option|deny>");
|
|
@@ -999,24 +1359,30 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
999
1359
|
hub.close();
|
|
1000
1360
|
},
|
|
1001
1361
|
|
|
1362
|
+
pet: () => {
|
|
1363
|
+
if (args.some(arg => arg !== "--short")) return fail("usage: ahub pet [--short]");
|
|
1364
|
+
const short = args.includes("--short");
|
|
1365
|
+
let character: import("../pet/frame.ts").CharacterId | undefined;
|
|
1366
|
+
try { character = loadPetConfig(cwd).character; } catch { /* explicit structural reads remain available with malformed presentation config */ }
|
|
1367
|
+
const text = renderPet(readPet(stateDir), short, character);
|
|
1368
|
+
process.stdout.write(short ? text : text + "\n");
|
|
1369
|
+
},
|
|
1370
|
+
|
|
1002
1371
|
status: async () => {
|
|
1003
|
-
|
|
1372
|
+
const options = outputOptions();
|
|
1373
|
+
if (args.includes("--all")) return printProjects(options);
|
|
1004
1374
|
const hub = await connect();
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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)");
|
|
1375
|
+
try {
|
|
1376
|
+
const { status } = await hub.request({ t: "status" });
|
|
1377
|
+
if (options.json) return console.log(JSON.stringify(status, null, 2));
|
|
1378
|
+
const quota = await hub.request({ t: "budget" });
|
|
1379
|
+
printOutput(renderStatus({ ...status, budget: quota.budget ?? {} }, options.columns, Date.now(), options.full), options.color);
|
|
1380
|
+
if (status.needs) { const pet = loadPetConfig(cwd); if (pet.enabled) console.log(renderPet(petView(status), false, pet.character)); }
|
|
1381
|
+
// #258: open long-term tasks and who holds them, as of this machine's last memory sync.
|
|
1382
|
+
if (existsSync(join(hubHome(), "memory", "index.db"))) {
|
|
1383
|
+
try { const lines = await goalCommand(["list"], cwd); if (!lines.startsWith("no open")) console.log(`\nlong-term tasks\n${lines}`); } catch { /* memory unreadable: status goes on */ }
|
|
1384
|
+
}
|
|
1385
|
+
} finally { hub.close(); }
|
|
1020
1386
|
},
|
|
1021
1387
|
|
|
1022
1388
|
logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
|
|
@@ -1028,18 +1394,26 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1028
1394
|
const json = args.includes("--json");
|
|
1029
1395
|
const [sub, ...rest] = args.filter((a) => a !== "--json");
|
|
1030
1396
|
if (sub === "run") {
|
|
1031
|
-
const { one, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks"], []);
|
|
1397
|
+
const { one, many, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks", "--memory"], ["--addon"]);
|
|
1398
|
+
if (one["--memory"] !== undefined && !(MEMORY_MODES as readonly string[]).includes(one["--memory"])) fail(`--memory takes ${MEMORY_MODES.join(", ")}`);
|
|
1399
|
+
// #248: `--addon ripwire=off` declares the arm's add-on state; the run refuses peers that do not match it.
|
|
1400
|
+
const addons: Partial<Record<AddonName, "on" | "off">> = {};
|
|
1401
|
+
for (const spec of many["--addon"] ?? []) {
|
|
1402
|
+
const [name, state] = spec.split("=");
|
|
1403
|
+
if (!name || !isAddon(name) || (state !== "on" && state !== "off") || addons[name]) fail(`--addon takes <name>=on|off once per add-on, name one of ${ADDONS.join(", ")}`);
|
|
1404
|
+
addons[name] = state;
|
|
1405
|
+
}
|
|
1032
1406
|
const suite = positional[0];
|
|
1033
1407
|
const repeat = one["--repeat"] === undefined ? 1 : Number(one["--repeat"]);
|
|
1034
1408
|
if (!suite || positional.length !== 1 || !one["--arm"]?.trim() || !Number.isSafeInteger(repeat) || repeat < 1 || repeat > 100) {
|
|
1035
|
-
fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b]");
|
|
1409
|
+
fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b] [--addon <name>=on|off]... [--memory off|claude-mem|native|both]");
|
|
1036
1410
|
}
|
|
1037
1411
|
await benchPreflight(cwd, loadConfig(cwd).bench.enabled); // the suite's refs are checked once it is read
|
|
1038
1412
|
let interrupted = false;
|
|
1039
1413
|
const stop = () => { interrupted = true; console.log("stopping at the next check: the attempt in progress is recorded as interrupted (a task still open is named)"); };
|
|
1040
1414
|
process.on("SIGINT", stop);
|
|
1041
1415
|
try {
|
|
1042
|
-
const run = await runBench({ cwd, stateDir, suitePath: resolve(invokedFrom, suite!), arm: one["--arm"]!.trim(), repeat, only: one["--tasks"]?.split(",").map((t) => t.trim()).filter(Boolean), stopped: () => interrupted }, defaultIo(stateDir, cwd));
|
|
1416
|
+
const run = await runBench({ cwd, stateDir, suitePath: resolve(invokedFrom, suite!), arm: one["--arm"]!.trim(), repeat, addons, ...(one["--memory"] ? { memory: one["--memory"] as MemoryMode } : {}), only: one["--tasks"]?.split(",").map((t) => t.trim()).filter(Boolean), stopped: () => interrupted }, defaultIo(stateDir, cwd));
|
|
1043
1417
|
return console.log(formatBenchReport(benchReport(readRuns().filter((r) => r.header.run === run))).join("\n"));
|
|
1044
1418
|
} finally { process.off("SIGINT", stop); }
|
|
1045
1419
|
}
|
|
@@ -1079,12 +1453,25 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1079
1453
|
}
|
|
1080
1454
|
fail("usage: ahub bench run|list|status|report|compare|export (ahub help bench)");
|
|
1081
1455
|
},
|
|
1456
|
+
memory: async () => {
|
|
1457
|
+
// #258: the hub's own memory on this machine; reads are anyone's, changes a person's.
|
|
1458
|
+
try { console.log(await memoryCommand(args, cwd)); } catch (e) { fail((e as Error).message); }
|
|
1459
|
+
},
|
|
1460
|
+
goal: async () => {
|
|
1461
|
+
// #258: long-term tasks; a link goes through the project's hub, so Tasks validates the board task.
|
|
1462
|
+
const link = async (goal: string, task: string) => {
|
|
1463
|
+
const hub = await connect();
|
|
1464
|
+
try { const r = await hub.request({ t: "goal_link", goal, task: Number(task) }); if (!r.ok) throw new Error(String(r.error)); return String(r.text); }
|
|
1465
|
+
finally { hub.close(); }
|
|
1466
|
+
};
|
|
1467
|
+
try { console.log(await goalCommand(args, cwd, { link })); } catch (e) { fail((e as Error).message); }
|
|
1468
|
+
},
|
|
1082
1469
|
research: () => {
|
|
1083
1470
|
// #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.
|
|
1084
1471
|
const [sub] = args;
|
|
1085
1472
|
// Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored.
|
|
1086
1473
|
const flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
|
|
1087
|
-
for (let i = sub === "export" || sub === "backfill" ? 1 : 0; i < args.length; i++) {
|
|
1474
|
+
for (let i = sub === "export" || sub === "backfill" || sub === "addons" ? 1 : 0; i < args.length; i++) {
|
|
1088
1475
|
if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
|
|
1089
1476
|
if (valued.has(args[i]!)) i++;
|
|
1090
1477
|
}
|
|
@@ -1102,20 +1489,23 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1102
1489
|
const kept = records.filter((r) => r.kind === "label" || Date.parse(r.approvedAt) >= from);
|
|
1103
1490
|
return void process.stdout.write(args.includes("--format") && format === "csv" ? toCsv(kept) : kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
|
|
1104
1491
|
}
|
|
1105
|
-
if (sub
|
|
1492
|
+
if (sub === "addons") {
|
|
1493
|
+
// #248: approved tasks with each add-on on against off, by the tooling fingerprint in their records.
|
|
1494
|
+
const rows = addonReport(records, from);
|
|
1495
|
+
return console.log(args.includes("--json") ? JSON.stringify(rows, null, 2) : formatAddons(rows).join("\n"));
|
|
1496
|
+
}
|
|
1497
|
+
if (sub !== undefined && !sub.startsWith("--")) fail("usage: ahub research [--since 30d] [--all] [--json] | addons [--since] [--all] [--json] | export [--format jsonl|csv] [--since] [--all] | backfill");
|
|
1106
1498
|
const r = researchReport(records, from);
|
|
1107
1499
|
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
|
|
1108
1500
|
},
|
|
1109
1501
|
report: () => {
|
|
1502
|
+
const options = outputOptions();
|
|
1110
1503
|
const by = args.indexOf("--by");
|
|
1111
1504
|
if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
|
|
1112
1505
|
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"));
|
|
1506
|
+
const data = by >= 0 ? summarizeByTask(events) : summarize(events);
|
|
1507
|
+
if (options.json) return console.log(JSON.stringify(data, null, 2));
|
|
1508
|
+
printOutput(renderReport(data, options.columns, Date.now(), by >= 0), options.color);
|
|
1119
1509
|
},
|
|
1120
1510
|
facts: async () => {
|
|
1121
1511
|
if (!args.includes("--hook")) return fail("usage: ahub facts --hook (a Claude Code PreToolUse, PostToolUse and Stop hook)");
|
|
@@ -1170,17 +1560,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1170
1560
|
}
|
|
1171
1561
|
},
|
|
1172
1562
|
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
|
-
}
|
|
1563
|
+
const options = outputOptions();
|
|
1564
|
+
const { one, rest } = takeFlags(outputArguments(), ["--limit"], []);
|
|
1565
|
+
const turns = turnRecords(t => t.list(rest[0], Number(one["--limit"]) || 20), []);
|
|
1566
|
+
if (options.json) return console.log(JSON.stringify(turns, null, 2));
|
|
1567
|
+
printOutput(renderTurns(turns, options.columns, Date.now()), options.color);
|
|
1184
1568
|
},
|
|
1185
1569
|
undo: async () => {
|
|
1186
1570
|
const id = args.find((a) => !a.startsWith("--")) ?? fail("usage: ahub undo <turn> [--yes] [--context]");
|
|
@@ -1189,9 +1573,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1189
1573
|
const { start_tree: startTree, end_tree: endTree } = turn;
|
|
1190
1574
|
if (!startTree || !endTree) fail(`turn ${id} has no ${startTree ? "end" : "start"} snapshot: the hub stopped during it, or a snapshot failed (see hub.log)`);
|
|
1191
1575
|
const repo = repoOf(cwd) ?? fail(`${cwd} is not in a git work tree`);
|
|
1192
|
-
if (!hasTree(repo
|
|
1576
|
+
if (!hasTree(repo, startTree) || !hasTree(repo, endTree)) fail(`turn ${id}'s snapshots are gone from the git object store (git gc prunes them after two weeks)`);
|
|
1193
1577
|
// Another peer's turn that overlapped this one in time has its changes in this turn's diff as well.
|
|
1194
|
-
const
|
|
1578
|
+
const undoConfig = projectConfig();
|
|
1579
|
+
const keep = Math.max(1, Number(undoConfig.snapshots.keep) || 20);
|
|
1195
1580
|
const overlapping = turnRecords((t) => t.overlapping(turn, keep), { paths: [] as string[], unknown: [] as string[] });
|
|
1196
1581
|
if (overlapping.unknown.length) fail(`refusing to undo ${id}: these turns of other peers ran at the same time and their changes are not known (still running, cut short, or not snapshotted): ${overlapping.unknown.join(", ")}`);
|
|
1197
1582
|
let reverted = false;
|
|
@@ -1209,6 +1594,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1209
1594
|
if (first.undone) return console.log(`turn ${id} is already undone: its files are as they were before it started`);
|
|
1210
1595
|
console.log(first.restore.length ? `turn ${id} changed:\n ${first.restore.join("\n ")}` : `turn ${id} changed no files`);
|
|
1211
1596
|
console.log("(every change made in the project during the turn counts as its own, including any by Claude or by you)");
|
|
1597
|
+
const projections = new TextProjections({ cwd, stateDir, config: undoConfig.text_projection ?? {}, deny: undoConfig.local.deny, readAllow: undoConfig.local.read_allow, log: line => console.error(line) });
|
|
1598
|
+
const preview = projectedDiff({ repo, from: endTree, to: startTree, paths: first.restore, projections, deny: undoConfig.local.deny, privatePath: path => matchesPolicy(cwd, join(repo.top, path), undoConfig.pii?.paths ?? []) });
|
|
1599
|
+
if (preview) console.log(terminalText("Undo preview (end snapshot to start snapshot):\n" + preview));
|
|
1600
|
+
|
|
1212
1601
|
if (!args.includes("--yes")) return console.log("nothing was changed; add --yes to restore these files");
|
|
1213
1602
|
if (args.includes("--context")) {
|
|
1214
1603
|
console.log(await taskOp("turn_revert", { turn: id }));
|
|
@@ -1311,68 +1700,123 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1311
1700
|
console.log("Claude Code sessions attached to this hub lost their hub session: relaunch them with ahub claude");
|
|
1312
1701
|
},
|
|
1313
1702
|
|
|
1703
|
+
role: async () => {
|
|
1704
|
+
const catalog = loadRoleCatalog(cwd);
|
|
1705
|
+
if (args[0] === "list") {
|
|
1706
|
+
const { one, rest } = takeFlags(args.slice(1), ["--group"], []);
|
|
1707
|
+
if (rest.length) fail("usage: ahub role list [--group <group>]");
|
|
1708
|
+
return console.log(paint([{ text: JSON.stringify({ roles: [...catalog.roles.values()].filter(role => !one["--group"] || role.group === one["--group"]).map(({ body: _body, review: _review, ...role }) => role), errors: catalog.errors }, null, 2) }], false));
|
|
1709
|
+
}
|
|
1710
|
+
if (args[0] === "show" && args.length === 2) {
|
|
1711
|
+
const role = catalog.roles.get(args[1]!); if (!role) fail("unknown catalog role; run ahub role list");
|
|
1712
|
+
return console.log(paint([{ text: JSON.stringify(role, null, 2) }], false));
|
|
1713
|
+
}
|
|
1714
|
+
if (["assign", "clear"].includes(args[0] ?? "")) {
|
|
1715
|
+
const [op, peer, id] = args;
|
|
1716
|
+
if (!peer || !isSupportedPeer(peer) || !id && op === "assign" || args.length > 3) fail("usage: ahub role assign <peer> <id> | clear <peer> [<id>]");
|
|
1717
|
+
const hub = await connect();
|
|
1718
|
+
try {
|
|
1719
|
+
const view = await hub.request({ t: "settings_get" }); if (!view.ok) fail(view.error);
|
|
1720
|
+
const row = (view.rows as SettingRow[]).find(row => row.key === `catalog_roles.${peer}`); if (!row) fail("this hub does not support catalog role settings");
|
|
1721
|
+
const current = Array.isArray(row!.pending) ? row!.pending : Array.isArray(row!.value) ? row!.value : [];
|
|
1722
|
+
const value = op === "assign" ? [...new Set([...current, id!])] : id ? current.filter(role => role !== id) : [];
|
|
1723
|
+
const reply = await hub.request({ t: "settings_set", key: row!.key, value }); if (!reply.ok) fail(reply.error); console.log(reply.text);
|
|
1724
|
+
} finally { hub.close(); }
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
fail("usage: ahub role list [--group <group>] | show <id> | assign <peer> <id> | clear <peer> [<id>]");
|
|
1728
|
+
},
|
|
1314
1729
|
doctor: async () => {
|
|
1315
|
-
|
|
1730
|
+
const options = outputOptions();
|
|
1731
|
+
if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"), options);
|
|
1316
1732
|
const version = (bin: string) => {
|
|
1317
1733
|
const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
|
|
1318
1734
|
return res.status === 0 ? res.stdout.trim().split("\n")[0]! : undefined;
|
|
1319
1735
|
};
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1736
|
+
const checks: DoctorCheck[] = [];
|
|
1737
|
+
const row = (ok: boolean | undefined, name: string, detail: string) => {
|
|
1738
|
+
const section: DoctorCheck["section"] = ["bun", "claude", "codex", "kimi", "qwen", "jcode"].includes(name) ? "Tools"
|
|
1739
|
+
: name === "config" || name === "retired setting" ? "Config"
|
|
1740
|
+
: name.startsWith("memory") ? "Memory"
|
|
1741
|
+
: ["omniroute", "omniroute key", "local fixed_model", "switchyard", "pi mlx"].includes(name) ? "Models" : "Hub";
|
|
1742
|
+
const optional = ["claude", "codex", "kimi", "qwen", "jcode", "ahub daemon", "switchyard", "pi mlx"].includes(name) || section === "Memory";
|
|
1743
|
+
const level: DoctorCheck["level"] = ok === undefined ? (["switchyard", "omniroute"].includes(name) ? "warn" : "unknown") : ok ? "ok" : optional ? "warn" : "fail";
|
|
1744
|
+
checks.push({ section, level, name, detail });
|
|
1745
|
+
};
|
|
1322
1746
|
|
|
1323
|
-
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1326
|
-
}
|
|
1327
|
-
const up = await healthy();
|
|
1328
|
-
row(up, "ahub daemon", up ? readControl(stateDir)!.url : "not running (ahub up)");
|
|
1329
|
-
if (up) {
|
|
1330
|
-
const hub = await connect();
|
|
1331
|
-
try {
|
|
1332
|
-
const { status } = await hub.request({ t: "status" });
|
|
1333
|
-
const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
|
|
1334
|
-
row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
|
|
1335
|
-
const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
|
|
1336
|
-
for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
|
|
1337
|
-
if (!["claude", "codex"].includes(peer.id) || peer.state === "offline") continue;
|
|
1338
|
-
const identity = peer.id === "claude" ? peer.sessionId : peer.threadId;
|
|
1339
|
-
row(!!identity, `${peer.id} recovery ID`, identity ? "recorded" : `missing; verify the current conversation and reconnect with ahub ${peer.id} before upgrading`);
|
|
1340
|
-
}
|
|
1341
|
-
} finally { hub.close(); }
|
|
1342
|
-
}
|
|
1343
|
-
const plugin = pluginState(parseList<InstalledPlugin>(spawnSync("claude", ["plugin", "list", "--json"], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
|
|
1344
|
-
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`);
|
|
1345
|
-
|
|
1346
|
-
const config = loadConfig(cwd);
|
|
1347
|
-
row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
|
|
1348
|
-
for (const line of config.retired ?? []) row(false, "retired setting", line); // issue #83
|
|
1349
|
-
const omni = new OmniRoute(config.omniroute);
|
|
1350
|
-
const gateway = await omni.base();
|
|
1351
|
-
row(!!gateway, "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");
|
|
1352
|
-
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.local.json");
|
|
1353
|
-
if (gateway && omni.apiKey()) {
|
|
1747
|
+
try {
|
|
1748
|
+
const catalog = loadRoleCatalog(cwd);
|
|
1749
|
+
for (const error of catalog.errors) row(false, "catalog role", `${error.id}: ${error.reason}`);
|
|
1354
1750
|
try {
|
|
1355
|
-
const
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1751
|
+
const config = loadConfig(cwd), assigned = catalogAssignments(config.catalog_roles);
|
|
1752
|
+
for (const peer of Object.keys(assigned)) selectedRoles(peer, config.roles, assigned, catalog);
|
|
1753
|
+
} catch { row(false, "catalog roles", "invalid assignment id, shape or flow base"); }
|
|
1754
|
+
for (const bin of ["bun", "claude", "codex", "kimi"]) {
|
|
1755
|
+
const v = version(bin);
|
|
1756
|
+
row(!!v, bin, v ?? "not found on PATH");
|
|
1757
|
+
}
|
|
1758
|
+
const up = await healthy();
|
|
1759
|
+
row(up, "ahub daemon", up ? readControl(stateDir)!.url : "not running (ahub up)");
|
|
1760
|
+
if (up) {
|
|
1761
|
+
const hub = await connect();
|
|
1762
|
+
try {
|
|
1763
|
+
const { status } = await hub.request({ t: "status" });
|
|
1764
|
+
const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
|
|
1765
|
+
row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
|
|
1766
|
+
const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
|
|
1767
|
+
for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
|
|
1768
|
+
if (!["claude", "codex"].includes(peer.id) || peer.state === "offline") continue;
|
|
1769
|
+
const identity = peer.id === "claude" ? peer.sessionId : peer.threadId;
|
|
1770
|
+
row(!!identity, `${peer.id} recovery ID`, identity ? "recorded" : `missing; verify the current conversation and reconnect with ahub ${peer.id} before upgrading`);
|
|
1771
|
+
}
|
|
1772
|
+
} finally { hub.close(); }
|
|
1773
|
+
}
|
|
1774
|
+
const plugin = pluginState(parseList<InstalledPlugin>(spawnSync("claude", ["plugin", "list", "--json"], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
|
|
1775
|
+
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`);
|
|
1776
|
+
|
|
1777
|
+
const config = loadConfig(cwd);
|
|
1778
|
+
row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
|
|
1779
|
+
for (const line of config.retired ?? []) row(false, "retired setting", line);
|
|
1780
|
+
if (config.roster.jcode === true) {
|
|
1781
|
+
row(verifiedJcodeCommand(config.jcode_cmd, cwd), "jcode", "CLI/source identity only; provider, project MCP and native teardown require separate receipts; native policy unmanaged");
|
|
1782
|
+
}
|
|
1783
|
+
if (config.roster.qwen === true) {
|
|
1784
|
+
const v = version(config.qwen_cmd[0]!);
|
|
1785
|
+
row(v === "0.25.0", "qwen", v === "0.25.0" ? "CLI reports 0.25.0; native ACP identity/modes and provider access require a separate live check" : "Qwen Code 0.25.0 executable not confirmed; provider and ACP readiness unknown");
|
|
1786
|
+
} // issue #83
|
|
1787
|
+
const omni = new OmniRoute(config.omniroute);
|
|
1788
|
+
const gateway = await omni.base();
|
|
1789
|
+
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");
|
|
1790
|
+
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.local.json");
|
|
1791
|
+
if (gateway && omni.apiKey()) {
|
|
1792
|
+
try {
|
|
1793
|
+
const fixed = currentRouting(cwd).local.fixed_model;
|
|
1794
|
+
const served = (await omni.models()).includes(fixed);
|
|
1795
|
+
row(served, "local fixed_model", served ? `${fixed} served` : `${fixed} is not served by the gateway`);
|
|
1796
|
+
} catch { row(undefined, "local fixed_model", "could not read the gateway model inventory"); }
|
|
1797
|
+
}
|
|
1798
|
+
const sy = spawnSync(process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server", ["--version"], { encoding: "utf8" });
|
|
1799
|
+
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)");
|
|
1800
|
+
const mlxConfig = config.mlx;
|
|
1801
|
+
if (mlxConfig.enabled === false) row(true, "pi mlx", "disabled (mlx.enabled=false; local endpoint not probed)");
|
|
1802
|
+
else {
|
|
1803
|
+
const mlx = await inspectMlx({ ...mlxConfig, runtimeDir: mlxConfig.runtimeDir ? resolve(cwd, mlxConfig.runtimeDir) : undefined, modelPath: mlxConfig.modelPath ? resolve(cwd, mlxConfig.modelPath) : undefined });
|
|
1804
|
+
row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
|
|
1805
|
+
}
|
|
1368
1806
|
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1807
|
+
const memory = new MemoryClient();
|
|
1808
|
+
const mem = await memory.health();
|
|
1809
|
+
row(mem.ok, "memory worker", mem.ok ? `claude-mem ${mem.version ?? ""} at ${memory.url}` : `unavailable at ${memory.url} (the hub works without it)`);
|
|
1810
|
+
const bridge = spawnSync("dot", ["ai", "memory", "status"], { encoding: "utf8" });
|
|
1811
|
+
const out = bridge.error ? "" : bridge.stdout.replace(/\x1b\[[0-9;]*m/g, "");
|
|
1812
|
+
for (const tool of ["codex", "kimi"]) {
|
|
1813
|
+
const ready = new RegExp(`✓\\s+${tool} ready`).test(out);
|
|
1814
|
+
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`");
|
|
1815
|
+
}
|
|
1816
|
+
} finally {
|
|
1817
|
+
// Preserve findings already collected if a later probe (for example config parsing) throws.
|
|
1818
|
+
if (options.json) console.log(JSON.stringify(checks, null, 2));
|
|
1819
|
+
else printOutput(renderDoctor(checks, options.columns, Date.now(), options.full), options.color);
|
|
1376
1820
|
}
|
|
1377
1821
|
},
|
|
1378
1822
|
};
|
|
@@ -1382,17 +1826,21 @@ async function runConductorCommand(): Promise<void> {
|
|
|
1382
1826
|
let input: Record<string, unknown>;
|
|
1383
1827
|
if (cmd === "task") {
|
|
1384
1828
|
op = args[0] === "assign" ? "hub_task_assign" : "hub_task_escalate";
|
|
1385
|
-
|
|
1386
|
-
|
|
1829
|
+
const { one, rest: positional } = takeFlags(args.slice(1), args[0] === "assign" ? ["--role"] : [], []);
|
|
1830
|
+
const role = one["--role"] ?? "owner";
|
|
1831
|
+
if (!positional[0] || positional.length !== (args[0] === "assign" ? 2 : 1) || !["owner", "reviewer"].includes(role)) fail("usage: ahub task assign <id> <peer> [--role owner|reviewer] | escalate <id>");
|
|
1832
|
+
input = { id: Number(positional[0]), ...(args[0] === "assign" ? { peer: positional[1], ...(role === "reviewer" ? { role } : {}) } : {}) };
|
|
1387
1833
|
} else if (cmd === "pause" || cmd === "resume") {
|
|
1388
1834
|
if (args.length !== 1) fail(`usage: ahub ${cmd} <peer>`);
|
|
1389
1835
|
op = cmd === "pause" ? "hub_peer_hold" : "hub_peer_release";
|
|
1390
1836
|
input = { peer: args[0] };
|
|
1391
1837
|
} else {
|
|
1392
1838
|
// Native TUIs return a human launch command through the daemon; an agent shell never executes it.
|
|
1839
|
+
if (cmd === "pi" && args.length === 1 && args[0] === "--headless") args.splice(0, 1, "--mode", "headless");
|
|
1393
1840
|
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
1841
|
op = "hub_peer_start";
|
|
1395
|
-
|
|
1842
|
+
// #269: a mode may only repeat the peer's start mode, which a person sets; without one the hub takes that mode.
|
|
1843
|
+
input = { peer: cmd, ...(cmd === "pi" && args[1] ? { mode: args[1] } : {}) };
|
|
1396
1844
|
}
|
|
1397
1845
|
console.log(await taskOp(op, input));
|
|
1398
1846
|
}
|