@staix/agent-hub 0.12.20 → 0.12.22
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 +32 -0
- package/README.md +17 -9
- package/docs/agent-notes/adapters.md +13 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +7 -3
- package/docs/agent-notes/daemon.md +12 -2
- package/docs/agent-notes/local-worker.md +5 -1
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +5 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +15 -5
- package/docs/operations.md +510 -54
- package/docs/quickstart.md +16 -11
- package/docs/research.md +92 -0
- package/docs/security.md +49 -11
- package/docs/smoke.md +138 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +77 -10
- package/src/adapters/codex-appserver.ts +72 -2
- package/src/adapters/local-worker.ts +46 -11
- package/src/adapters/pi.ts +215 -22
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +16 -5
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +272 -18
- package/src/cli/main.ts +290 -30
- package/src/cli/preview.ts +13 -3
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +123 -20
- package/src/cli/upgrade.ts +108 -19
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +24 -7
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +477 -86
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +4 -2
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -2
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +59 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +99 -55
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +34 -11
- package/src/pi/extension.ts +43 -20
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +24 -2
- package/src/ui/index.html +98 -17
- package/src/ui/task-progress.ts +33 -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";
|
|
@@ -10,22 +11,26 @@ import { loadConfig } from "../hub/daemon.ts";
|
|
|
10
11
|
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
|
-
import { inspectProject,
|
|
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, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, 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 {
|
|
27
|
+
import { createInterface as lineReader } from "node:readline";
|
|
28
|
+
import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner } from "../hub/recovery-store.ts";
|
|
26
29
|
import { childEnv } from "../hub/child-process.ts";
|
|
27
30
|
import { abortRecovery, createOperation, disposeRecovery, liveProjects, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
|
|
28
|
-
import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
31
|
+
import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
32
|
+
import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
|
|
33
|
+
import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
|
|
29
34
|
import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
30
35
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
31
36
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
@@ -33,13 +38,16 @@ import { setupOllamaModel } from "./models-setup.ts";
|
|
|
33
38
|
import { unknownContext } from "../hub/context-window.ts";
|
|
34
39
|
import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
35
40
|
import { parseSince, readEvents } from "../hub/events.ts";
|
|
41
|
+
import { armRuns, benchCompare, benchCsv, benchReport, formatCompare, formatReport as formatBenchReport, formatRuns, readRuns, runSummary, sameSuite } from "../hub/bench.ts";
|
|
42
|
+
import { benchPreflight, defaultIo, runBench } from "./bench.ts";
|
|
43
|
+
import { appendRecords, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
|
|
36
44
|
import { formatReport, summarize, formatTaskReport, summarizeByTask } from "../hub/report.ts";
|
|
37
45
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
38
46
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
39
47
|
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
|
|
40
48
|
import { recordCliAudit } from "./identity-audit.ts";
|
|
41
49
|
import { runConsole } from "./console.ts";
|
|
42
|
-
import { resolveColor } from "./console-state.ts";
|
|
50
|
+
import { permissionBoundary, resolveColor } from "./console-state.ts";
|
|
43
51
|
import { renderHelp } from "./help.ts";
|
|
44
52
|
import { renderTailEvent } from "./tail-render.ts";
|
|
45
53
|
import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
|
|
@@ -97,6 +105,7 @@ function audit(outcome: "run" | "refused" | "invalid"): void {
|
|
|
97
105
|
if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
|
|
98
106
|
if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
|
|
99
107
|
if (identity.role === "tools") audit("run");
|
|
108
|
+
const invokedFrom = process.cwd(); // relative paths a person types (a bench suite) are theirs, not the project's
|
|
100
109
|
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
101
110
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
102
111
|
const lifecycle = { inspectProject, startProject, stopProject };
|
|
@@ -108,6 +117,13 @@ function registeredProject(): Project {
|
|
|
108
117
|
finally { registry.close(); }
|
|
109
118
|
}
|
|
110
119
|
|
|
120
|
+
/** #247: this project's registration for its research records; writing needs `research.enabled` in its config. */
|
|
121
|
+
function researchProject(writes = true): Project {
|
|
122
|
+
const project = matchingProject() ?? fail("this project has no hub registration; run ahub up here once first");
|
|
123
|
+
if (writes && !loadConfig(cwd).research.enabled) fail('research records are off for this project; set "research": { "enabled": true } in .agenthub/config.json first');
|
|
124
|
+
return project;
|
|
125
|
+
}
|
|
126
|
+
|
|
111
127
|
/** The registration of exactly this root and state directory, the only one kill and reset act on; never registers. */
|
|
112
128
|
function matchingProject(): Project | undefined {
|
|
113
129
|
const registry = new Registry();
|
|
@@ -124,10 +140,13 @@ function resetLockFree(): void {
|
|
|
124
140
|
/** A manifest whose daemon is alive or uncertain, by inspectProject's rule; a crashed daemon's leftovers do not count. */
|
|
125
141
|
function liveManifest(): boolean {
|
|
126
142
|
const control = readControl(stateDir);
|
|
127
|
-
if (control &&
|
|
143
|
+
if (control && processLiveness(control.pid, control.pidSignature) !== "gone") return true;
|
|
144
|
+
// A signed manifest is this release's daemon, which writes no hub.pid: one beside it is an older run's leftover.
|
|
145
|
+
if (control?.pidSignature) return false;
|
|
128
146
|
let text: string;
|
|
147
|
+
// Only daemons up to 0.12.20 wrote hub.pid (#226): an unsigned legacy record.
|
|
129
148
|
try { text = readFileSync(join(stateDir, "hub.pid"), "utf8").trim(); } catch { return false; }
|
|
130
|
-
if (/^\d+$/.test(text)) return
|
|
149
|
+
if (/^\d+$/.test(text)) return processLiveness(Number(text)) !== "gone";
|
|
131
150
|
// Empty or garbled: beside a dead daemon's status it is that daemon's leftover too; alone it proves nothing.
|
|
132
151
|
return !control;
|
|
133
152
|
}
|
|
@@ -217,17 +236,16 @@ function hubDaemonCommand(pid: number, root: string): string | undefined {
|
|
|
217
236
|
|
|
218
237
|
/** Live PID candidates for a registration whose project root is gone: the claim, the manifest, the legacy pid file. */
|
|
219
238
|
function orphanPids(project: Project): number[] {
|
|
239
|
+
// #226: a recorded pid counts only while its signature (when one was recorded) still names its process.
|
|
220
240
|
const candidates = new Set<number>();
|
|
221
|
-
if (typeof project.pid === "number") candidates.add(project.pid);
|
|
241
|
+
if (typeof project.pid === "number" && processLiveness(project.pid, project.pidSignature) === "live") candidates.add(project.pid);
|
|
222
242
|
const control = readControl(project.stateDir);
|
|
223
|
-
if (control && Number.isSafeInteger(control.pid)) candidates.add(control.pid!);
|
|
243
|
+
if (control && Number.isSafeInteger(control.pid) && processLiveness(control.pid, control.pidSignature) === "live") candidates.add(control.pid!);
|
|
224
244
|
try {
|
|
225
245
|
const pid = Number(readFileSync(join(project.stateDir, "hub.pid"), "utf8").trim());
|
|
226
246
|
if (Number.isSafeInteger(pid) && pid > 0) candidates.add(pid);
|
|
227
247
|
} catch { /* no legacy pid file */ }
|
|
228
|
-
return [...candidates].filter((pid) =>
|
|
229
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
230
|
-
});
|
|
248
|
+
return [...candidates].filter((pid) => processLiveness(pid) === "live");
|
|
231
249
|
}
|
|
232
250
|
|
|
233
251
|
const processGone = (pid: number): boolean => !processCommandLine(pid);
|
|
@@ -324,32 +342,124 @@ async function hold(t: "pause" | "resume"): Promise<void> {
|
|
|
324
342
|
console.log(`${args[0]} is ${res.state}`);
|
|
325
343
|
}
|
|
326
344
|
|
|
327
|
-
function spawnRecovery(operation: RecoveryOperation): void {
|
|
345
|
+
function spawnRecovery(operation: RecoveryOperation, quiet = false): void {
|
|
328
346
|
const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
|
|
329
347
|
cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
|
|
330
348
|
});
|
|
331
349
|
child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
|
|
332
350
|
child.unref();
|
|
333
351
|
// #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")}`);
|
|
352
|
+
if (!quiet) console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** #272: a terminal session for the interactive screens. One line reader for the whole session, in the terminal's own
|
|
356
|
+
* line mode, so Ctrl+C stays a signal: from then on every question answers null and the screens leave. The terminal
|
|
357
|
+
* is not read again after it (a parent that died on the same Ctrl+C may have handed it back to the shell), and no
|
|
358
|
+
* runner is ever stopped by it. */
|
|
359
|
+
async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<void> {
|
|
360
|
+
const rl = lineReader({ input: process.stdin, terminal: false });
|
|
361
|
+
const lines: string[] = [], waiters: ((line: string | null) => void)[] = [];
|
|
362
|
+
let ended = false, interrupted = false;
|
|
363
|
+
rl.on("line", (line) => { const next = waiters.shift(); if (next) next(line.trim()); else lines.push(line.trim()); });
|
|
364
|
+
rl.on("close", () => { ended = true; for (const waiter of waiters.splice(0)) waiter(null); });
|
|
365
|
+
const onInterrupt = () => { interrupted = true; for (const waiter of waiters.splice(0)) waiter(null); };
|
|
366
|
+
process.on("SIGINT", onInterrupt);
|
|
367
|
+
try {
|
|
368
|
+
await screens({
|
|
369
|
+
ask: (question) => {
|
|
370
|
+
if (interrupted) return Promise.resolve(null);
|
|
371
|
+
process.stdout.write(question);
|
|
372
|
+
const typed = lines.shift();
|
|
373
|
+
return typed !== undefined ? Promise.resolve(typed) : ended ? Promise.resolve(null) : new Promise((resolve) => waiters.push(resolve));
|
|
374
|
+
},
|
|
375
|
+
out: (line) => console.log(line),
|
|
376
|
+
run: (argv) => new Promise((resolve) => {
|
|
377
|
+
// The command owns the terminal while it runs; a reader whose input already ended has nothing to pause.
|
|
378
|
+
if (!ended) rl.pause();
|
|
379
|
+
const done = (code: number) => { if (!ended) rl.resume(); resolve(code); };
|
|
380
|
+
const child = spawn(process.execPath, argv, { cwd, stdio: "inherit" });
|
|
381
|
+
child.on("error", () => done(1));
|
|
382
|
+
child.on("close", (code) => done(code ?? 1));
|
|
383
|
+
}),
|
|
384
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
385
|
+
interrupted: () => interrupted,
|
|
386
|
+
// Scripted input (a pipe) is answers typed ahead for the next question; only a terminal's line is a key press.
|
|
387
|
+
typed: () => !ended && lines.splice(0).length > 0,
|
|
388
|
+
});
|
|
389
|
+
} finally { process.off("SIGINT", onInterrupt); rl.close(); }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost {
|
|
393
|
+
const scope = kind === "restart" ? cwd : undefined;
|
|
394
|
+
return {
|
|
395
|
+
plan: () => makeUpgradePlan(kind, version, scope),
|
|
396
|
+
apply: async (plan, interrupted) => {
|
|
397
|
+
assertLifecycleAvailable();
|
|
398
|
+
const current = await makeUpgradePlan(kind, plan.version, scope);
|
|
399
|
+
if (current.fingerprint !== plan.fingerprint) throw new Error("the plan changed during review; review it again");
|
|
400
|
+
// The check above takes seconds: a Ctrl+C in that time is still a no.
|
|
401
|
+
if (interrupted()) throw new Error("interrupted; nothing was started");
|
|
402
|
+
const operation = createOperation(plan, preserveSource(plan));
|
|
403
|
+
spawnRecovery(operation, true);
|
|
404
|
+
return operation;
|
|
405
|
+
},
|
|
406
|
+
lock: recoveryLock,
|
|
407
|
+
read: (id) => readOperation<RecoveryOperation>(id),
|
|
408
|
+
runner: recoveryRunner,
|
|
409
|
+
live: (op) => liveProjects(op, makeRecoveryDriver().inspect),
|
|
410
|
+
endPeer: (planned, peer) => endPlannedPeer(planned, peer),
|
|
411
|
+
runnerStoppable: (id) => signedRunner(id) !== undefined,
|
|
412
|
+
stopRunner: (id) => stopSignedRunner(id),
|
|
413
|
+
entry: join(import.meta.dir, "main.js"),
|
|
414
|
+
version: VERSION,
|
|
415
|
+
};
|
|
335
416
|
}
|
|
336
417
|
|
|
337
418
|
async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
338
419
|
const { one, rest } = takeFlags(args, ["--to"], []);
|
|
339
|
-
if (rest.some((a) => !["--dry-run", "--yes"].includes(a)) || (kind === "restart" && one["--to"])) fail("usage: ahub upgrade --to <version> [--dry-run] [--yes] | ahub restart [--dry-run] [--yes]");
|
|
340
|
-
if (kind === "upgrade" && !one["--to"]) fail("upgrade requires --to <exact-version>");
|
|
420
|
+
if (rest.some((a) => !["--dry-run", "--yes"].includes(a)) || (kind === "restart" && one["--to"])) fail("usage: ahub upgrade [--to <version>] [--dry-run] [--yes] | ahub restart [--dry-run] [--yes]");
|
|
341
421
|
if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
|
|
342
|
-
const
|
|
422
|
+
const dry = rest.includes("--dry-run"), yes = rest.includes("--yes");
|
|
423
|
+
// #272: on a terminal, with neither flag, the plan is reviewed, applied and followed in one session.
|
|
424
|
+
const interactive = !dry && !yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
425
|
+
let to = one["--to"];
|
|
426
|
+
if (kind === "upgrade" && !to) {
|
|
427
|
+
if (yes || (!dry && !interactive)) fail("upgrade requires --to <exact-version>");
|
|
428
|
+
to = await latestRelease();
|
|
429
|
+
console.error(`ahub: the latest release is ${to}`);
|
|
430
|
+
}
|
|
431
|
+
// Before the value reaches a package spec or the registry: a range such as 0.13 would resolve to whatever matches.
|
|
432
|
+
if (to !== undefined) { try { exactVersion(to); } catch (error) { fail((error as Error).message); } }
|
|
433
|
+
// An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
|
|
434
|
+
if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
|
|
435
|
+
const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", "upgrade", "--to", to!, ...rest];
|
|
436
|
+
console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
|
|
437
|
+
if (interactive) {
|
|
438
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
439
|
+
try { if (!/^(|y|yes)$/i.test((await rl.question("Continue? [Y/n] ")).trim())) return; } finally { rl.close(); }
|
|
440
|
+
}
|
|
441
|
+
// The coordinator owns the terminal from here. This process only waits, and a Ctrl+C meant for that session must not
|
|
442
|
+
// end it first and hand the terminal back to the shell under it.
|
|
443
|
+
process.on("SIGINT", () => {});
|
|
444
|
+
const code = await new Promise<number>((resolve) => {
|
|
445
|
+
const child = spawn(process.execPath, argv, { cwd, stdio: "inherit", env: { ...process.env, AGENTHUB_UPGRADE_HANDOVER: "1" } });
|
|
446
|
+
child.on("error", (error) => { console.error(`ahub: cannot run bun x: ${error.message}`); resolve(1); });
|
|
447
|
+
child.on("close", (status) => resolve(status ?? 1));
|
|
448
|
+
});
|
|
449
|
+
process.exit(code);
|
|
450
|
+
}
|
|
451
|
+
if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io));
|
|
452
|
+
const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
|
|
343
453
|
console.log(JSON.stringify(plan, null, 2));
|
|
344
454
|
// #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
|
|
345
455
|
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
456
|
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
457
|
const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
|
|
348
458
|
for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
|
|
349
|
-
if (
|
|
459
|
+
if (dry) return;
|
|
350
460
|
if (blockers.length) fail("plan has blockers; no runtime was changed");
|
|
351
461
|
assertLifecycleAvailable();
|
|
352
|
-
if (!
|
|
462
|
+
if (!yes) {
|
|
353
463
|
if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
|
|
354
464
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
355
465
|
try {
|
|
@@ -367,9 +477,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
367
477
|
upgrade: () => upgrade("upgrade"),
|
|
368
478
|
restart: () => upgrade("restart"),
|
|
369
479
|
recovery: async () => {
|
|
370
|
-
const [action,
|
|
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
|
-
|
|
480
|
+
const [action, given, ...more] = args;
|
|
481
|
+
const usage = "usage: ahub recovery [status|resume|abort] [<operation-id>] | ahub recovery dispose [<operation-id>] --fresh-session <peer>|--stop-and-archive --reason <text>";
|
|
482
|
+
// #272: without an id, the operation that holds the machine's lock; bare on a terminal, its screen.
|
|
483
|
+
const named = !!given && operationIdPattern.test(given);
|
|
484
|
+
const id = named ? given : recoveryLock();
|
|
485
|
+
const rest = named || given === undefined ? more : [given, ...more];
|
|
486
|
+
if (action === undefined && process.stdin.isTTY && process.stdout.isTTY) {
|
|
487
|
+
if (!id) return console.log("no recovery operation is open");
|
|
488
|
+
return onTerminal(async (io) => { await operationScreen(upgradeHost("restart", VERSION), io, id); });
|
|
489
|
+
}
|
|
490
|
+
if (!["status", "resume", "abort", "dispose"].includes(action ?? "") || (action !== "dispose" && rest.length)) fail(usage);
|
|
491
|
+
if (!id) fail("no recovery operation holds the lock; name one by its id");
|
|
373
492
|
const operation = readOperation<RecoveryOperation>(id);
|
|
374
493
|
const runner = recoveryRunner(id);
|
|
375
494
|
if (action === "status") {
|
|
@@ -525,10 +644,6 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
525
644
|
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
526
645
|
const terminal = await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
|
|
527
646
|
if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
|
|
528
|
-
// Native hook identity exists in an ordinary terminal too; this is not an Orca recovery record.
|
|
529
|
-
const file = join(stateDir, "claude-launch.json");
|
|
530
|
-
writeFileSync(`${file}.tmp`, JSON.stringify({ instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID }), { mode: 0o600 });
|
|
531
|
-
chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
|
|
532
647
|
}
|
|
533
648
|
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
534
649
|
// project local, then project, then user.
|
|
@@ -542,9 +657,26 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
542
657
|
}
|
|
543
658
|
// Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
|
|
544
659
|
const facts = claudeObservationHooks(projectConfig(), { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
660
|
+
let previousLaunch: unknown;
|
|
661
|
+
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")); } catch { /* no previous managed launch */ }
|
|
545
662
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
546
|
-
|
|
547
|
-
|
|
663
|
+
let result: Awaited<ReturnType<typeof runClaudeLaunch>>;
|
|
664
|
+
try {
|
|
665
|
+
let launchRecord: ClaudeLaunchRecord | undefined;
|
|
666
|
+
if (control?.instanceId) {
|
|
667
|
+
// Publish only the final launch's installed hook and effective permission flags.
|
|
668
|
+
launchRecord = { instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
|
|
669
|
+
recordClaudeLaunch(stateDir, launchRecord);
|
|
670
|
+
cleanupStaleClaudeSettings(stateDir, previousLaunch);
|
|
671
|
+
}
|
|
672
|
+
if (launch.warning) console.error(launch.warning);
|
|
673
|
+
const env = nativeLaunchEnv("claude", childEnv());
|
|
674
|
+
result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
|
|
675
|
+
} finally {
|
|
676
|
+
cleanupClaudeSettings(stateDir, launch.settingsFile);
|
|
677
|
+
}
|
|
678
|
+
if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
|
|
679
|
+
process.exit(result.status ?? 1);
|
|
548
680
|
},
|
|
549
681
|
|
|
550
682
|
codex: async () => {
|
|
@@ -561,7 +693,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
561
693
|
const before = readControl(stateDir);
|
|
562
694
|
if (recovering && before?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, before.instanceId);
|
|
563
695
|
const hub = await connect();
|
|
564
|
-
const res = await hub.request({ t: "start", peer: "codex", operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
696
|
+
const res = await hub.request({ t: "start", peer: "codex", args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
565
697
|
hub.close();
|
|
566
698
|
if (!res.ok) fail(res.error);
|
|
567
699
|
const control = readControl(stateDir);
|
|
@@ -766,6 +898,17 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
766
898
|
if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
|
|
767
899
|
if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
|
|
768
900
|
if (sub === "assign") return console.log(await taskOp("task_assign", { id: rest[0], peer: rest[1] }));
|
|
901
|
+
if (sub === "label") {
|
|
902
|
+
// #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
|
|
903
|
+
const { one, rest: positional } = takeFlags(rest, ["--created"], []);
|
|
904
|
+
const id = Number(positional[0]), label = positional[1] as Label;
|
|
905
|
+
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>]`);
|
|
906
|
+
const project = researchProject();
|
|
907
|
+
let record: TaskRecord;
|
|
908
|
+
try { record = labelTarget(readStores(project.id), readEvents(join(stateDir, "events.jsonl")), id, one["--created"]); } catch (error) { fail((error as Error).message); }
|
|
909
|
+
appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
|
|
910
|
+
return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
|
|
911
|
+
}
|
|
769
912
|
if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | escalate <id>");
|
|
770
913
|
// `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
|
|
771
914
|
// short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
|
|
@@ -814,6 +957,39 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
814
957
|
pause: () => hold("pause"),
|
|
815
958
|
resume: () => hold("resume"),
|
|
816
959
|
|
|
960
|
+
stop: async () => {
|
|
961
|
+
const peer = args[0];
|
|
962
|
+
if (args.length !== 1 || !peer || !/^[a-z][a-z0-9-]{0,31}$/.test(peer)) fail("usage: ahub stop <peer>");
|
|
963
|
+
const hub = await connect();
|
|
964
|
+
try {
|
|
965
|
+
const reply = await hub.request({ t: "peer_stop", peer }, 35_000);
|
|
966
|
+
if (reply.ok !== true) {
|
|
967
|
+
const error = String(reply.error ?? "peer stop request refused");
|
|
968
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error);
|
|
969
|
+
}
|
|
970
|
+
console.log(`${peer}: ${reply.state}`);
|
|
971
|
+
} finally { hub.close(); }
|
|
972
|
+
},
|
|
973
|
+
|
|
974
|
+
permission: async () => {
|
|
975
|
+
const positional = args.filter(arg => arg !== "--yes");
|
|
976
|
+
const [peer, mode] = positional;
|
|
977
|
+
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]");
|
|
978
|
+
if (mode === "never-ask" && !args.includes("--yes")) fail("never-ask requires --yes; nothing was changed");
|
|
979
|
+
if (mode === "never-ask" && peer) console.error(`never-ask: ${permissionBoundary(peer)}; approval prompts are disabled`);
|
|
980
|
+
const hub = await connect();
|
|
981
|
+
try {
|
|
982
|
+
const reply = await hub.request({ t: "permission", ...(peer ? { peer } : {}), ...(mode ? { mode, confirmed: args.includes("--yes") } : {}) }, 35_000);
|
|
983
|
+
if (reply.ok === false) {
|
|
984
|
+
const error = String(reply.error ?? "permission request refused");
|
|
985
|
+
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);
|
|
986
|
+
}
|
|
987
|
+
if (reply.peers) for (const [id, value] of Object.entries(reply.peers)) console.log(`${id}: ${value}`);
|
|
988
|
+
else console.log(`${peer}: ${reply.permissionMode}`);
|
|
989
|
+
if (typeof reply.note === "string" && reply.note) console.log(reply.note);
|
|
990
|
+
} finally { hub.close(); }
|
|
991
|
+
},
|
|
992
|
+
|
|
817
993
|
permit: async () => {
|
|
818
994
|
const [id, option] = args;
|
|
819
995
|
if (!id || !option) fail("usage: ahub permit <id> <option|deny>");
|
|
@@ -831,6 +1007,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
831
1007
|
if (args.includes("--json")) return console.log(JSON.stringify(status, null, 2));
|
|
832
1008
|
console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
|
|
833
1009
|
if (status.deliveryError) console.log(` delivery storage: ${status.deliveryError}; dispatch is stopped`);
|
|
1010
|
+
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`);
|
|
834
1011
|
for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
|
|
835
1012
|
const peers = Object.entries(status.peers as Record<string, PeerRow>);
|
|
836
1013
|
for (const [id, p] of peers) console.log(peerLine(id, { ...p, context: p.context ?? unknownContext() }));
|
|
@@ -846,6 +1023,89 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
846
1023
|
export: () => {
|
|
847
1024
|
for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
|
|
848
1025
|
},
|
|
1026
|
+
bench: async () => {
|
|
1027
|
+
// #251: benchmark suites run against the peers attached here, and their comparison across arms.
|
|
1028
|
+
const json = args.includes("--json");
|
|
1029
|
+
const [sub, ...rest] = args.filter((a) => a !== "--json");
|
|
1030
|
+
if (sub === "run") {
|
|
1031
|
+
const { one, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks"], []);
|
|
1032
|
+
const suite = positional[0];
|
|
1033
|
+
const repeat = one["--repeat"] === undefined ? 1 : Number(one["--repeat"]);
|
|
1034
|
+
if (!suite || positional.length !== 1 || !one["--arm"]?.trim() || !Number.isSafeInteger(repeat) || repeat < 1 || repeat > 100) {
|
|
1035
|
+
fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b]");
|
|
1036
|
+
}
|
|
1037
|
+
await benchPreflight(cwd, loadConfig(cwd).bench.enabled); // the suite's refs are checked once it is read
|
|
1038
|
+
let interrupted = false;
|
|
1039
|
+
const stop = () => { interrupted = true; console.log("stopping at the next check: the attempt in progress is recorded as interrupted (a task still open is named)"); };
|
|
1040
|
+
process.on("SIGINT", stop);
|
|
1041
|
+
try {
|
|
1042
|
+
const run = await runBench({ cwd, stateDir, suitePath: resolve(invokedFrom, suite!), arm: one["--arm"]!.trim(), repeat, only: one["--tasks"]?.split(",").map((t) => t.trim()).filter(Boolean), stopped: () => interrupted }, defaultIo(stateDir, cwd));
|
|
1043
|
+
return console.log(formatBenchReport(benchReport(readRuns().filter((r) => r.header.run === run))).join("\n"));
|
|
1044
|
+
} finally { process.off("SIGINT", stop); }
|
|
1045
|
+
}
|
|
1046
|
+
const runs = readRuns();
|
|
1047
|
+
if (sub === undefined || sub === "list") return console.log(json ? JSON.stringify(runs.map(runSummary), null, 2) : formatRuns(runs).join("\n"));
|
|
1048
|
+
if (sub === "status") {
|
|
1049
|
+
const live = runs.filter((r) => r.state === "running" || r.state === "unknown");
|
|
1050
|
+
return console.log(json ? JSON.stringify(live.map(runSummary), null, 2) : live.length ? formatRuns(live).join("\n") : "no benchmark run is running");
|
|
1051
|
+
}
|
|
1052
|
+
if (sub === "report") {
|
|
1053
|
+
const run = runs.find((r) => r.header.run === rest[0]) ?? fail(`usage: ahub bench report <run> (ahub bench list shows the runs)`);
|
|
1054
|
+
const r = benchReport([run]);
|
|
1055
|
+
return console.log(json ? JSON.stringify(r, null, 2) : formatBenchReport(r).join("\n"));
|
|
1056
|
+
}
|
|
1057
|
+
if (sub === "compare") {
|
|
1058
|
+
const mixed = rest.includes("--mixed");
|
|
1059
|
+
const { one, rest: picks } = takeFlags(rest.filter((a) => a !== "--mixed"), ["--suite"], []);
|
|
1060
|
+
const scoped = one["--suite"] ? runs.filter((r) => r.header.suite === one["--suite"]) : runs;
|
|
1061
|
+
const groups = picks.map((pick) => {
|
|
1062
|
+
const byRun = scoped.filter((r) => r.header.run === pick);
|
|
1063
|
+
const chosen = byRun.length ? byRun : armRuns(scoped, pick);
|
|
1064
|
+
if (!chosen.length) fail(`no run, or arm with a run that is over, named ${pick}${one["--suite"] ? ` in suite ${one["--suite"]}` : ""}`);
|
|
1065
|
+
return { arm: byRun.length ? `${pick} [${byRun[0]!.header.arm}]` : pick, runs: chosen };
|
|
1066
|
+
});
|
|
1067
|
+
if (groups.length < 2) fail("usage: ahub bench compare <run|arm> <run|arm>... [--suite <name>] [--mixed] [--json]");
|
|
1068
|
+
const differs = sameSuite(groups);
|
|
1069
|
+
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`);
|
|
1070
|
+
const c = benchCompare(groups);
|
|
1071
|
+
return console.log(json ? JSON.stringify(c, null, 2) : formatCompare(c).join("\n"));
|
|
1072
|
+
}
|
|
1073
|
+
if (sub === "export") {
|
|
1074
|
+
const format = rest.includes("--format") ? rest[rest.indexOf("--format") + 1] : "jsonl";
|
|
1075
|
+
if (format !== "jsonl" && format !== "csv") fail("--format takes jsonl or csv");
|
|
1076
|
+
if (format === "csv") return void process.stdout.write(benchCsv(runs));
|
|
1077
|
+
for (const r of runs) for (const record of [r.header, ...r.attempts, ...(r.end ? [r.end] : [])]) console.log(JSON.stringify(record));
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
fail("usage: ahub bench run|list|status|report|compare|export (ahub help bench)");
|
|
1081
|
+
},
|
|
1082
|
+
research: () => {
|
|
1083
|
+
// #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.
|
|
1084
|
+
const [sub] = args;
|
|
1085
|
+
// Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored.
|
|
1086
|
+
const flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
|
|
1087
|
+
for (let i = sub === "export" || sub === "backfill" ? 1 : 0; i < args.length; i++) {
|
|
1088
|
+
if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
|
|
1089
|
+
if (valued.has(args[i]!)) i++;
|
|
1090
|
+
}
|
|
1091
|
+
if (sub === "backfill") {
|
|
1092
|
+
const project = researchProject();
|
|
1093
|
+
const written = appendRecords(project.id, taskRecords(readEvents(join(stateDir, "events.jsonl")), project.id, { version: VERSION, source: "backfill" }));
|
|
1094
|
+
return console.log(`${written} research record${written === 1 ? "" : "s"} written from this project's events`);
|
|
1095
|
+
}
|
|
1096
|
+
const all = args.includes("--all");
|
|
1097
|
+
const records = readStores(all ? undefined : researchProject(false).id);
|
|
1098
|
+
const from = since();
|
|
1099
|
+
if (sub === "export") {
|
|
1100
|
+
const format = args[args.indexOf("--format") + 1] ?? "jsonl";
|
|
1101
|
+
if (args.includes("--format") && !["jsonl", "csv"].includes(format)) fail("--format takes jsonl or csv");
|
|
1102
|
+
const kept = records.filter((r) => r.kind === "label" || Date.parse(r.approvedAt) >= from);
|
|
1103
|
+
return void process.stdout.write(args.includes("--format") && format === "csv" ? toCsv(kept) : kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
|
|
1104
|
+
}
|
|
1105
|
+
if (sub !== undefined && !sub.startsWith("--")) fail("usage: ahub research [--since 30d] [--all] [--json] | export [--format jsonl|csv] [--since] [--all] | backfill");
|
|
1106
|
+
const r = researchReport(records, from);
|
|
1107
|
+
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
|
|
1108
|
+
},
|
|
849
1109
|
report: () => {
|
|
850
1110
|
const by = args.indexOf("--by");
|
|
851
1111
|
if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
|
|
@@ -1020,7 +1280,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1020
1280
|
// The prefix tells a concurrent reset's claim from a daemon's.
|
|
1021
1281
|
const claim = `reset-${randomUUID()}`;
|
|
1022
1282
|
try {
|
|
1023
|
-
if (!registry.claim(project.id, claim, process.pid)) {
|
|
1283
|
+
if (!registry.claim(project.id, claim, process.pid, processSignature(process.pid))) {
|
|
1024
1284
|
const holder = registry.get(project.id);
|
|
1025
1285
|
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
1286
|
}
|
package/src/cli/preview.ts
CHANGED
|
@@ -38,9 +38,19 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
|
|
|
38
38
|
}
|
|
39
39
|
// Build with original inputs so channel selection and owned-flag validation match normal launch.
|
|
40
40
|
const facts = claudeObservationHooks(config, { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
41
|
-
launch = buildLaunch(tool, args, { unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
launch = buildLaunch(tool, args, { preview: true, unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
|
|
42
|
+
// Merged caller settings change the native argument count. Rebuild a redacted preview,
|
|
43
|
+
// retaining the managed hooks but never serializing caller settings or their file path.
|
|
44
|
+
const safeArgs = args.map((arg, i) => {
|
|
45
|
+
if (args[i - 1] === "--settings") return "{}";
|
|
46
|
+
if (arg.startsWith("--settings=")) return "--settings={}";
|
|
47
|
+
if (["--unattended", "--safe", "--new"].includes(arg)) return arg;
|
|
48
|
+
return publicArg(arg);
|
|
49
|
+
});
|
|
50
|
+
const publicLaunch = buildLaunch(tool, safeArgs, { preview: true, unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir,
|
|
51
|
+
...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, facts });
|
|
52
|
+
publicLaunch.args.splice(0, 2, ...launch.args.slice(0, 2)); // channel selection was verified against the original MCP config
|
|
53
|
+
launch = publicLaunch;
|
|
44
54
|
} else if (tool === "codex") {
|
|
45
55
|
// A fresh native proxy is assigned by start, never guessed from an old status file.
|
|
46
56
|
launch = buildLaunch(tool, args, { unattended, proxyUrl: UNRESOLVED, codexBin: config.codex_bin === "codex" ? "codex" : REDACTED });
|
|
@@ -67,6 +67,23 @@ export async function registryRelease(version: string, run: RunCommand = runComm
|
|
|
67
67
|
return { version, integrity: data["dist.integrity"] };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** #272: the registry's latest release, the target when `--to` is left out. */
|
|
71
|
+
export async function latestRelease(run: RunCommand = runCommand): Promise<string> {
|
|
72
|
+
const result = await run(["npm", "view", "@staix/agent-hub", "version", "--json"], { timeoutMs: 30_000 });
|
|
73
|
+
if (result.code !== 0) throw new Error("registry metadata unavailable for @staix/agent-hub; name the release with --to <version>");
|
|
74
|
+
let version: unknown;
|
|
75
|
+
try { version = JSON.parse(result.stdout); } catch { /* reported below */ }
|
|
76
|
+
if (typeof version !== "string") throw new Error("registry returned an unexpected latest release; name the release with --to <version>");
|
|
77
|
+
return exactVersion(version);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ponytail: compares the numeric triple only, so a pre-release never counts as newer than its own release; a semver compare if pre-releases are ever published. */
|
|
81
|
+
export function newerVersion(a: string, b: string): boolean {
|
|
82
|
+
const [x, y] = [a, b].map((v) => v.split("-")[0]!.split(".").map(Number));
|
|
83
|
+
for (let i = 0; i < 3; i++) if (x![i] !== y![i]) return (x![i] ?? 0) > (y![i] ?? 0);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
70
87
|
/** Stage outside the mutable global install; retain successful versions for recovery. */
|
|
71
88
|
export async function stageRelease(version: string, integrity: string, run: RunCommand = runCommand, home = hubHome()): Promise<{ root: string; digest: string }> {
|
|
72
89
|
exactVersion(version);
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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; toolsOnly?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
|
+
export interface PeerRow { permissionMode?: string; 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
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. */
|
|
@@ -26,6 +26,7 @@ export function peerLine(id: string, p: PeerRow): string {
|
|
|
26
26
|
(p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
|
|
27
27
|
(p.attached === false ? " disconnected" : "") +
|
|
28
28
|
(p.toolsOnly ? ` ${p.toolsOnly}` : "") +
|
|
29
|
+
(p.permissionMode && p.permissionMode !== "ask" ? ` permission: ${p.permissionMode}` : "") +
|
|
29
30
|
(p.paused ? ` (${p.paused})` : "") +
|
|
30
31
|
(p.servedBy ? ` last call: ${p.servedBy}` : "") +
|
|
31
32
|
// Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
|