@staix/agent-hub 0.12.19 → 0.12.20
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 +16 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +2 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/events.md +4 -1
- package/docs/operations.md +413 -17
- package/docs/quickstart.md +1 -1
- package/docs/security.md +17 -10
- package/docs/smoke.md +58 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +399 -3
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +63 -25
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +3 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/console-state.ts +325 -102
- package/src/cli/console.ts +44 -23
- package/src/cli/help.ts +120 -0
- package/src/cli/identity.ts +3 -3
- package/src/cli/launch.ts +2 -0
- package/src/cli/main.ts +171 -75
- package/src/cli/preview.ts +1 -0
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +9 -5
- package/src/cli/terminal-recovery.ts +140 -36
- package/src/cli/upgrade-runtime.ts +274 -56
- package/src/cli/upgrade.ts +366 -41
- package/src/hub/ask.ts +1 -1
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +60 -4
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +38 -6
- package/src/hub/control-client.ts +5 -3
- package/src/hub/daemon.ts +56 -9
- package/src/hub/events.ts +7 -1
- 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 +7 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/recovery-store.ts +51 -3
- package/src/hub/report.ts +43 -1
- 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 +236 -83
- package/src/local/deny.ts +2 -1
- 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/ui/index.html +1 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
package/src/cli/main.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { loadConfig } from "../hub/daemon.ts";
|
|
|
10
10
|
import { factsHook } from "./facts-hook.ts";
|
|
11
11
|
import { projectContext, realPath } from "../hub/project.ts";
|
|
12
12
|
import { Registry, type Project } from "../hub/registry.ts";
|
|
13
|
-
import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
|
|
13
|
+
import { inspectProject, processAlive, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
|
|
14
14
|
import { openManager, startManager, stopManager } from "../hub/manager.ts";
|
|
15
15
|
import { OmniRoute } from "../omniroute/client.ts";
|
|
16
16
|
import { MemoryClient } from "../memory/client.ts";
|
|
@@ -22,9 +22,9 @@ import { CLASSES } from "../hub/board.ts";
|
|
|
22
22
|
import { VERSION } from "../version.ts";
|
|
23
23
|
import { freeText } from "./free-text.ts";
|
|
24
24
|
import { createInterface } from "node:readline/promises";
|
|
25
|
-
import { assertLifecycleAvailable, readOperation } from "../hub/recovery-store.ts";
|
|
25
|
+
import { activeOperation, assertLifecycleAvailable, readOperation, recoveryLock, recoveryRunner } from "../hub/recovery-store.ts";
|
|
26
26
|
import { childEnv } from "../hub/child-process.ts";
|
|
27
|
-
import { abortRecovery, createOperation, publicOperation, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
|
|
27
|
+
import { abortRecovery, createOperation, disposeRecovery, liveProjects, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
|
|
28
28
|
import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
29
29
|
import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
30
30
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
@@ -40,7 +40,9 @@ import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRef
|
|
|
40
40
|
import { recordCliAudit } from "./identity-audit.ts";
|
|
41
41
|
import { runConsole } from "./console.ts";
|
|
42
42
|
import { resolveColor } from "./console-state.ts";
|
|
43
|
+
import { renderHelp } from "./help.ts";
|
|
43
44
|
import { renderTailEvent } from "./tail-render.ts";
|
|
45
|
+
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
44
46
|
|
|
45
47
|
/** `--since 7d|24h|<iso>` for export and report; everything when absent. */
|
|
46
48
|
const since = (): number => {
|
|
@@ -51,62 +53,6 @@ const since = (): number => {
|
|
|
51
53
|
return t;
|
|
52
54
|
};
|
|
53
55
|
|
|
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
56
|
const argv = process.argv.slice(2);
|
|
111
57
|
let selector: string | undefined;
|
|
112
58
|
if (argv[0] === "--project") {
|
|
@@ -114,7 +60,12 @@ if (argv[0] === "--project") {
|
|
|
114
60
|
selector = argv.shift();
|
|
115
61
|
if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
|
|
116
62
|
}
|
|
117
|
-
const [
|
|
63
|
+
const [given = "help", ...rest] = argv;
|
|
64
|
+
// `ahub <command> -h` alone asks for that command's help instead of running it. Elsewhere `-h` may be message text
|
|
65
|
+
// (`ahub say try ls -h`), and claude and codex pass their arguments on to the agent.
|
|
66
|
+
const asksHelp = (a: string | undefined) => a === "--help" || a === "-h";
|
|
67
|
+
const cmd = asksHelp(given) || (given !== "claude" && given !== "codex" && rest.length === 1 && asksHelp(rest[0])) ? "help" : given;
|
|
68
|
+
const args = cmd === "help" && given !== "help" && !asksHelp(given) ? [given] : rest;
|
|
118
69
|
let selected: { root: string; stateDir: string };
|
|
119
70
|
try {
|
|
120
71
|
if (selector) {
|
|
@@ -157,6 +108,30 @@ function registeredProject(): Project {
|
|
|
157
108
|
finally { registry.close(); }
|
|
158
109
|
}
|
|
159
110
|
|
|
111
|
+
/** The registration of exactly this root and state directory, the only one kill and reset act on; never registers. */
|
|
112
|
+
function matchingProject(): Project | undefined {
|
|
113
|
+
const registry = new Registry();
|
|
114
|
+
try { return registry.list().find((p) => p.root === cwd && p.stateDir === stateDir); }
|
|
115
|
+
finally { registry.close(); }
|
|
116
|
+
}
|
|
117
|
+
const hubManifest = () => !!readControl(stateDir) || existsSync(join(stateDir, "hub.pid"));
|
|
118
|
+
/** A reset is never part of an upgrade or recovery: any operation holding the machine's lock refuses it, whatever
|
|
119
|
+
* AGENTHUB_RECOVERY_OPERATION says (assertLifecycleAvailable lets that operation's own processes through). */
|
|
120
|
+
function resetLockFree(): void {
|
|
121
|
+
const owner = recoveryLock();
|
|
122
|
+
if (owner) throw new Error(`${activeOperation(owner)}; nothing was changed`);
|
|
123
|
+
}
|
|
124
|
+
/** A manifest whose daemon is alive or uncertain, by inspectProject's rule; a crashed daemon's leftovers do not count. */
|
|
125
|
+
function liveManifest(): boolean {
|
|
126
|
+
const control = readControl(stateDir);
|
|
127
|
+
if (control && processAlive(control.pid) !== false) return true;
|
|
128
|
+
let text: string;
|
|
129
|
+
try { text = readFileSync(join(stateDir, "hub.pid"), "utf8").trim(); } catch { return false; }
|
|
130
|
+
if (/^\d+$/.test(text)) return processAlive(Number(text)) !== false;
|
|
131
|
+
// Empty or garbled: beside a dead daemon's status it is that daemon's leftover too; alone it proves nothing.
|
|
132
|
+
return !control;
|
|
133
|
+
}
|
|
134
|
+
|
|
160
135
|
async function healthy(): Promise<boolean> {
|
|
161
136
|
let hub: ControlClient | undefined;
|
|
162
137
|
try { hub = await connect(); const reply = await hub.request({ t: "status" }, 3000); return reply.status?.cwd === cwd; }
|
|
@@ -353,9 +328,10 @@ function spawnRecovery(operation: RecoveryOperation): void {
|
|
|
353
328
|
const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
|
|
354
329
|
cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
|
|
355
330
|
});
|
|
356
|
-
child.on("error", () => console.error(`runner launch failed; use
|
|
331
|
+
child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
|
|
357
332
|
child.unref();
|
|
358
|
-
|
|
333
|
+
// #215: the operation's own coordinator, which has every recovery command; the global ahub may be older mid-upgrade.
|
|
334
|
+
console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
|
|
359
335
|
}
|
|
360
336
|
|
|
361
337
|
async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
@@ -365,8 +341,13 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
|
365
341
|
if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
|
|
366
342
|
const plan = await makeUpgradePlan(kind, one["--to"] ?? VERSION, kind === "restart" ? cwd : undefined);
|
|
367
343
|
console.log(JSON.stringify(plan, null, 2));
|
|
344
|
+
// #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
|
|
345
|
+
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`);
|
|
346
|
+
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`);
|
|
347
|
+
const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
|
|
348
|
+
for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
|
|
368
349
|
if (args.includes("--dry-run")) return;
|
|
369
|
-
if (
|
|
350
|
+
if (blockers.length) fail("plan has blockers; no runtime was changed");
|
|
370
351
|
assertLifecycleAvailable();
|
|
371
352
|
if (!args.includes("--yes")) {
|
|
372
353
|
if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
|
|
@@ -386,12 +367,33 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
386
367
|
upgrade: () => upgrade("upgrade"),
|
|
387
368
|
restart: () => upgrade("restart"),
|
|
388
369
|
recovery: async () => {
|
|
389
|
-
const [action, id] = args;
|
|
390
|
-
|
|
370
|
+
const [action, id, ...rest] = args;
|
|
371
|
+
const usage = "usage: ahub recovery status|resume|abort <operation-id> | ahub recovery dispose <operation-id> --fresh-session <peer>|--stop-and-archive --reason <text>";
|
|
372
|
+
if (!id || !["status", "resume", "abort", "dispose"].includes(action ?? "") || (action !== "dispose" && rest.length)) fail(usage);
|
|
391
373
|
const operation = readOperation<RecoveryOperation>(id);
|
|
392
|
-
|
|
374
|
+
const runner = recoveryRunner(id);
|
|
375
|
+
if (action === "status") {
|
|
376
|
+
// #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
|
|
377
|
+
const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver().inspect);
|
|
378
|
+
console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
|
|
379
|
+
}
|
|
393
380
|
else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver()); console.log("preflight cancelled; no committed transition was rolled back"); }
|
|
381
|
+
else if (action === "dispose") {
|
|
382
|
+
// #215: human-only (the identity gate refuses agent shells); the reason is kept in the operation's audit.
|
|
383
|
+
const { one, rest: flags } = takeFlags(rest, ["--fresh-session", "--reason"], []);
|
|
384
|
+
const stop = flags.length === 1 && flags[0] === "--stop-and-archive";
|
|
385
|
+
const reason = one["--reason"]?.trim() ?? "";
|
|
386
|
+
if (!reason || reason.length > 500 || (flags.length && !stop) || stop === !!one["--fresh-session"]) fail(usage);
|
|
387
|
+
const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver());
|
|
388
|
+
if (!stop) { console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`); return spawnRecovery(result); }
|
|
389
|
+
console.log(JSON.stringify(publicOperation(result), null, 2));
|
|
390
|
+
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.`);
|
|
391
|
+
}
|
|
394
392
|
else if (["completed", "cancelled"].includes(operation.phase)) console.log(`recovery is already ${operation.phase}`);
|
|
393
|
+
// #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
|
|
394
|
+
else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver().inspect))}`);
|
|
395
|
+
else if (runner === "unknown") console.log(`whether a runner holds this operation could not be read; nothing was started; ${recoveryCommand(operation, "status")}`);
|
|
396
|
+
else if (runner) console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
|
|
395
397
|
else spawnRecovery(operation);
|
|
396
398
|
},
|
|
397
399
|
"recovery-run": async () => {
|
|
@@ -399,7 +401,13 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
399
401
|
const result = await runRecovery(args[0]!, makeRecoveryDriver());
|
|
400
402
|
if (result.phase !== "completed") process.exitCode = 1;
|
|
401
403
|
},
|
|
402
|
-
help: () =>
|
|
404
|
+
help: () => {
|
|
405
|
+
const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
|
|
406
|
+
const topic = asksHelp(args[0]) ? undefined : args[0];
|
|
407
|
+
const text = renderHelp(process.stdout.columns ?? 80, color, topic);
|
|
408
|
+
if (!text) fail(`unknown command "${topic}"; run ahub help`);
|
|
409
|
+
console.log(text);
|
|
410
|
+
},
|
|
403
411
|
"--version": () => console.log(VERSION),
|
|
404
412
|
version: () => console.log(VERSION),
|
|
405
413
|
|
|
@@ -546,12 +554,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
546
554
|
}
|
|
547
555
|
assertLifecycleAvailable();
|
|
548
556
|
const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
|
|
557
|
+
// #215: a launch by a recovery operation records itself before the hub round trip, so the coordinator never takes
|
|
558
|
+
// a launcher still starting for one that never ran. An ordinary launch records only once the hub accepted it: a
|
|
559
|
+
// refused start must not replace the record of a Codex already running here (one row per peer and instance).
|
|
560
|
+
const recovering = !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock(); // not an id a restored session inherited
|
|
561
|
+
const before = readControl(stateDir);
|
|
562
|
+
if (recovering && before?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, before.instanceId);
|
|
549
563
|
const hub = await connect();
|
|
550
564
|
const res = await hub.request({ t: "start", peer: "codex", operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
551
565
|
hub.close();
|
|
552
566
|
if (!res.ok) fail(res.error);
|
|
553
567
|
const control = readControl(stateDir);
|
|
554
|
-
if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
568
|
+
if (!recovering && control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
555
569
|
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
|
|
556
570
|
if (launch0.warning) console.error(launch0.warning);
|
|
557
571
|
exec(launch.cmd, launch.args, "codex");
|
|
@@ -577,6 +591,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
577
591
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
578
592
|
}
|
|
579
593
|
const options = piFlags();
|
|
594
|
+
// #215: as for Codex, a TUI launch by the recovery operation holding the lock records itself before the hub round
|
|
595
|
+
// trip, so a resume in that window never reads it as gone; an ordinary launch records only after the hub accepted it.
|
|
596
|
+
const recovering = options.mode === "tui" && !!process.env.AGENTHUB_RECOVERY_OPERATION && process.env.AGENTHUB_RECOVERY_OPERATION === recoveryLock();
|
|
597
|
+
const before = readControl(stateDir);
|
|
598
|
+
if (recovering && before?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, before.instanceId);
|
|
580
599
|
const hub = await connect();
|
|
581
600
|
const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
582
601
|
hub.close();
|
|
@@ -588,7 +607,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
588
607
|
delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
589
608
|
delete launchEnv.AGENTHUB_UNATTENDED;
|
|
590
609
|
const control = readControl(stateDir);
|
|
591
|
-
if (control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
|
|
610
|
+
if (!recovering && control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
|
|
592
611
|
return execWithEnv(launch.cmd, launch.args, launchEnv);
|
|
593
612
|
}
|
|
594
613
|
console.log(res.already ? "pi is already attached" : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
|
|
@@ -943,18 +962,95 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
943
962
|
},
|
|
944
963
|
|
|
945
964
|
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(); }
|
|
965
|
+
const project = matchingProject();
|
|
950
966
|
if (!project) {
|
|
951
|
-
if (!
|
|
967
|
+
if (!hubManifest()) return console.log("hub is not running");
|
|
952
968
|
fail("hub has no matching registration; use its matching CLI to stop it before upgrading");
|
|
953
969
|
}
|
|
954
970
|
await stopProject(project);
|
|
955
971
|
console.log("hub stopped");
|
|
956
972
|
},
|
|
957
973
|
|
|
974
|
+
reset: async () => {
|
|
975
|
+
if (args.some((a) => !["--all", "--yes"].includes(a))) fail("usage: ahub reset [--all] [--yes]");
|
|
976
|
+
const all = args.includes("--all");
|
|
977
|
+
resetLockFree();
|
|
978
|
+
// Registered with another state directory: `ahub up` here would register the default one and orphan that state.
|
|
979
|
+
const elsewhere = registeredProjects().find((p) => p.root === cwd);
|
|
980
|
+
const project = matchingProject() ?? fail(hubManifest() ? "hub has no matching registration; use its matching CLI to stop it; nothing was changed"
|
|
981
|
+
: elsewhere ? `this project is registered with the state directory ${elsewhere.stateDir}; run ahub --project ${elsewhere.id} reset; nothing was changed`
|
|
982
|
+
: "no registration matches this project and state directory; nothing was changed (ahub up registers it)");
|
|
983
|
+
if (!existsSync(stateDir)) return console.log("no state directory; nothing to reset");
|
|
984
|
+
// A state directory elsewhere (AGENTHUB_STATE_DIR, or a symlink) would cross file systems or pull outside state
|
|
985
|
+
// into the project tree.
|
|
986
|
+
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`);
|
|
987
|
+
const unsafe = all ? archiveProblem(cwd, stateDir) : undefined;
|
|
988
|
+
if (unsafe) fail(`${unsafe}; nothing was changed`);
|
|
989
|
+
const live = await inspectProject(project);
|
|
990
|
+
if (!["stopped", "running", "stopping"].includes(live.state)) fail(`${live.error ?? `hub is ${live.state}`}; nothing was changed`);
|
|
991
|
+
const recovery = live.status?.recovery as { operationId?: string; phase?: string } | undefined;
|
|
992
|
+
if (recovery?.operationId && recovery.phase !== "released") fail(`recovery operation ${recovery.operationId} is open in this hub; nothing was changed`);
|
|
993
|
+
const kept = "kept: the board, logs and audit, recovery records, execution budgets, configuration and pi-sessions/";
|
|
994
|
+
const memory = "claude-mem is not touched: notes saved with hub_remember stay in shared memory";
|
|
995
|
+
if (!args.includes("--yes")) {
|
|
996
|
+
// Planned only for the dry run: `--all --yes` archives a state directory it cannot read as it is.
|
|
997
|
+
let plan: ResetPlan | undefined, unread: string | undefined;
|
|
998
|
+
try { plan = planReset(stateDir, project.id); }
|
|
999
|
+
catch (error) {
|
|
1000
|
+
if (!all) fail(`cannot read the state (${failureText(error)}); nothing was changed${damagedState(error) ? "; ahub reset --all --yes archives it as it is" : ""}`);
|
|
1001
|
+
unread = failureText(error);
|
|
1002
|
+
}
|
|
1003
|
+
console.log(`ahub reset${all ? " --all" : ""}, dry run: the hub is ${live.state}${live.state === "stopped" ? "" : "; --yes stops it first, as ahub kill does"}`);
|
|
1004
|
+
if (all) {
|
|
1005
|
+
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`);
|
|
1006
|
+
console.log(unread ? ` could not read the state (${unread}); it is archived as it is` : " archived as they are:");
|
|
1007
|
+
} else console.log(' settle as discard with reason "reset", clear and drop:');
|
|
1008
|
+
for (const line of plan ? resetLines(plan) : []) console.log(` ${line}`);
|
|
1009
|
+
const stale = live.state === "stopped" && !all ? MANIFEST.filter((name) => existsSync(join(stateDir, name))) : [];
|
|
1010
|
+
if (stale.length) console.log(` remove the manifest a hub left when it did not stop cleanly: ${stale.join(", ")}`);
|
|
1011
|
+
console.log(` ${all ? ".agenthub/config.json, config.local.json and routing.toml are outside the state directory and stay" : kept}`);
|
|
1012
|
+
console.log(` ${memory}`);
|
|
1013
|
+
return console.log("nothing was changed; add --yes to apply");
|
|
1014
|
+
}
|
|
1015
|
+
await stopProject(project);
|
|
1016
|
+
if (live.state !== "stopped") console.log("hub stopped");
|
|
1017
|
+
// Hold the claim a daemon takes to run: a hub started after the stop holds it (refused here), and none can start
|
|
1018
|
+
// while the reset acts. A reset that dies leaves a claim with a dead pid, which the next start takes over.
|
|
1019
|
+
const registry = new Registry();
|
|
1020
|
+
// The prefix tells a concurrent reset's claim from a daemon's.
|
|
1021
|
+
const claim = `reset-${randomUUID()}`;
|
|
1022
|
+
try {
|
|
1023
|
+
if (!registry.claim(project.id, claim, process.pid)) {
|
|
1024
|
+
const holder = registry.get(project.id);
|
|
1025
|
+
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");
|
|
1026
|
+
}
|
|
1027
|
+
if (liveManifest()) throw new Error("a hub started after the stop; nothing was reset, run ahub reset again");
|
|
1028
|
+
// An upgrade or recovery that took the machine's lock after the first check must not run beside the reset.
|
|
1029
|
+
resetLockFree();
|
|
1030
|
+
if (all) {
|
|
1031
|
+
let archived: string;
|
|
1032
|
+
try { archived = archiveState(cwd, stateDir); }
|
|
1033
|
+
catch (error) { throw new Error(`the hub is stopped; nothing was moved: ${failureText(error)}`); }
|
|
1034
|
+
console.log(`moved the state directory to ${archived}`);
|
|
1035
|
+
try { startState(stateDir, archived); }
|
|
1036
|
+
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`); }
|
|
1037
|
+
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})`);
|
|
1038
|
+
} else {
|
|
1039
|
+
let result: ReturnType<typeof resetRuntime>;
|
|
1040
|
+
try { result = resetRuntime(stateDir, project); }
|
|
1041
|
+
catch (error) {
|
|
1042
|
+
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"}`);
|
|
1043
|
+
}
|
|
1044
|
+
console.log(`settled ${result.settled.length} deliveries as discard with reason "reset"`);
|
|
1045
|
+
for (const line of resetLines(result.plan).slice(1)) console.log(`cleared ${line}`);
|
|
1046
|
+
if (result.stale.length) console.log(`removed the manifest a hub left when it did not stop cleanly: ${result.stale.join(", ")}`);
|
|
1047
|
+
console.log(kept);
|
|
1048
|
+
}
|
|
1049
|
+
} finally { registry.release(project.id, claim); registry.close(); }
|
|
1050
|
+
console.log(memory);
|
|
1051
|
+
console.log("Claude Code sessions attached to this hub lost their hub session: relaunch them with ahub claude");
|
|
1052
|
+
},
|
|
1053
|
+
|
|
958
1054
|
doctor: async () => {
|
|
959
1055
|
if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"));
|
|
960
1056
|
const version = (bin: string) => {
|
|
@@ -1040,7 +1136,7 @@ async function runConductorCommand(): Promise<void> {
|
|
|
1040
1136
|
}
|
|
1041
1137
|
console.log(await taskOp(op, input));
|
|
1042
1138
|
}
|
|
1043
|
-
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : commands[cmd] ?? (() => fail(`unknown command "${cmd}"
|
|
1139
|
+
const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
|
|
1044
1140
|
try {
|
|
1045
1141
|
await run();
|
|
1046
1142
|
} catch (e) {
|
package/src/cli/preview.ts
CHANGED
|
@@ -71,6 +71,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
|
|
|
71
71
|
unresolved.push({ field: "bridge/relay endpoints and owner credentials", reason: "assigned at runtime; preview does not bind a server or start a model" });
|
|
72
72
|
if (!value("--session-id") && !value("--session-file")) unresolved.push({ field: "sessionId", reason: "new native session identity is not allocated by preview" });
|
|
73
73
|
}
|
|
74
|
+
if (tool === "claude") envNames.push("AGENTHUB_CHANNEL");
|
|
74
75
|
if (tool === "claude" || tool === "codex") {
|
|
75
76
|
envNames.push("AGENTHUB_INSTANCE_ID", "AGENTHUB_LAUNCH_ID");
|
|
76
77
|
unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: tool === "claude" ? "injected when an existing daemon instance is read at launch; preview does not read runtime state" : "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
|
package/src/cli/reset.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { constants, Database } from "bun:sqlite";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Bus } from "../hub/bus.ts";
|
|
6
|
+
import { DeliveryJournal } from "../hub/delivery-journal.ts";
|
|
7
|
+
import type { Project } from "../hub/registry.ts";
|
|
8
|
+
|
|
9
|
+
/** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
|
|
10
|
+
export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
|
|
11
|
+
/** What a running daemon publishes; a daemon that did not stop cleanly (crash, SIGKILL, forced exit) leaves it. */
|
|
12
|
+
export const MANIFEST = ["status.json", "control-token", "hub.pid"];
|
|
13
|
+
|
|
14
|
+
/** A state the reset cannot read the same way twice: rerunning fails alike, while `--all` archives it as it is. */
|
|
15
|
+
export function damagedState(error: unknown): boolean {
|
|
16
|
+
return error instanceof SyntaxError || /invalid delivery journal|not a database|malformed|corrupt/i.test((error as Error)?.message ?? "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What may be printed about a failure: a parser's message can quote the data it choked on (envelope bodies, task text),
|
|
21
|
+
* so JSON and SQLite errors are named by class and code only. Every other message is written by the hub or the system.
|
|
22
|
+
*/
|
|
23
|
+
export function failureText(error: unknown): string {
|
|
24
|
+
const e = error as NodeJS.ErrnoException;
|
|
25
|
+
if (e instanceof SyntaxError || e?.name === "SQLiteError") return `${[e.name, e.code].filter(Boolean).join(" ")} reading hub.db`;
|
|
26
|
+
return e?.message ?? String(error);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Ids and peer names only: a reset never prints task or message text (#214). */
|
|
30
|
+
export interface ResetPlan {
|
|
31
|
+
queued: string[];
|
|
32
|
+
needsReview: string[];
|
|
33
|
+
/** Dispatching or accepted: the journal holds them as needs_review once the hub has stopped. */
|
|
34
|
+
inFlight: string[];
|
|
35
|
+
manualHolds: string[];
|
|
36
|
+
budgetPauses: string[];
|
|
37
|
+
conductorHolds: string[];
|
|
38
|
+
sessionPointers: string[];
|
|
39
|
+
entries: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isList = (value: unknown, item: (v: unknown) => boolean) => Array.isArray(value) && value.every(item);
|
|
43
|
+
const isText = (v: unknown) => typeof v === "string";
|
|
44
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
45
|
+
const isEnvelope = (v: unknown) => isObject(v) && typeof v.id === "string";
|
|
46
|
+
/** Valid JSON of the wrong shape is damage as well: the journal refuses it on every open, so the message says so. */
|
|
47
|
+
const shaped = (ok: boolean, what: string) => { if (!ok) throw new Error(`invalid delivery journal: ${what} has the wrong shape`); };
|
|
48
|
+
|
|
49
|
+
/** What a reset would act on, read from the state directory without changing it (hub.db opened read-only). */
|
|
50
|
+
export function planReset(stateDir: string, projectId: string): ResetPlan {
|
|
51
|
+
const plan: ResetPlan = { queued: [], needsReview: [], inFlight: [], manualHolds: [], budgetPauses: [], conductorHolds: [],
|
|
52
|
+
sessionPointers: SESSION_POINTERS.filter((name) => existsSync(join(stateDir, name))), entries: existsSync(stateDir) ? readdirSync(stateDir).length : 0 };
|
|
53
|
+
const file = join(stateDir, "hub.db");
|
|
54
|
+
if (!existsSync(file)) return plan;
|
|
55
|
+
// A read-only open of a WAL database creates hub.db-wal and -shm when the last writer's close removed them (Linux),
|
|
56
|
+
// and fails without them on macOS. Without them no writer is mid-transaction, so `immutable` reads hub.db alone and
|
|
57
|
+
// writes nothing. ponytail: a -wal left without its -shm (deleted by hand) is ignored then, and commits still in
|
|
58
|
+
// it go unlisted; open plainly in that corner if it ever matters. SQLITE_OPEN_URI is passed because Linux builds do
|
|
59
|
+
// not parse `file:` names by default (the name then fails as a plain path).
|
|
60
|
+
const quiet = !existsSync(`${file}-wal`) || !existsSync(`${file}-shm`);
|
|
61
|
+
const db = quiet ? new Database(`file:${file.replace(/[%?#]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}?immutable=1`, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) : new Database(file, { readonly: true });
|
|
62
|
+
try {
|
|
63
|
+
const has = (table: string) => !!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
64
|
+
const peers = (table: string) => has(table) ? (db.query(`SELECT peer FROM ${table} ORDER BY peer`).all() as { peer: string }[]).map((row) => row.peer) : [];
|
|
65
|
+
plan.budgetPauses = peers("budget_pauses");
|
|
66
|
+
plan.conductorHolds = peers("conductor_holds");
|
|
67
|
+
if (!has("deliveries")) return plan;
|
|
68
|
+
const rows = db.query("SELECT id, peer, state, originals FROM deliveries WHERE project_id = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(projectId) as { id: string; peer: string; state: string; originals: string }[];
|
|
69
|
+
for (const row of rows) (row.state === "queued" ? plan.queued : row.state === "needs_review" ? plan.needsReview : plan.inFlight).push(row.id);
|
|
70
|
+
const meta = db.query("SELECT bus_snapshot, manual_paused FROM delivery_meta WHERE project_id = ?").get(projectId) as { bus_snapshot: string; manual_paused: string } | null;
|
|
71
|
+
const bus: unknown = JSON.parse(meta?.bus_snapshot || "{}"), manual: unknown = JSON.parse(meta?.manual_paused || "[]");
|
|
72
|
+
shaped(isList(manual, isText), "manual_paused");
|
|
73
|
+
shaped(isObject(bus) && (bus.manualPaused === undefined || isList(bus.manualPaused, isText)) && (bus.queues === undefined || (isObject(bus.queues) && Object.values(bus.queues).every((queue) => isList(queue, isEnvelope)))), "bus_snapshot");
|
|
74
|
+
const { queues = {}, manualPaused = [] } = bus as { queues?: Record<string, { id: string }[]>; manualPaused?: string[] };
|
|
75
|
+
plan.manualHolds = [...new Set([...manual as string[], ...manualPaused])].sort();
|
|
76
|
+
// As Bus.queueList: a queued envelope that no open row stands for is listed as q:<peer>:<envelope id>.
|
|
77
|
+
// ponytail: the rule is copied so the dry run never opens the journal for writing; the reset tests compare the
|
|
78
|
+
// listed ids with the settled ones. Share one pure helper with queueList if that rule changes.
|
|
79
|
+
const recorded = new Set(rows.flatMap((row) => {
|
|
80
|
+
const originals: unknown = JSON.parse(row.originals);
|
|
81
|
+
shaped(isList(originals, isEnvelope), `delivery ${row.id} originals`);
|
|
82
|
+
return (originals as { id: string }[]).map((env) => `${row.peer}:${env.id}`);
|
|
83
|
+
}));
|
|
84
|
+
for (const [peer, queue] of Object.entries(queues)) for (const env of queue) if (!recorded.has(`${peer}:${env.id}`)) plan.queued.push(`q:${peer}:${env.id}`);
|
|
85
|
+
} finally { db.close(); }
|
|
86
|
+
return plan;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const named = (label: string, items: string[]) => `${label} ${items.length}${items.length ? `: ${items.join(", ")}` : ""}`;
|
|
90
|
+
/** The runtime scope, ids and counts: the deliveries first, then the holds and pointers. */
|
|
91
|
+
export function resetLines(plan: ResetPlan): string[] {
|
|
92
|
+
return [
|
|
93
|
+
`deliveries: ${named("queued", plan.queued)}; ${named("needs_review", plan.needsReview)}; ${named("in flight", plan.inFlight)}`,
|
|
94
|
+
named("manual holds", plan.manualHolds),
|
|
95
|
+
named("budget pauses", plan.budgetPauses),
|
|
96
|
+
named("conductor holds", plan.conductorHolds),
|
|
97
|
+
named("session pointers", plan.sessionPointers),
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Runtime reset of a stopped hub: every queued and needs_review delivery is discarded with reason `reset` through
|
|
103
|
+
* the journal (one resolution_history entry each), manual, budget and conductor holds are cleared and the session
|
|
104
|
+
* pointers dropped, with a crashed run's manifest. The board, logs, audit, recovery records and execution budgets stay.
|
|
105
|
+
*/
|
|
106
|
+
export function resetRuntime(stateDir: string, project: Project): { settled: string[]; plan: ResetPlan; stale: string[] } {
|
|
107
|
+
const file = join(stateDir, "hub.db");
|
|
108
|
+
const plan = planReset(stateDir, project.id);
|
|
109
|
+
const settled: string[] = [];
|
|
110
|
+
if (existsSync(file)) {
|
|
111
|
+
// Opening the journal as a new instance turns dispatching and accepted rows of the stopped run into needs_review.
|
|
112
|
+
const journal = new DeliveryJournal({ file, projectRoot: project.root, projectId: project.id, instanceId: randomUUID() });
|
|
113
|
+
const bus = new Bus({ journal });
|
|
114
|
+
try {
|
|
115
|
+
for (const { id } of bus.queueList().filter((row) => row.state === "queued" || row.state === "needs_review")) {
|
|
116
|
+
// Read again each time: a queue entry's revision is the journal's, and every resolution moves it on.
|
|
117
|
+
bus.resolveDelivery(id, bus.queueShow(id)!.revision, "discard", "reset");
|
|
118
|
+
settled.push(id);
|
|
119
|
+
}
|
|
120
|
+
bus.setManualPaused([]);
|
|
121
|
+
} finally { bus.closeJournal(); }
|
|
122
|
+
const db = new Database(file);
|
|
123
|
+
try {
|
|
124
|
+
db.transaction(() => {
|
|
125
|
+
for (const table of ["budget_pauses", "conductor_holds"]) {
|
|
126
|
+
if (db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) db.run(`DELETE FROM ${table}`);
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
} finally { db.close(); }
|
|
130
|
+
}
|
|
131
|
+
for (const name of SESSION_POINTERS) rmSync(join(stateDir, name), { force: true });
|
|
132
|
+
// The caller has checked that no daemon behind these is alive or uncertain: they are a crashed run's leftovers.
|
|
133
|
+
const stale = MANIFEST.filter((name) => existsSync(join(stateDir, name)));
|
|
134
|
+
for (const name of stale) rmSync(join(stateDir, name), { force: true });
|
|
135
|
+
return { settled, plan, stale };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Why `--all` must not move this state directory into `<root>/.agenthub/archive`, or undefined. An agent with write
|
|
140
|
+
* access to the project, or a repository that commits `.agenthub/`, could otherwise turn the archive into a symlink and
|
|
141
|
+
* have hub.db (PII) moved out of the project, or the `.gitignore` into one that truncates any file it names.
|
|
142
|
+
*/
|
|
143
|
+
export function archiveProblem(root: string, stateDir: string): string | undefined {
|
|
144
|
+
const uid = process.getuid?.();
|
|
145
|
+
const own = (path: string) => { const st = lstatSync(path); return st.isDirectory() && (uid === undefined || st.uid === uid); };
|
|
146
|
+
for (const dir of [join(root, ".agenthub"), stateDir]) if (!own(dir)) return `${dir} is not a directory of this user`;
|
|
147
|
+
const archive = join(root, ".agenthub", "archive");
|
|
148
|
+
if (lstatSync(archive, { throwIfNoEntry: false }) && !own(archive)) return `${archive} is not a directory of this user (a symlink, or another owner's)`;
|
|
149
|
+
const ignore = lstatSync(join(archive, ".gitignore"), { throwIfNoEntry: false });
|
|
150
|
+
if (ignore && !ignore.isFile()) return `${join(archive, ".gitignore")} is not a regular file`;
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Full reset, first step: the state directory moves to `.agenthub/archive/state-<UTC time>/` (0700). Nothing is
|
|
156
|
+
* deleted; moving the archive back restores it. Only the default `<root>/.agenthub/state` qualifies (the caller checks).
|
|
157
|
+
* The rename is the last step: when this throws, nothing was moved.
|
|
158
|
+
*/
|
|
159
|
+
export function archiveState(root: string, stateDir: string, now = new Date()): string {
|
|
160
|
+
const problem = archiveProblem(root, stateDir);
|
|
161
|
+
if (problem) throw new Error(problem);
|
|
162
|
+
const archive = join(root, ".agenthub", "archive");
|
|
163
|
+
if (!lstatSync(archive, { throwIfNoEntry: false })) mkdirSync(archive, { mode: 0o700 });
|
|
164
|
+
// hub.db holds task text, PII included: keep the archive out of git whatever the project's .gitignore says.
|
|
165
|
+
// The local worker's denylist (src/local/deny.ts) keeps it out of its tools, sandbox and memory capture.
|
|
166
|
+
// Verified, not only created: an edited, emptied or hard-linked one is replaced by a rename, never written through.
|
|
167
|
+
// Turn snapshots exclude the archive as well.
|
|
168
|
+
const ignore = join(archive, ".gitignore");
|
|
169
|
+
const current = lstatSync(ignore, { throwIfNoEntry: false });
|
|
170
|
+
if (!current || current.nlink > 1 || readFileSync(ignore, "utf8") !== "*\n") {
|
|
171
|
+
const temporary = join(archive, `.gitignore.${randomUUID()}.tmp`);
|
|
172
|
+
try { writeFileSync(temporary, "*\n", { flag: "wx", mode: 0o600 }); renameSync(temporary, ignore); }
|
|
173
|
+
finally { rmSync(temporary, { force: true }); }
|
|
174
|
+
}
|
|
175
|
+
const target = join(archive, `state-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}`);
|
|
176
|
+
if (lstatSync(target, { throwIfNoEntry: false })) throw new Error(`${target} already exists`);
|
|
177
|
+
chmodSync(stateDir, 0o700);
|
|
178
|
+
// ponytail: checked, then renamed by path; a symlink swapped in for .agenthub/archive in the microseconds between
|
|
179
|
+
// still wins. Rename relative to a held directory descriptor (renameat) if Bun ever exposes one.
|
|
180
|
+
renameSync(stateDir, target);
|
|
181
|
+
return target;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Full reset, second step: a new state directory with only the archive's `project.json`, so the project id and
|
|
186
|
+
* registration stay. Something else may have created the directory meanwhile (the status line tee, 0755).
|
|
187
|
+
*/
|
|
188
|
+
export function startState(stateDir: string, archived: string): void {
|
|
189
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
190
|
+
const st = lstatSync(stateDir);
|
|
191
|
+
if (!st.isDirectory() || st.uid !== (process.getuid?.() ?? st.uid)) throw new Error(`${stateDir} was re-created as something other than a directory of this user`);
|
|
192
|
+
chmodSync(stateDir, 0o700);
|
|
193
|
+
if (existsSync(join(archived, "project.json"))) copyFileSync(join(archived, "project.json"), join(stateDir, "project.json"));
|
|
194
|
+
}
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ContextView } from "../hub/context-window.ts";
|
|
2
2
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
3
3
|
|
|
4
|
-
export interface PeerRow { context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
|
|
5
|
-
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string }
|
|
4
|
+
export interface PeerRow { context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
5
|
+
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
|
|
6
6
|
|
|
7
7
|
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
|
8
8
|
export function backendLabel(backend: BackendRow): string {
|
|
@@ -11,8 +11,9 @@ export function backendLabel(backend: BackendRow): string {
|
|
|
11
11
|
return alias.startsWith(`${kind}/`) ? alias : `${kind}/${alias}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/** `measured` reads as an ISO time unless `time` says otherwise: the console uses local time, as its event headers do. */
|
|
15
|
+
export function contextLine(reading: ContextView, time = (at: number) => new Date(at).toISOString()): string {
|
|
16
|
+
return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${time(reading.measuredAt)}` : ""})`;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export function peerLine(id: string, p: PeerRow): string {
|
|
@@ -24,6 +25,7 @@ export function peerLine(id: string, p: PeerRow): string {
|
|
|
24
25
|
(p.heldBy ? ` ${p.holdNote ?? `held by needs_review ${p.heldBy}; ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`}` : "") +
|
|
25
26
|
(p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
|
|
26
27
|
(p.attached === false ? " disconnected" : "") +
|
|
28
|
+
(p.toolsOnly ? ` ${p.toolsOnly}` : "") +
|
|
27
29
|
(p.paused ? ` (${p.paused})` : "") +
|
|
28
30
|
(p.servedBy ? ` last call: ${p.servedBy}` : "") +
|
|
29
31
|
// Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
|
|
@@ -36,5 +38,7 @@ export function backendLine(backend: BackendRow): string {
|
|
|
36
38
|
return ` model ${backendLabel(backend)} ${backend.state ?? "unknown"} active ${backend.active ?? 0}` +
|
|
37
39
|
(backend.requestedModel ? ` requested ${backend.requestedModel}` : "") +
|
|
38
40
|
(backend.actualModel ? ` actual ${backend.actualModel}` : "") +
|
|
39
|
-
(backend.provider ? ` provider ${backend.provider}` : "")
|
|
41
|
+
(backend.provider ? ` provider ${backend.provider}` : "") +
|
|
42
|
+
(backend.coolingUntil ? ` cooling down until ${backend.coolingUntil} after ${backend.failures ?? "?"} failures` : "") +
|
|
43
|
+
(backend.failingUntil ? ` last dispatch failed, no load moves until ${backend.failingUntil}` : "");
|
|
40
44
|
}
|