@staix/agent-hub 0.12.19 → 0.12.21
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 +38 -0
- package/README.md +7 -6
- package/docs/agent-notes/adapters.md +10 -2
- package/docs/agent-notes/bus.md +6 -2
- package/docs/agent-notes/daemon.md +10 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +6 -3
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +6 -2
- package/docs/operations.md +655 -34
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +59 -17
- package/docs/smoke.md +137 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +65 -26
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +70 -2
- package/src/adapters/local-worker.ts +8 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +531 -110
- package/src/cli/console.ts +59 -26
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +130 -0
- package/src/cli/identity.ts +11 -5
- package/src/cli/launch.ts +98 -17
- package/src/cli/main.ts +325 -88
- package/src/cli/preview.ts +14 -3
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +10 -5
- package/src/cli/terminal-recovery.ts +196 -40
- package/src/cli/upgrade-runtime.ts +327 -60
- package/src/cli/upgrade.ts +442 -45
- package/src/hub/ask.ts +1 -1
- package/src/hub/bench.ts +211 -0
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +79 -8
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +54 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +9 -4
- package/src/hub/daemon.ts +401 -48
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +9 -2
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +13 -12
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -0
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +68 -8
- package/src/hub/registry.ts +36 -22
- package/src/hub/report.ts +43 -1
- package/src/hub/research.ts +255 -0
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +295 -115
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +2 -1
- package/src/local/tools.ts +14 -10
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +84 -6
- package/src/ui/task-progress.ts +33 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
package/src/cli/main.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { isPermissionMode } from "../hub/permission-mode.ts";
|
|
2
3
|
import { currentRouting } from "../hub/routing.ts";
|
|
3
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
5
|
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -11,20 +12,21 @@ import { factsHook } from "./facts-hook.ts";
|
|
|
11
12
|
import { projectContext, realPath } from "../hub/project.ts";
|
|
12
13
|
import { Registry, type Project } from "../hub/registry.ts";
|
|
13
14
|
import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
|
|
15
|
+
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
14
16
|
import { openManager, startManager, stopManager } from "../hub/manager.ts";
|
|
15
17
|
import { OmniRoute } from "../omniroute/client.ts";
|
|
16
18
|
import { MemoryClient } from "../memory/client.ts";
|
|
17
19
|
import { init, planInit } from "./init.ts";
|
|
18
20
|
import { launcherPreview } from "./preview.ts";
|
|
19
|
-
import { buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
|
|
21
|
+
import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
|
|
20
22
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
21
23
|
import { CLASSES } from "../hub/board.ts";
|
|
22
24
|
import { VERSION } from "../version.ts";
|
|
23
25
|
import { freeText } from "./free-text.ts";
|
|
24
26
|
import { createInterface } from "node:readline/promises";
|
|
25
|
-
import { assertLifecycleAvailable, readOperation } from "../hub/recovery-store.ts";
|
|
27
|
+
import { activeOperation, assertLifecycleAvailable, readOperation, recoveryLock, recoveryRunner } from "../hub/recovery-store.ts";
|
|
26
28
|
import { childEnv } from "../hub/child-process.ts";
|
|
27
|
-
import { abortRecovery, createOperation, publicOperation, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
|
|
29
|
+
import { abortRecovery, createOperation, disposeRecovery, liveProjects, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
|
|
28
30
|
import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
29
31
|
import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
30
32
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
@@ -33,14 +35,19 @@ import { setupOllamaModel } from "./models-setup.ts";
|
|
|
33
35
|
import { unknownContext } from "../hub/context-window.ts";
|
|
34
36
|
import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
35
37
|
import { parseSince, readEvents } from "../hub/events.ts";
|
|
38
|
+
import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, readRuns, runSummary, sameSuite } from "../hub/bench.ts";
|
|
39
|
+
import { benchPreflight, defaultIo, runBench } from "./bench.ts";
|
|
40
|
+
import { appendRecords, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
|
|
36
41
|
import { formatReport, summarize, formatTaskReport, summarizeByTask } from "../hub/report.ts";
|
|
37
42
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
38
43
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
39
44
|
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
|
|
40
45
|
import { recordCliAudit } from "./identity-audit.ts";
|
|
41
46
|
import { runConsole } from "./console.ts";
|
|
42
|
-
import { resolveColor } from "./console-state.ts";
|
|
47
|
+
import { permissionBoundary, resolveColor } from "./console-state.ts";
|
|
48
|
+
import { renderHelp } from "./help.ts";
|
|
43
49
|
import { renderTailEvent } from "./tail-render.ts";
|
|
50
|
+
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
44
51
|
|
|
45
52
|
/** `--since 7d|24h|<iso>` for export and report; everything when absent. */
|
|
46
53
|
const since = (): number => {
|
|
@@ -51,62 +58,6 @@ const since = (): number => {
|
|
|
51
58
|
return t;
|
|
52
59
|
};
|
|
53
60
|
|
|
54
|
-
const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
|
|
55
|
-
|
|
56
|
-
ahub --project <path|id> <command> select a repository or worktree explicitly
|
|
57
|
-
ahub projects [--json] list registered projects and live status
|
|
58
|
-
ahub projects remove <id> forget a stopped registration (keeps project files)
|
|
59
|
-
ahub status --all show every registered project
|
|
60
|
-
ahub ui --all [--no-open] open the unified project dashboard
|
|
61
|
-
ahub ui --all --stop stop only the dashboard manager
|
|
62
|
-
ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
|
|
63
|
-
ahub init [--dry-run --json] preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
|
|
64
|
-
ahub up [--unattended] [--no-console] start the daemon; interactive terminals enter console
|
|
65
|
-
ahub console [--panels] [--color=auto|always|never] enter the human console, leaving the daemon running on exit
|
|
66
|
-
ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
|
|
67
|
-
ahub restart [--dry-run] [--yes] recover this project's runtime
|
|
68
|
-
ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
|
|
69
|
-
ahub claude [--print-command] [args...] launch Claude Code with the hub channel [--unattended]
|
|
70
|
-
ahub codex [--print-command] [args...] start the Codex adapter and attach the TUI [--unattended]
|
|
71
|
-
ahub kimi [--print-command] [--model <alias>] start Kimi headless under ACP
|
|
72
|
-
ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] start Pi
|
|
73
|
-
ahub models setup|status|start|stop prepare or inspect local Ollama MLX (legacy stop is explicit)
|
|
74
|
-
ahub local [--route <id> | --model <id>]
|
|
75
|
-
start the hub-native worker on the self-hosted models (routing.toml)
|
|
76
|
-
ahub say [@peer ...] <text> send as the console user (no @peer = broadcast); delivered at once,
|
|
77
|
-
start the text with [STATUS] to let it batch or [FYI] for the record only
|
|
78
|
-
ahub pause|resume <peer> hold a peer's deliveries in its queue / release them
|
|
79
|
-
ahub budget execution configure <config.json> | status [id] | disable <id>
|
|
80
|
-
ahub budget quota windows per peer, and who is paused until when
|
|
81
|
-
ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week] feed a reading by hand (also: test the relay)
|
|
82
|
-
ahub budget resume <peer> override a budget pause; readings are ignored for that peer until the window resets
|
|
83
|
-
ahub board [state | --ready] the task board; --ready: proposed tasks with nothing left to wait for
|
|
84
|
-
ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--detail <text>]
|
|
85
|
-
ahub task show|escalate <id> full task with history (PII text included) / hand it to the next peer in escalate_to
|
|
86
|
-
ahub task assign <id> <peer> give a task to a peer yourself
|
|
87
|
-
ahub review <id> approved|changes_requested [note...] [--unmet <item>]...
|
|
88
|
-
ahub remember <text...> save a note to the memory all agents share
|
|
89
|
-
ahub ask [--remember] <question...> answer from the task board, shared memory and this run's log, with the ids it rests on
|
|
90
|
-
ahub route explain <id> why a task went where it went
|
|
91
|
-
ahub route explain --class <c> <title...> what would happen to such a task now
|
|
92
|
-
ahub ui [--no-open] open the local dashboard (or print its one-time link)
|
|
93
|
-
ahub tail live stream of messages, states and permission requests
|
|
94
|
-
ahub permit <id> <option> answer a permission request shown by tail ("deny" cancels)
|
|
95
|
-
ahub queue list [--peer <id>] [--json] inspect durable deliveries
|
|
96
|
-
ahub queue show <delivery-id> inspect one delivery and revision
|
|
97
|
-
ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>
|
|
98
|
-
ahub status | logs [-f] | doctor | kill
|
|
99
|
-
ahub export [--since 7d|<iso>] structured events (events.jsonl) as JSON lines; never message bodies
|
|
100
|
-
ahub report [--since 7d|<iso>] [--by task] [--json] turns, tokens, messages, overlaps and task events per period
|
|
101
|
-
ahub check-path <file> [--peer <id>] other owners' open tasks that claim or changed a file
|
|
102
|
-
ahub check-path --hook the same as a Claude Code PreToolUse hook (templates/claude-hooks.json); never blocks
|
|
103
|
-
ahub facts --hook turn-free facts as a Claude Code PreToolUse, PostToolUse and Stop hook (issue #108); never blocks
|
|
104
|
-
ahub turns [peer] [--limit N] recent turns and the files each changed (a git work tree only)
|
|
105
|
-
ahub undo <turn> [--yes] [--context] put back the files a turn changed; refuses files changed since.
|
|
106
|
-
Without --yes it only lists them; --context also drops a Codex turn from its conversation
|
|
107
|
-
ahub doctor --orphans [--kill] list registrations whose project root is gone; --kill stops their
|
|
108
|
-
daemons (SIGTERM, then SIGKILL) only after the process identity checks out`;
|
|
109
|
-
|
|
110
61
|
const argv = process.argv.slice(2);
|
|
111
62
|
let selector: string | undefined;
|
|
112
63
|
if (argv[0] === "--project") {
|
|
@@ -114,7 +65,12 @@ if (argv[0] === "--project") {
|
|
|
114
65
|
selector = argv.shift();
|
|
115
66
|
if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
|
|
116
67
|
}
|
|
117
|
-
const [
|
|
68
|
+
const [given = "help", ...rest] = argv;
|
|
69
|
+
// `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
|
|
70
|
+
// (`ahub say try ls -h`), and claude and codex pass their arguments on to the agent.
|
|
71
|
+
const asksHelp = (a: string | undefined) => a === "--help" || a === "-h";
|
|
72
|
+
const cmd = asksHelp(given) || (given !== "claude" && given !== "codex" && rest.length === 1 && asksHelp(rest[0])) ? "help" : given;
|
|
73
|
+
const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : rest;
|
|
118
74
|
let selected: { root: string; stateDir: string };
|
|
119
75
|
try {
|
|
120
76
|
if (selector) {
|
|
@@ -146,6 +102,7 @@ function audit(outcome: "run" | "refused" | "invalid"): void {
|
|
|
146
102
|
if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
|
|
147
103
|
if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
|
|
148
104
|
if (identity.role === "tools") audit("run");
|
|
105
|
+
const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
|
|
149
106
|
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
150
107
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
151
108
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
@@ -157,6 +114,40 @@ function registeredProject(): Project {
|
|
|
157
114
|
finally { registry.close(); }
|
|
158
115
|
}
|
|
159
116
|
|
|
117
|
+
/** #247: this project's registration for its research records; writing needs `research.enabled` in its config. */
|
|
118
|
+
function researchProject(writes = true): Project {
|
|
119
|
+
const project = matchingProject() ?? fail("this project has no hub registration; run ahub up here once first");
|
|
120
|
+
if (writes && !loadConfig(cwd).research.enabled) fail('research records are off for this project; set "research": { "enabled": true } in .agenthub/config.json first');
|
|
121
|
+
return project;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The registration of exactly this root and state directory, the only one kill and reset act on; never registers. */
|
|
125
|
+
function matchingProject(): Project | undefined {
|
|
126
|
+
const registry = new Registry();
|
|
127
|
+
try { return registry.list().find((p) => p.root === cwd && p.stateDir === stateDir); }
|
|
128
|
+
finally { registry.close(); }
|
|
129
|
+
}
|
|
130
|
+
const hubManifest = () => !!readControl(stateDir) || existsSync(join(stateDir, "hub.pid"));
|
|
131
|
+
/** A reset is never part of an upgrade or recovery: any operation holding the machine's lock refuses it, whatever
|
|
132
|
+
* AGENTHUB_RECOVERY_OPERATION says (assertLifecycleAvailable lets that operation's own processes through). */
|
|
133
|
+
function resetLockFree(): void {
|
|
134
|
+
const owner = recoveryLock();
|
|
135
|
+
if (owner) throw new Error(`${activeOperation(owner)}; nothing was changed`);
|
|
136
|
+
}
|
|
137
|
+
/** A manifest whose daemon is alive or uncertain, by inspectProject's rule; a crashed daemon's leftovers do not count. */
|
|
138
|
+
function liveManifest(): boolean {
|
|
139
|
+
const control = readControl(stateDir);
|
|
140
|
+
if (control && processLiveness(control.pid, control.pidSignature) !== "gone") return true;
|
|
141
|
+
// A signed manifest is this release's daemon, which writes no hub.pid: one beside it is an older run's leftover.
|
|
142
|
+
if (control?.pidSignature) return false;
|
|
143
|
+
let text: string;
|
|
144
|
+
// Only daemons up to 0.12.20 wrote hub.pid (#226): an unsigned legacy record.
|
|
145
|
+
try { text = readFileSync(join(stateDir, "hub.pid"), "utf8").trim(); } catch { return false; }
|
|
146
|
+
if (/^\d+$/.test(text)) return processLiveness(Number(text)) !== "gone";
|
|
147
|
+
// Empty or garbled: beside a dead daemon's status it is that daemon's leftover too; alone it proves nothing.
|
|
148
|
+
return !control;
|
|
149
|
+
}
|
|
150
|
+
|
|
160
151
|
async function healthy(): Promise<boolean> {
|
|
161
152
|
let hub: ControlClient | undefined;
|
|
162
153
|
try { hub = await connect(); const reply = await hub.request({ t: "status" }, 3000); return reply.status?.cwd === cwd; }
|
|
@@ -242,17 +233,16 @@ function hubDaemonCommand(pid: number, root: string): string | undefined {
|
|
|
242
233
|
|
|
243
234
|
/** Live PID candidates for a registration whose project root is gone: the claim, the manifest, the legacy pid file. */
|
|
244
235
|
function orphanPids(project: Project): number[] {
|
|
236
|
+
// #226: a recorded pid counts only while its signature (when one was recorded) still names its process.
|
|
245
237
|
const candidates = new Set<number>();
|
|
246
|
-
if (typeof project.pid === "number") candidates.add(project.pid);
|
|
238
|
+
if (typeof project.pid === "number" && processLiveness(project.pid, project.pidSignature) === "live") candidates.add(project.pid);
|
|
247
239
|
const control = readControl(project.stateDir);
|
|
248
|
-
if (control && Number.isSafeInteger(control.pid)) candidates.add(control.pid!);
|
|
240
|
+
if (control && Number.isSafeInteger(control.pid) && processLiveness(control.pid, control.pidSignature) === "live") candidates.add(control.pid!);
|
|
249
241
|
try {
|
|
250
242
|
const pid = Number(readFileSync(join(project.stateDir, "hub.pid"), "utf8").trim());
|
|
251
243
|
if (Number.isSafeInteger(pid) && pid > 0) candidates.add(pid);
|
|
252
244
|
} catch { /* no legacy pid file */ }
|
|
253
|
-
return [...candidates].filter((pid) =>
|
|
254
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
255
|
-
});
|
|
245
|
+
return [...candidates].filter((pid) => processLiveness(pid) === "live");
|
|
256
246
|
}
|
|
257
247
|
|
|
258
248
|
const processGone = (pid: number): boolean => !processCommandLine(pid);
|
|
@@ -353,9 +343,10 @@ function spawnRecovery(operation: RecoveryOperation): void {
|
|
|
353
343
|
const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
|
|
354
344
|
cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
|
|
355
345
|
});
|
|
356
|
-
child.on("error", () => console.error(`runner launch failed; use
|
|
346
|
+
child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
|
|
357
347
|
child.unref();
|
|
358
|
-
|
|
348
|
+
// #215: the operation's own coordinator, which has every recovery command; the global ahub may be older mid-upgrade.
|
|
349
|
+
console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
|
|
359
350
|
}
|
|
360
351
|
|
|
361
352
|
async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
@@ -365,8 +356,13 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
|
365
356
|
if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
|
|
366
357
|
const plan = await makeUpgradePlan(kind, one["--to"] ?? VERSION, kind === "restart" ? cwd : undefined);
|
|
367
358
|
console.log(JSON.stringify(plan, null, 2));
|
|
359
|
+
// #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
|
|
360
|
+
for (const p of plan.projects) for (const peer of p.reconnectOnly ?? []) console.error(`ahub: ${p.project.id}: ${peer} is reconnect-only (unmanaged session): its plugin reattaches to the new hub; no terminal is closed or relaunched`);
|
|
361
|
+
for (const p of plan.projects) for (const peer of p.freshStart ?? []) console.error(`ahub: ${p.project.id}: ${peer} restarts as a new session: its thread has no rollout and the hub recorded no turn on it, so nothing is lost`);
|
|
362
|
+
const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
|
|
363
|
+
for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
|
|
368
364
|
if (args.includes("--dry-run")) return;
|
|
369
|
-
if (
|
|
365
|
+
if (blockers.length) fail("plan has blockers; no runtime was changed");
|
|
370
366
|
assertLifecycleAvailable();
|
|
371
367
|
if (!args.includes("--yes")) {
|
|
372
368
|
if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
|
|
@@ -386,12 +382,33 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
386
382
|
upgrade: () => upgrade("upgrade"),
|
|
387
383
|
restart: () => upgrade("restart"),
|
|
388
384
|
recovery: async () => {
|
|
389
|
-
const [action, id] = args;
|
|
390
|
-
|
|
385
|
+
const [action, id, ...rest] = args;
|
|
386
|
+
const usage = "usage: ahub recovery status|resume|abort <operation-id> | ahub recovery dispose <operation-id> --fresh-session <peer>|--stop-and-archive --reason <text>";
|
|
387
|
+
if (!id || !["status", "resume", "abort", "dispose"].includes(action ?? "") || (action !== "dispose" && rest.length)) fail(usage);
|
|
391
388
|
const operation = readOperation<RecoveryOperation>(id);
|
|
392
|
-
|
|
389
|
+
const runner = recoveryRunner(id);
|
|
390
|
+
if (action === "status") {
|
|
391
|
+
// #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
|
|
392
|
+
const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver().inspect);
|
|
393
|
+
console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
|
|
394
|
+
}
|
|
393
395
|
else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver()); console.log("preflight cancelled; no committed transition was rolled back"); }
|
|
396
|
+
else if (action === "dispose") {
|
|
397
|
+
// #215: human-only (the identity gate refuses agent shells); the reason is kept in the operation's audit.
|
|
398
|
+
const { one, rest: flags } = takeFlags(rest, ["--fresh-session", "--reason"], []);
|
|
399
|
+
const stop = flags.length === 1 && flags[0] === "--stop-and-archive";
|
|
400
|
+
const reason = one["--reason"]?.trim() ?? "";
|
|
401
|
+
if (!reason || reason.length > 500 || (flags.length && !stop) || stop === !!one["--fresh-session"]) fail(usage);
|
|
402
|
+
const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver());
|
|
403
|
+
if (!stop) { console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`); return spawnRecovery(result); }
|
|
404
|
+
console.log(JSON.stringify(publicOperation(result), null, 2));
|
|
405
|
+
console.log(`${result.plan.kind} abandoned, not completed; the recovery lock is released. A project whose target ran starts again with that version's CLI.`);
|
|
406
|
+
}
|
|
394
407
|
else if (["completed", "cancelled"].includes(operation.phase)) console.log(`recovery is already ${operation.phase}`);
|
|
408
|
+
// #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
|
|
409
|
+
else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver().inspect))}`);
|
|
410
|
+
else if (runner === "unknown") console.log(`whether a runner holds this operation could not be read; nothing was started; ${recoveryCommand(operation, "status")}`);
|
|
411
|
+
else if (runner) console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
|
|
395
412
|
else spawnRecovery(operation);
|
|
396
413
|
},
|
|
397
414
|
"recovery-run": async () => {
|
|
@@ -399,7 +416,13 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
399
416
|
const result = await runRecovery(args[0]!, makeRecoveryDriver());
|
|
400
417
|
if (result.phase !== "completed") process.exitCode = 1;
|
|
401
418
|
},
|
|
402
|
-
help: () =>
|
|
419
|
+
help: () => {
|
|
420
|
+
const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
|
|
421
|
+
const topic = asksHelp(args[0]) ? undefined : args[0];
|
|
422
|
+
const text = renderHelp(process.stdout.columns ?? 80, color, topic);
|
|
423
|
+
if (!text) fail(`unknown command "${topic}"; run ahub help`);
|
|
424
|
+
console.log(text);
|
|
425
|
+
},
|
|
403
426
|
"--version": () => console.log(VERSION),
|
|
404
427
|
version: () => console.log(VERSION),
|
|
405
428
|
|
|
@@ -517,10 +540,6 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
517
540
|
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
518
541
|
const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
|
|
519
542
|
if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
|
|
520
|
-
// Native hook identity exists in an ordinary terminal too; this is not an Orca recovery record.
|
|
521
|
-
const file = join(stateDir, "claude-launch.json");
|
|
522
|
-
writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID }), { mode: 0o600 });
|
|
523
|
-
chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
|
|
524
543
|
}
|
|
525
544
|
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
526
545
|
// project local, then project, then user.
|
|
@@ -534,9 +553,26 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
534
553
|
}
|
|
535
554
|
// Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
|
|
536
555
|
const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
556
|
+
let previousLaunch: unknown;
|
|
557
|
+
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")); } catch { /* no previous managed launch */ }
|
|
537
558
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
538
|
-
|
|
539
|
-
|
|
559
|
+
let result: ReturnType<typeof spawnSync>;
|
|
560
|
+
try {
|
|
561
|
+
if (control?.instanceId) {
|
|
562
|
+
// Publish only the final launch's installed hook and effective permission flags.
|
|
563
|
+
const file = join(stateDir, "claude-launch.json");
|
|
564
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true }), { mode: 0o600 });
|
|
565
|
+
chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
|
|
566
|
+
cleanupStaleClaudeSettings(stateDir, previousLaunch);
|
|
567
|
+
}
|
|
568
|
+
if (launch.warning) console.error(launch.warning);
|
|
569
|
+
const env = nativeLaunchEnv("claude", childEnv());
|
|
570
|
+
result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
571
|
+
} finally {
|
|
572
|
+
cleanupClaudeSettings(stateDir, launch.settingsFile);
|
|
573
|
+
}
|
|
574
|
+
if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
|
|
575
|
+
process.exit(result.status ?? 1);
|
|
540
576
|
},
|
|
541
577
|
|
|
542
578
|
codex: async () => {
|
|
@@ -546,12 +582,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
546
582
|
}
|
|
547
583
|
assertLifecycleAvailable();
|
|
548
584
|
const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
|
|
585
|
+
// #215: a launch by a recovery operation records itself before the hub round trip, so the coordinator never takes
|
|
586
|
+
// a launcher still starting for one that never ran. An ordinary launch records only once the hub accepted it: a
|
|
587
|
+
// refused start must not replace the record of a Codex already running here (one row per peer and instance).
|
|
588
|
+
const recovering = !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock(); // not an id a restored session inherited
|
|
589
|
+
const before = readControl(stateDir);
|
|
590
|
+
if (recovering && before?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, before.instanceId);
|
|
549
591
|
const hub = await connect();
|
|
550
|
-
const res = await hub.request({ t: "start", peer: "codex", operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
592
|
+
const res = await hub.request({ t: "start", peer: "codex", args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
551
593
|
hub.close();
|
|
552
594
|
if (!res.ok) fail(res.error);
|
|
553
595
|
const control = readControl(stateDir);
|
|
554
|
-
if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
596
|
+
if (!recovering && control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
555
597
|
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
|
|
556
598
|
if (launch0.warning) console.error(launch0.warning);
|
|
557
599
|
exec(launch.cmd, launch.args, "codex");
|
|
@@ -577,6 +619,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
577
619
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
578
620
|
}
|
|
579
621
|
const options = piFlags();
|
|
622
|
+
// #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
|
|
623
|
+
// trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
|
|
624
|
+
const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
|
|
625
|
+
const before = readControl(stateDir);
|
|
626
|
+
if (recovering && before?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, before.instanceId);
|
|
580
627
|
const hub = await connect();
|
|
581
628
|
const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
582
629
|
hub.close();
|
|
@@ -588,7 +635,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
588
635
|
delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
589
636
|
delete launchEnv.AGENTHUB_UNATTENDED;
|
|
590
637
|
const control = readControl(stateDir);
|
|
591
|
-
if (control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
|
|
638
|
+
if (!recovering && control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
|
|
592
639
|
return execWithEnv(launch.cmd, launch.args, launchEnv);
|
|
593
640
|
}
|
|
594
641
|
console.log(res.already ? "pi is already attached" : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
|
|
@@ -747,6 +794,17 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
747
794
|
if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
|
|
748
795
|
if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
|
|
749
796
|
if (sub === "assign") return console.log(await taskOp("task_assign", { id: rest[0], peer: rest[1] }));
|
|
797
|
+
if (sub === "label") {
|
|
798
|
+
// #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
|
|
799
|
+
const { one, rest: positional } = takeFlags(rest, ["--created"], []);
|
|
800
|
+
const id = Number(positional[0]), label = positional[1] as Label;
|
|
801
|
+
if (positional.length !== 2 || !/^[1-9]\d*$/.test(positional[0]!) || !Number.isSafeInteger(id) || !(LABELS as readonly string[]).includes(label)) fail(`usage: ahub task label <id> ${LABELS.join("|")} [--created <time from ahub research export>]`);
|
|
802
|
+
const project = researchProject();
|
|
803
|
+
let record: TaskRecord;
|
|
804
|
+
try { record = labelTarget(readStores(project.id), readEvents(join(stateDir, "events.jsonl")), id, one["--created"]); } catch (error) { fail((error as Error).message); }
|
|
805
|
+
appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
|
|
806
|
+
return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
|
|
807
|
+
}
|
|
750
808
|
if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
|
|
751
809
|
// `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
|
|
752
810
|
// short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
|
|
@@ -795,6 +853,24 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
795
853
|
pause: () => hold("pause"),
|
|
796
854
|
resume: () => hold("resume"),
|
|
797
855
|
|
|
856
|
+
permission: async () => {
|
|
857
|
+
const positional = args.filter(arg => arg !== "--yes");
|
|
858
|
+
const [peer, mode] = positional;
|
|
859
|
+
if (positional.length > 2 || positional.some(arg => arg.startsWith("--")) || (mode !== undefined && !isPermissionMode(mode))) fail("usage: ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]");
|
|
860
|
+
if (mode === "never-ask" && !args.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
|
|
861
|
+
if (mode === "never-ask" && peer) console.error(`never-ask: ${permissionBoundary(peer)}; approval prompts are disabled`);
|
|
862
|
+
const hub = await connect();
|
|
863
|
+
try {
|
|
864
|
+
const reply = await hub.request({ t: "permission", ...(peer ? { peer } : {}), ...(mode ? { mode, confirmed: args.includes("--yes") } : {}) }, 35_000);
|
|
865
|
+
if (reply.ok === false) {
|
|
866
|
+
const error = String(reply.error ?? "permission request refused");
|
|
867
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "permission"/i.test(error) ? `${error}; upgrade the running hub to use ahub permission` : error);
|
|
868
|
+
}
|
|
869
|
+
if (reply.peers) for (const [id, value] of Object.entries(reply.peers)) console.log(`${id}: ${value}`);
|
|
870
|
+
else console.log(`${peer}: ${reply.permissionMode}`);
|
|
871
|
+
} finally { hub.close(); }
|
|
872
|
+
},
|
|
873
|
+
|
|
798
874
|
permit: async () => {
|
|
799
875
|
const [id, option] = args;
|
|
800
876
|
if (!id || !option) fail("usage: ahub permit <id> <option|deny>");
|
|
@@ -812,6 +888,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
812
888
|
if (args.includes("--json")) return console.log(JSON.stringify(status, null, 2));
|
|
813
889
|
console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
|
|
814
890
|
if (status.deliveryError) console.log(` delivery storage: ${status.deliveryError}; dispatch is stopped`);
|
|
891
|
+
for (const pending of status.permissionDefaults ?? []) console.log(` permission default ${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`);
|
|
815
892
|
for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
|
|
816
893
|
const peers = Object.entries(status.peers as Record<string, PeerRow>);
|
|
817
894
|
for (const [id, p] of peers) console.log(peerLine(id, { ...p, context: p.context ?? unknownContext() }));
|
|
@@ -827,6 +904,89 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
827
904
|
export: () => {
|
|
828
905
|
for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
|
|
829
906
|
},
|
|
907
|
+
bench: async () => {
|
|
908
|
+
// #251: benchmark suites run against the peers attached here, and their comparison across arms.
|
|
909
|
+
const json = args.includes("--json");
|
|
910
|
+
const [sub, ...rest] = args.filter((a) => a !== "--json");
|
|
911
|
+
if (sub === "run") {
|
|
912
|
+
const { one, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks"], []);
|
|
913
|
+
const suite = positional[0];
|
|
914
|
+
const repeat = one["--repeat"] === undefined ? 1 : Number(one["--repeat"]);
|
|
915
|
+
if (!suite || positional.length !== 1 || !one["--arm"]?.trim() || !Number.isSafeInteger(repeat) || repeat < 1 || repeat > 100) {
|
|
916
|
+
fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b]");
|
|
917
|
+
}
|
|
918
|
+
await benchPreflight(cwd, loadConfig(cwd).bench.enabled); // the suite's refs are checked once it is read
|
|
919
|
+
let interrupted = false;
|
|
920
|
+
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)"); };
|
|
921
|
+
process.on("SIGINT", stop);
|
|
922
|
+
try {
|
|
923
|
+
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));
|
|
924
|
+
return console.log(formatBenchReport(benchReport(readRuns().filter((r) => r.header.run === run))).join("\n"));
|
|
925
|
+
} finally { process.off("SIGINT", stop); }
|
|
926
|
+
}
|
|
927
|
+
const runs = readRuns();
|
|
928
|
+
if (sub === undefined || sub === "list") return console.log(json ? JSON.stringify(runs.map(runSummary), null, 2) : formatRuns(runs).join("\n"));
|
|
929
|
+
if (sub === "status") {
|
|
930
|
+
const live = runs.filter((r) => r.state === "running" || r.state === "unknown");
|
|
931
|
+
return console.log(json ? JSON.stringify(live.map(runSummary), null, 2) : live.length ? formatRuns(live).join("\n") : "no benchmark run is running");
|
|
932
|
+
}
|
|
933
|
+
if (sub === "report") {
|
|
934
|
+
const run = runs.find((r) => r.header.run === rest[0]) ?? fail(`usage: ahub bench report <run> (ahub bench list shows the runs)`);
|
|
935
|
+
const r = benchReport([run]);
|
|
936
|
+
return console.log(json ? JSON.stringify(r, null, 2) : formatBenchReport(r).join("\n"));
|
|
937
|
+
}
|
|
938
|
+
if (sub === "compare") {
|
|
939
|
+
const mixed = rest.includes("--mixed");
|
|
940
|
+
const { one, rest: picks } = takeFlags(rest.filter((a) => a !== "--mixed"), ["--suite"], []);
|
|
941
|
+
const scoped = one["--suite"] ? runs.filter((r) => r.header.suite === one["--suite"]) : runs;
|
|
942
|
+
const groups = picks.map((pick) => {
|
|
943
|
+
const byRun = scoped.filter((r) => r.header.run === pick);
|
|
944
|
+
const chosen = byRun.length ? byRun : armRuns(scoped, pick);
|
|
945
|
+
if (!chosen.length) fail(`no run, or arm with a run that is over, named ${pick}${one["--suite"] ? ` in suite ${one["--suite"]}` : ""}`);
|
|
946
|
+
return { arm: byRun.length ? `${pick} [${byRun[0]!.header.arm}]` : pick, runs: chosen };
|
|
947
|
+
});
|
|
948
|
+
if (groups.length < 2) fail("usage: ahub bench compare <run|arm> <run|arm>... [--suite <name>] [--mixed] [--json]");
|
|
949
|
+
const differs = sameSuite(groups);
|
|
950
|
+
if (differs && !mixed) fail(`${differs}; compare runs of one suite file (name runs by id, or --suite when the names differ), or pass --mixed to compare them anyway`);
|
|
951
|
+
const c = benchCompare(groups);
|
|
952
|
+
return console.log(json ? JSON.stringify(c, null, 2) : formatCompare(c).join("\n"));
|
|
953
|
+
}
|
|
954
|
+
if (sub === "export") {
|
|
955
|
+
const format = rest.includes("--format") ? rest[rest.indexOf("--format") + 1] : "jsonl";
|
|
956
|
+
if (format !== "jsonl" && format !== "csv") fail("--format takes jsonl or csv");
|
|
957
|
+
if (format === "csv") return void process.stdout.write(benchCsv(runs));
|
|
958
|
+
for (const r of runs) for (const record of [r.header, ...r.attempts, ...(r.end ? [r.end] : [])]) console.log(JSON.stringify(record));
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
fail("usage: ahub bench run|list|status|report|compare|export (ahub help bench)");
|
|
962
|
+
},
|
|
963
|
+
research: () => {
|
|
964
|
+
// #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.
|
|
965
|
+
const [sub] = args;
|
|
966
|
+
// Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored.
|
|
967
|
+
const flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
|
|
968
|
+
for (let i = sub === "export" || sub === "backfill" ? 1 : 0; i < args.length; i++) {
|
|
969
|
+
if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
|
|
970
|
+
if (valued.has(args[i]!)) i++;
|
|
971
|
+
}
|
|
972
|
+
if (sub === "backfill") {
|
|
973
|
+
const project = researchProject();
|
|
974
|
+
const written = appendRecords(project.id, taskRecords(readEvents(join(stateDir, "events.jsonl")), project.id, { version: VERSION, source: "backfill" }));
|
|
975
|
+
return console.log(`${written} research record${written === 1 ? "" : "s"} written from this project's events`);
|
|
976
|
+
}
|
|
977
|
+
const all = args.includes("--all");
|
|
978
|
+
const records = readStores(all ? undefined : researchProject(false).id);
|
|
979
|
+
const from = since();
|
|
980
|
+
if (sub === "export") {
|
|
981
|
+
const format = args[args.indexOf("--format") + 1] ?? "jsonl";
|
|
982
|
+
if (args.includes("--format") && !["jsonl", "csv"].includes(format)) fail("--format takes jsonl or csv");
|
|
983
|
+
const kept = records.filter((r) => r.kind === "label" || Date.parse(r.approvedAt) >= from);
|
|
984
|
+
return void process.stdout.write(args.includes("--format") && format === "csv" ? toCsv(kept) : kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
|
|
985
|
+
}
|
|
986
|
+
if (sub !== undefined && !sub.startsWith("--")) fail("usage: ahub research [--since 30d] [--all] [--json] | export [--format jsonl|csv] [--since] [--all] | backfill");
|
|
987
|
+
const r = researchReport(records, from);
|
|
988
|
+
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
|
|
989
|
+
},
|
|
830
990
|
report: () => {
|
|
831
991
|
const by = args.indexOf("--by");
|
|
832
992
|
if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
|
|
@@ -943,18 +1103,95 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
943
1103
|
},
|
|
944
1104
|
|
|
945
1105
|
kill: async () => {
|
|
946
|
-
const
|
|
947
|
-
let project: Project | undefined;
|
|
948
|
-
try { project = registry.list().find((p) => p.root === cwd && p.stateDir === stateDir); }
|
|
949
|
-
finally { registry.close(); }
|
|
1106
|
+
const project = matchingProject();
|
|
950
1107
|
if (!project) {
|
|
951
|
-
if (!
|
|
1108
|
+
if (!hubManifest()) return console.log("hub is not running");
|
|
952
1109
|
fail("hub has no matching registration; use its matching CLI to stop it before upgrading");
|
|
953
1110
|
}
|
|
954
1111
|
await stopProject(project);
|
|
955
1112
|
console.log("hub stopped");
|
|
956
1113
|
},
|
|
957
1114
|
|
|
1115
|
+
reset: async () => {
|
|
1116
|
+
if (args.some((a) => !["--all", "--yes"].includes(a))) fail("usage: ahub reset [--all] [--yes]");
|
|
1117
|
+
const all = args.includes("--all");
|
|
1118
|
+
resetLockFree();
|
|
1119
|
+
// Registered with another state directory: `ahub up` here would register the default one and orphan that state.
|
|
1120
|
+
const elsewhere = registeredProjects().find((p) => p.root === cwd);
|
|
1121
|
+
const project = matchingProject() ?? fail(hubManifest() ? "hub has no matching registration; use its matching CLI to stop it; nothing was changed"
|
|
1122
|
+
: elsewhere ? `this project is registered with the state directory ${elsewhere.stateDir}; run ahub --project ${elsewhere.id} reset; nothing was changed`
|
|
1123
|
+
: "no registration matches this project and state directory; nothing was changed (ahub up registers it)");
|
|
1124
|
+
if (!existsSync(stateDir)) return console.log("no state directory; nothing to reset");
|
|
1125
|
+
// A state directory elsewhere (AGENTHUB_STATE_DIR, or a symlink) would cross file systems or pull outside state
|
|
1126
|
+
// into the project tree.
|
|
1127
|
+
if (all && stateDir !== join(cwd, ".agenthub", "state")) fail(`--all moves only ${join(cwd, ".agenthub", "state")}; this project's state is in ${stateDir}: stop the hub and archive it by hand; nothing was changed`);
|
|
1128
|
+
const unsafe = all ? archiveProblem(cwd, stateDir) : undefined;
|
|
1129
|
+
if (unsafe) fail(`${unsafe}; nothing was changed`);
|
|
1130
|
+
const live = await inspectProject(project);
|
|
1131
|
+
if (!["stopped", "running", "stopping"].includes(live.state)) fail(`${live.error ?? `hub is ${live.state}`}; nothing was changed`);
|
|
1132
|
+
const recovery = live.status?.recovery as { operationId?: string; phase?: string } | undefined;
|
|
1133
|
+
if (recovery?.operationId && recovery.phase !== "released") fail(`recovery operation ${recovery.operationId} is open in this hub; nothing was changed`);
|
|
1134
|
+
const kept = "kept: the board, logs and audit, recovery records, execution budgets, configuration and pi-sessions/";
|
|
1135
|
+
const memory = "claude-mem is not touched: notes saved with hub_remember stay in shared memory";
|
|
1136
|
+
if (!args.includes("--yes")) {
|
|
1137
|
+
// Planned only for the dry run: `--all --yes` archives a state directory it cannot read as it is.
|
|
1138
|
+
let plan: ResetPlan | undefined, unread: string | undefined;
|
|
1139
|
+
try { plan = planReset(stateDir, project.id); }
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
if (!all) fail(`cannot read the state (${failureText(error)}); nothing was changed${damagedState(error) ? "; ahub reset --all --yes archives it as it is" : ""}`);
|
|
1142
|
+
unread = failureText(error);
|
|
1143
|
+
}
|
|
1144
|
+
console.log(`ahub reset${all ? " --all" : ""}, dry run: the hub is ${live.state}${live.state === "stopped" ? "" : "; --yes stops it first, as ahub kill does"}`);
|
|
1145
|
+
if (all) {
|
|
1146
|
+
console.log(` move ${stateDir}${plan ? ` (${plan.entries} entries)` : ""} to ${join(cwd, ".agenthub", "archive")}/state-<UTC time>/ (0700), then start an empty state directory holding only project.json; project ${project.id} keeps its id and registration`);
|
|
1147
|
+
console.log(unread ? ` could not read the state (${unread}); it is archived as it is` : " archived as they are:");
|
|
1148
|
+
} else console.log(' settle as discard with reason "reset", clear and drop:');
|
|
1149
|
+
for (const line of plan ? resetLines(plan) : []) console.log(` ${line}`);
|
|
1150
|
+
const stale = live.state === "stopped" && !all ? MANIFEST.filter((name) => existsSync(join(stateDir, name))) : [];
|
|
1151
|
+
if (stale.length) console.log(` remove the manifest a hub left when it did not stop cleanly: ${stale.join(", ")}`);
|
|
1152
|
+
console.log(` ${all ? ".agenthub/config.json, config.local.json and routing.toml are outside the state directory and stay" : kept}`);
|
|
1153
|
+
console.log(` ${memory}`);
|
|
1154
|
+
return console.log("nothing was changed; add --yes to apply");
|
|
1155
|
+
}
|
|
1156
|
+
await stopProject(project);
|
|
1157
|
+
if (live.state !== "stopped") console.log("hub stopped");
|
|
1158
|
+
// Hold the claim a daemon takes to run: a hub started after the stop holds it (refused here), and none can start
|
|
1159
|
+
// while the reset acts. A reset that dies leaves a claim with a dead pid, which the next start takes over.
|
|
1160
|
+
const registry = new Registry();
|
|
1161
|
+
// The prefix tells a concurrent reset's claim from a daemon's.
|
|
1162
|
+
const claim = `reset-${randomUUID()}`;
|
|
1163
|
+
try {
|
|
1164
|
+
if (!registry.claim(project.id, claim, process.pid, processSignature(process.pid))) {
|
|
1165
|
+
const holder = registry.get(project.id);
|
|
1166
|
+
throw new Error(holder?.instanceId?.startsWith("reset-") ? `another ahub reset of this project is running (pid ${holder.pid}); nothing was reset` : "a hub started after the stop; nothing was reset, run ahub reset again");
|
|
1167
|
+
}
|
|
1168
|
+
if (liveManifest()) throw new Error("a hub started after the stop; nothing was reset, run ahub reset again");
|
|
1169
|
+
// An upgrade or recovery that took the machine's lock after the first check must not run beside the reset.
|
|
1170
|
+
resetLockFree();
|
|
1171
|
+
if (all) {
|
|
1172
|
+
let archived: string;
|
|
1173
|
+
try { archived = archiveState(cwd, stateDir); }
|
|
1174
|
+
catch (error) { throw new Error(`the hub is stopped; nothing was moved: ${failureText(error)}`); }
|
|
1175
|
+
console.log(`moved the state directory to ${archived}`);
|
|
1176
|
+
try { startState(stateDir, archived); }
|
|
1177
|
+
catch (error) { throw new Error(`${(error as Error).message}; the state directory is archived at ${archived}: create ${stateDir} (0700) and copy project.json from the archive`); }
|
|
1178
|
+
console.log(`the new state directory holds only project.json (to restore: ahub kill, move ${stateDir} into ${join(cwd, ".agenthub", "archive")}/, then move the archive back to ${stateDir})`);
|
|
1179
|
+
} else {
|
|
1180
|
+
let result: ReturnType<typeof resetRuntime>;
|
|
1181
|
+
try { result = resetRuntime(stateDir, project); }
|
|
1182
|
+
catch (error) {
|
|
1183
|
+
throw new Error(`${failureText(error)}; the hub is stopped and the reset is incomplete: ${damagedState(error) ? "the state cannot be read, so a rerun fails the same way; ahub reset --all --yes archives it as it is" : "run ahub reset --yes again to finish it"}`);
|
|
1184
|
+
}
|
|
1185
|
+
console.log(`settled ${result.settled.length} deliveries as discard with reason "reset"`);
|
|
1186
|
+
for (const line of resetLines(result.plan).slice(1)) console.log(`cleared ${line}`);
|
|
1187
|
+
if (result.stale.length) console.log(`removed the manifest a hub left when it did not stop cleanly: ${result.stale.join(", ")}`);
|
|
1188
|
+
console.log(kept);
|
|
1189
|
+
}
|
|
1190
|
+
} finally { registry.release(project.id, claim); registry.close(); }
|
|
1191
|
+
console.log(memory);
|
|
1192
|
+
console.log("Claude Code sessions attached to this hub lost their hub session: relaunch them with ahub claude");
|
|
1193
|
+
},
|
|
1194
|
+
|
|
958
1195
|
doctor: async () => {
|
|
959
1196
|
if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"));
|
|
960
1197
|
const version = (bin: string) => {
|
|
@@ -1040,7 +1277,7 @@ async function runConductorCommand(): Promise<void> {
|
|
|
1040
1277
|
}
|
|
1041
1278
|
console.log(await taskOp(op, input));
|
|
1042
1279
|
}
|
|
1043
|
-
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : commands[cmd] ?? (() => fail(`unknown command "${cmd}"
|
|
1280
|
+
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
|
|
1044
1281
|
try {
|
|
1045
1282
|
await run();
|
|
1046
1283
|
} catch (e) {
|