@staix/agent-hub 0.12.30 → 0.12.32
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 +33 -0
- package/README.md +29 -9
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +5 -0
- package/docs/agent-notes/daemon.md +14 -8
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +2 -2
- package/docs/operations.md +236 -37
- package/docs/quickstart.md +24 -5
- package/docs/security.md +42 -10
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verified.json +20 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1361 -44
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/definitions/builtin-metadata.ts +121 -0
- package/src/adapters/definitions/kimi-native.ts +4 -0
- package/src/adapters/definitions/qwen-native.ts +24 -0
- package/src/adapters/definitions/qwen.ts +71 -0
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +11 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +130 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +16 -5
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +208 -40
- package/src/cli/output.ts +15 -2
- package/src/cli/preview.ts +8 -5
- package/src/cli/recovery-guidance.ts +137 -0
- package/src/cli/recovery-presentation-options.ts +8 -0
- package/src/cli/recovery-session-file.ts +18 -0
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -74
- package/src/cli/upgrade-labels.ts +30 -0
- package/src/cli/upgrade-presentation-data.ts +41 -0
- package/src/cli/upgrade-presentation-flags.ts +31 -0
- package/src/cli/upgrade-presentation.ts +217 -0
- package/src/cli/upgrade-restore-state.ts +137 -0
- package/src/cli/upgrade-runtime.ts +430 -175
- package/src/cli/upgrade.ts +325 -49
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +89 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/control-client.ts +5 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +931 -209
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +13 -13
- package/src/hub/peers.ts +12 -2
- package/src/hub/permission-mode.ts +5 -3
- package/src/hub/recovery-continuity.ts +152 -0
- package/src/hub/recovery-store.ts +10 -4
- package/src/hub/routing.ts +35 -16
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +52 -37
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +24 -8
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +199 -40
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/memory/capture.ts +2 -1
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +35 -16
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/cli/launch.ts
CHANGED
|
@@ -393,12 +393,7 @@ export function buildKimiLaunch(command: string[], model?: string): Launch {
|
|
|
393
393
|
return { cmd: cmd!, args: model ? ["--model", model, ...args] : args };
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
-
|
|
397
|
-
export function buildQwenLaunch(command: string[], model?: string): Launch {
|
|
398
|
-
const [cmd, ...args] = command;
|
|
399
|
-
if (!cmd) throw new Error("qwen_cmd must name an executable");
|
|
400
|
-
return { cmd, args: [...args, ...(model ? ["--model", model] : [])] };
|
|
401
|
-
}
|
|
396
|
+
export { buildQwenLaunch } from "../adapters/definitions/qwen-native.ts";
|
|
402
397
|
|
|
403
398
|
/** The configured ACP argv has a TUI form only when its exact final word is the ACP subcommand. */
|
|
404
399
|
export function buildKimiTuiLaunch(command: string[], model?: string): Launch {
|
package/src/cli/main.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
2
3
|
import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board-read.ts";
|
|
3
4
|
import { matchesPolicy } from "../hub/path-policy.ts";
|
|
4
5
|
import { projectedDiff } from "../hub/projected-diff.ts";
|
|
@@ -43,13 +44,16 @@ import { VERSION } from "../version.ts";
|
|
|
43
44
|
import { freeText } from "./free-text.ts";
|
|
44
45
|
import { createInterface } from "node:readline/promises";
|
|
45
46
|
import { createInterface as lineReader } from "node:readline";
|
|
46
|
-
import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner } from "../hub/recovery-store.ts";
|
|
47
|
+
import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner, supportsAction } from "../hub/recovery-store.ts";
|
|
47
48
|
import { childEnv } from "../hub/child-process.ts";
|
|
48
|
-
import { abortRecovery, createOperation, disposeRecovery, liveProjects, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
|
|
49
|
+
import { abortRecovery, createOperation, disposeRecovery, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
|
|
49
50
|
import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
50
51
|
import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
|
|
51
52
|
import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
|
|
52
|
-
import {
|
|
53
|
+
import { readRecoveryPresentationFlags } from "./upgrade-presentation-flags.ts";
|
|
54
|
+
import { renderOperation, renderBlocked, renderSummary, type RecoveryPresentationOptions } from "./upgrade-presentation.ts";
|
|
55
|
+
import { recordTerminalLaunch, type TerminalPeer } from "./terminal-recovery.ts";
|
|
56
|
+
import { stopAndArchiveSummary } from "./recovery-guidance.ts";
|
|
53
57
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
54
58
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
55
59
|
|
|
@@ -65,7 +69,7 @@ import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, label
|
|
|
65
69
|
import { summarize, summarizeByTask } from "../hub/report.ts";
|
|
66
70
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
67
71
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
68
|
-
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
|
|
72
|
+
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, LIVE_TEST_PERSON_REFUSAL, liveTestRefusal, peerCommandRefusal } from "./identity.ts";
|
|
69
73
|
import { recordCliAudit } from "./identity-audit.ts";
|
|
70
74
|
import { runConsole } from "./console.ts";
|
|
71
75
|
import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
|
|
@@ -138,7 +142,7 @@ function audit(outcome: "run" | "refused" | "invalid"): void {
|
|
|
138
142
|
}
|
|
139
143
|
}
|
|
140
144
|
if (identity.role === "invalid") { audit("invalid"); fail(`${identity.reason}; use ahub console or a terminal with no agent markers`); }
|
|
141
|
-
if (delegationRefused) { audit("refused"); fail(
|
|
145
|
+
if (delegationRefused) { audit("refused"); fail(LIVE_TEST_PERSON_REFUSAL); }
|
|
142
146
|
if (identity.role === "tools" && commandAccess === "console") { audit("refused"); fail(peerCommandRefusal(identity.peer, commandLabel)); }
|
|
143
147
|
if (identity.role === "tools" || delegatedPeer) audit("run");
|
|
144
148
|
// Forward only to ahub's own continuation commands, never to native agent argv or an environment flag.
|
|
@@ -353,6 +357,7 @@ function fail(message: string): never {
|
|
|
353
357
|
async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient): Promise<string> {
|
|
354
358
|
const hub = connection ?? await connect();
|
|
355
359
|
try {
|
|
360
|
+
if (op === "hub_task_cancel" && !hub.features.includes("task_cancel")) fail("this daemon does not support task cancellation; upgrade it first");
|
|
356
361
|
const res = await hub.request({ t: "task", op, args: a }, op === "github_import" ? 60_000 : 30_000);
|
|
357
362
|
if (!res.ok) { if (throwOnError) throw new Error(res.error); audit("refused"); fail(res.error); }
|
|
358
363
|
return res.text;
|
|
@@ -398,21 +403,50 @@ async function hold(t: "pause" | "resume"): Promise<void> {
|
|
|
398
403
|
console.log(`${args[0]} is ${res.state}`);
|
|
399
404
|
}
|
|
400
405
|
|
|
401
|
-
function
|
|
406
|
+
function recoveryDisplay() {
|
|
407
|
+
return readRecoveryPresentationFlags(args, { isTTY: !!process.stdout.isTTY, columns: process.stdout.columns ?? 80, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
|
|
408
|
+
}
|
|
409
|
+
const humanRecovery = (json: boolean) => !json && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
410
|
+
const printRecovery = (lines: readonly string[]) => { for (const line of lines) console.log(line); };
|
|
411
|
+
function recoveryFormatting(entry: string, presentation: RecoveryPresentationOptions): string[] {
|
|
412
|
+
// ponytail: old retained coordinators have no presentation parser; capability metadata can replace this display-only module-presence check.
|
|
413
|
+
return existsSync(join(dirname(entry), "upgrade-presentation-flags.ts"))
|
|
414
|
+
? [`--color=${presentation.color ? "always" : "never"}`, ...(presentation.verbose ? ["--verbose"] : [])] : [];
|
|
415
|
+
}
|
|
416
|
+
function printRecoveryStatus(op: RecoveryOperation, message: string, presentation: RecoveryPresentationOptions): void {
|
|
417
|
+
printRecovery(wrap(message, presentation.columns));
|
|
418
|
+
printRecovery(wrap(`ahub recovery status ${op.id}`, presentation.columns));
|
|
419
|
+
if (presentation.verbose) {
|
|
420
|
+
printRecovery(wrap("Retained bun coordinator (main.js), wrapped command:", presentation.columns));
|
|
421
|
+
printRecovery(wrap(recoveryCommand(op, "status"), presentation.columns));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
async function recoveryEndInspection(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>> {
|
|
425
|
+
const driver = makeRecoveryDriver(undefined, delegatedPeer);
|
|
426
|
+
return Object.fromEntries(await Promise.all(op.plan.projects.map(async planned => [planned.project.id, await driver.inspect(planned.project).catch(() => undefined)] as const)));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function spawnRecovery(operation: RecoveryOperation, quiet = false, presentation?: RecoveryPresentationOptions): void {
|
|
402
430
|
const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), ...liveTestArgs, "recovery-run", operation.id], {
|
|
403
431
|
cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
|
|
404
432
|
});
|
|
405
433
|
child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
|
|
406
434
|
child.unref();
|
|
407
435
|
// #215: the operation's own coordinator, which has every recovery command; the global ahub may be older mid-upgrade.
|
|
408
|
-
if (!quiet)
|
|
436
|
+
if (!quiet) {
|
|
437
|
+
if (presentation) {
|
|
438
|
+
printRecovery(wrap(`Recovery operation ${operation.id} scheduled.`, presentation.columns));
|
|
439
|
+
printRecovery(wrap(`ahub recovery status ${operation.id}`, presentation.columns));
|
|
440
|
+
if (presentation.verbose) printRecovery(wrap(recoveryCommand(operation, "status"), presentation.columns));
|
|
441
|
+
} else console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
|
|
442
|
+
}
|
|
409
443
|
}
|
|
410
444
|
|
|
411
445
|
/** #272: a terminal session for the interactive screens. One line reader for the whole session, in the terminal's own
|
|
412
446
|
* line mode, so Ctrl+C stays a signal: from then on every question answers null and the screens leave. The terminal
|
|
413
447
|
* 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
|
|
414
448
|
* runner is ever stopped by it. */
|
|
415
|
-
async function onTerminal(screens: (io: ScreenIO) => Promise<void
|
|
449
|
+
async function onTerminal(screens: (io: ScreenIO) => Promise<void>, presentation?: RecoveryPresentationOptions): Promise<void> {
|
|
416
450
|
const rl = lineReader({ input: process.stdin, terminal: false });
|
|
417
451
|
const lines: string[] = [], waiters: ((line: string | null) => void)[] = [];
|
|
418
452
|
let ended = false, interrupted = false;
|
|
@@ -422,6 +456,7 @@ async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<voi
|
|
|
422
456
|
process.on("SIGINT", onInterrupt);
|
|
423
457
|
try {
|
|
424
458
|
await screens({
|
|
459
|
+
...(presentation ? { presentation: { ...presentation, get columns() { return process.stdout.columns ?? presentation.columns; } } } : {}),
|
|
425
460
|
ask: (question) => {
|
|
426
461
|
if (interrupted) return Promise.resolve(null);
|
|
427
462
|
process.stdout.write(question);
|
|
@@ -433,7 +468,13 @@ async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<voi
|
|
|
433
468
|
// The command owns the terminal while it runs; a reader whose input already ended has nothing to pause.
|
|
434
469
|
if (!ended) rl.pause();
|
|
435
470
|
const done = (code: number) => { if (!ended) rl.resume(); resolve(code); };
|
|
436
|
-
const
|
|
471
|
+
const formatting = presentation && argv[1] === "recovery" ? recoveryFormatting(argv[0]!, presentation) : [];
|
|
472
|
+
// A capable screen child updates the receipt; its parent owns the human view after the action.
|
|
473
|
+
// Older coordinators keep their existing terminal contract and receive no unsupported JSON flag.
|
|
474
|
+
const quiet = formatting.length > 0;
|
|
475
|
+
const child = spawn(process.execPath, [argv[0]!, ...liveTestArgs, ...argv.slice(1), ...formatting, ...(quiet ? ["--json"] : [])], {
|
|
476
|
+
cwd, stdio: quiet ? ["inherit", "ignore", "inherit"] : "inherit",
|
|
477
|
+
});
|
|
437
478
|
child.on("error", () => done(1));
|
|
438
479
|
child.on("close", (code) => done(code ?? 1));
|
|
439
480
|
}),
|
|
@@ -463,6 +504,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
|
|
|
463
504
|
read: (id) => readOperation<RecoveryOperation>(id),
|
|
464
505
|
runner: recoveryRunner,
|
|
465
506
|
live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer).inspect),
|
|
507
|
+
endInspection: recoveryEndInspection,
|
|
466
508
|
endPeer: (planned, peer) => endPlannedPeer(planned, peer),
|
|
467
509
|
runnerStoppable: (id) => signedRunner(id) !== undefined,
|
|
468
510
|
stopRunner: (id) => stopSignedRunner(id),
|
|
@@ -472,12 +514,13 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
|
|
|
472
514
|
}
|
|
473
515
|
|
|
474
516
|
async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
475
|
-
const
|
|
517
|
+
const display = recoveryDisplay();
|
|
518
|
+
const { one, rest } = takeFlags(display.args, ["--to"], []);
|
|
476
519
|
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]");
|
|
477
520
|
if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
|
|
478
|
-
const
|
|
521
|
+
const yes = rest.includes("--yes"), dry = rest.includes("--dry-run") || (display.json && !yes);
|
|
479
522
|
// #272: on a terminal, with neither flag, the plan is reviewed, applied and followed in one session.
|
|
480
|
-
const interactive = !dry && !yes &&
|
|
523
|
+
const interactive = !dry && !yes && humanRecovery(display.json);
|
|
481
524
|
let to = one["--to"];
|
|
482
525
|
if (kind === "upgrade" && !to) {
|
|
483
526
|
if (yes || (!dry && !interactive)) fail("upgrade requires --to <exact-version>");
|
|
@@ -488,7 +531,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
|
488
531
|
if (to !== undefined) { try { exactVersion(to); } catch (error) { fail((error as Error).message); } }
|
|
489
532
|
// An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
|
|
490
533
|
if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
|
|
491
|
-
const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", ...liveTestArgs, "upgrade", "--to", to!, ...rest];
|
|
534
|
+
const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", ...liveTestArgs, "upgrade", "--to", to!, ...rest, ...display.flags, ...(dry && !rest.includes("--dry-run") ? ["--dry-run"] : [])];
|
|
492
535
|
console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
|
|
493
536
|
if (interactive) {
|
|
494
537
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -505,16 +548,17 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
|
505
548
|
process.exit(code);
|
|
506
549
|
}
|
|
507
550
|
if (kind === "upgrade") console.error("ahub: Kimi now defaults to its own TUI (pull-only) in projects without peers.kimi.start_mode. To keep ACP: ahub settings set peers.kimi.start_mode headless");
|
|
508
|
-
if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io));
|
|
551
|
+
if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io), display.options);
|
|
509
552
|
const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
|
|
510
|
-
|
|
553
|
+
const jsonApply = display.json && yes && !dry;
|
|
554
|
+
if (!jsonApply) console.log(JSON.stringify(plan, null, 2));
|
|
511
555
|
// #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
|
|
512
556
|
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`);
|
|
513
557
|
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`);
|
|
514
558
|
const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
|
|
515
559
|
for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
|
|
516
560
|
if (dry) return;
|
|
517
|
-
if (blockers.length) fail("plan has blockers; no runtime was changed");
|
|
561
|
+
if (blockers.length) { if (jsonApply) console.log(JSON.stringify(plan, null, 2)); fail("plan has blockers; no runtime was changed"); }
|
|
518
562
|
assertLifecycleAvailable();
|
|
519
563
|
if (!yes) {
|
|
520
564
|
if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
|
|
@@ -527,50 +571,128 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
|
527
571
|
if (current.fingerprint !== plan.fingerprint) fail("plan changed during review; run the command again");
|
|
528
572
|
const sourceRoot = preserveSource(plan);
|
|
529
573
|
const operation = createOperation(plan, sourceRoot);
|
|
530
|
-
spawnRecovery(operation);
|
|
574
|
+
if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
|
|
575
|
+
else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Strict person command: recorded identity only, no session or terminal overrides. */
|
|
579
|
+
function parseRecoveryReattach(argv: readonly string[]): { id: string; peer: TerminalPeer } | undefined {
|
|
580
|
+
if (argv.length !== 3 || argv[0] !== "reattach" || !operationIdPattern.test(argv[1]!) || !["codex", "claude", "pi"].includes(argv[2]!)) return undefined;
|
|
581
|
+
return { id: argv[1]!, peer: argv[2] as TerminalPeer };
|
|
531
582
|
}
|
|
532
583
|
|
|
533
584
|
const commands: Record<string, () => Promise<void> | void> = {
|
|
534
585
|
upgrade: () => upgrade("upgrade"),
|
|
535
586
|
restart: () => upgrade("restart"),
|
|
536
587
|
recovery: async () => {
|
|
537
|
-
const
|
|
588
|
+
const display = recoveryDisplay();
|
|
589
|
+
const [action, given, ...more] = display.args;
|
|
538
590
|
const usage = "usage: ahub recovery [status|resume|abort] [<operation-id>] | ahub recovery dispose [<operation-id>] --fresh-session <peer>|--stop-and-archive --reason <text>";
|
|
591
|
+
if (action === "reattach") {
|
|
592
|
+
const selected = parseRecoveryReattach(display.args);
|
|
593
|
+
if (!selected) fail("usage: ahub recovery reattach <operation-id> codex|claude|pi");
|
|
594
|
+
let operation: RecoveryOperation;
|
|
595
|
+
try { operation = readOperation<RecoveryOperation>(selected.id); }
|
|
596
|
+
catch { fail(`recovery receipt ${selected.id} cannot be read; inspect the retained operation with ahub recovery status ${selected.id}; no session was started`); }
|
|
597
|
+
if (typeof operation.sourceRoot !== "string" || !operation.sourceRoot.trim()) fail(`recovery receipt ${selected.id} has no retained coordinator path; inspect it with ahub recovery status ${selected.id}; no session was started`);
|
|
598
|
+
const coordinator = join(operation.sourceRoot, "src/cli/main.js");
|
|
599
|
+
if (!existsSync(coordinator)) fail(`the retained coordinator for ${selected.id} is unavailable; restore its retained package and inspect ahub recovery status ${selected.id} before retrying; no session was started`);
|
|
600
|
+
let coordinatorIsCurrent: boolean;
|
|
601
|
+
try { coordinatorIsCurrent = realPath(coordinator) === realPath(join(import.meta.dir, "main.js")); }
|
|
602
|
+
catch { fail(`the retained coordinator for ${selected.id} cannot be resolved; inspect ahub recovery status ${selected.id} before retrying; no session was started`); }
|
|
603
|
+
if (supportsAction(operation.sourceRoot, "reattach") && !coordinatorIsCurrent) {
|
|
604
|
+
if (humanRecovery(display.json)) {
|
|
605
|
+
for (const line of wrap("ahub: using the operation's retained coordinator for reattachment", display.options.columns)) console.error(line);
|
|
606
|
+
if (display.options.verbose) for (const line of wrap(recoveryCommand(operation, "reattach", selected.peer), display.options.columns)) console.error(line);
|
|
607
|
+
} else console.error(`ahub: using the operation's coordinator: ${recoveryCommand(operation, "reattach", selected.peer)}`);
|
|
608
|
+
const formatting = recoveryFormatting(coordinator, display.options);
|
|
609
|
+
const code = await new Promise<number>(resolve => {
|
|
610
|
+
const child = spawn(process.execPath, [coordinator, "recovery", "reattach", selected.id, selected.peer, ...formatting], { cwd, stdio: display.json ? ["inherit", "ignore", "inherit"] : "inherit", env: process.env });
|
|
611
|
+
child.on("error", () => { console.error(`ahub: coordinator could not start; ${recoveryCommand(operation, "status")}`); resolve(1); });
|
|
612
|
+
child.on("close", status => resolve(status ?? 1));
|
|
613
|
+
});
|
|
614
|
+
process.exitCode = code;
|
|
615
|
+
if (display.json && code === 0) console.log(JSON.stringify(publicOperation(readOperation<RecoveryOperation>(selected.id)), null, 2));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const result = await reattachRecovery(selected.id, selected.peer, makeRecoveryDriver());
|
|
619
|
+
if (humanRecovery(display.json)) {
|
|
620
|
+
printRecovery(renderOperation(result, undefined, display.options));
|
|
621
|
+
const live = await liveProjects(result, makeRecoveryDriver(undefined, delegatedPeer).inspect);
|
|
622
|
+
printRecovery(renderBlocked(result, nextChoices(result, undefined, live), display.options));
|
|
623
|
+
} else {
|
|
624
|
+
console.log(JSON.stringify(publicOperation(result), null, 2));
|
|
625
|
+
if (!display.json) console.log(nextActionsText(result));
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
539
629
|
// #272: without an id, the operation that holds the machine's lock; bare on a terminal, its screen.
|
|
540
630
|
const named = !!given && operationIdPattern.test(given);
|
|
541
631
|
const id = named ? given : recoveryLock();
|
|
542
632
|
const rest = named || given === undefined ? more : [given, ...more];
|
|
543
|
-
if (action === undefined &&
|
|
633
|
+
if (action === undefined && humanRecovery(display.json)) {
|
|
544
634
|
if (!id) return console.log("no recovery operation is open");
|
|
545
|
-
return onTerminal(async (io) => { await operationScreen(upgradeHost("restart", VERSION), io, id); });
|
|
635
|
+
return onTerminal(async (io) => { await operationScreen(upgradeHost("restart", VERSION), io, id); }, display.options);
|
|
546
636
|
}
|
|
547
637
|
if (!["status", "resume", "abort", "dispose"].includes(action ?? "") || (action !== "dispose" && rest.length)) fail(usage);
|
|
548
638
|
if (!id) fail("no recovery operation holds the lock; name one by its id");
|
|
549
|
-
const
|
|
639
|
+
const delegatedFresh = delegatedPeer && action === "dispose" ? takeFlags(rest, ["--fresh-session", "--reason"], []).one["--fresh-session"] : undefined;
|
|
640
|
+
let operation: RecoveryOperation;
|
|
641
|
+
try { operation = readOperation<RecoveryOperation>(id); }
|
|
642
|
+
catch (error) {
|
|
643
|
+
if (delegatedFresh !== undefined) { audit("refused"); fail(LIVE_TEST_PERSON_REFUSAL); }
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
646
|
+
if (delegatedFresh !== undefined) {
|
|
647
|
+
const repairedFresh = repairedFreshSession(operation, delegatedFresh);
|
|
648
|
+
if (repairedFresh === undefined || liveTestRefusal("recovery", display.args, { repairedFresh })) {
|
|
649
|
+
audit("refused"); fail(LIVE_TEST_PERSON_REFUSAL);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
550
652
|
const runner = recoveryRunner(id);
|
|
551
653
|
if (action === "status") {
|
|
552
654
|
// #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
|
|
553
655
|
const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect);
|
|
554
656
|
console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
|
|
555
657
|
}
|
|
556
|
-
else if (action === "abort") {
|
|
658
|
+
else if (action === "abort") {
|
|
659
|
+
await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer));
|
|
660
|
+
if (display.json) console.log(JSON.stringify(publicOperation(readOperation<RecoveryOperation>(id)), null, 2));
|
|
661
|
+
else console.log("preflight cancelled; no committed transition was rolled back");
|
|
662
|
+
}
|
|
557
663
|
else if (action === "dispose") {
|
|
558
|
-
// Console authority
|
|
664
|
+
// Console authority; #408 live-test delegation remains for ordinary fresh choices, never repaired loss.
|
|
559
665
|
const { one, rest: flags } = takeFlags(rest, ["--fresh-session", "--reason"], []);
|
|
560
666
|
const stop = flags.length === 1 && flags[0] === "--stop-and-archive";
|
|
561
667
|
const reason = one["--reason"]?.trim() ?? "";
|
|
562
668
|
if (!reason || reason.length > 500 || (flags.length && !stop) || stop === !!one["--fresh-session"]) fail(usage);
|
|
563
669
|
const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver(undefined, delegatedPeer));
|
|
564
|
-
if (!stop) {
|
|
565
|
-
|
|
566
|
-
|
|
670
|
+
if (!stop) {
|
|
671
|
+
if (display.json) { console.log(JSON.stringify(publicOperation(result), null, 2)); return spawnRecovery(result, true); }
|
|
672
|
+
console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`);
|
|
673
|
+
return spawnRecovery(result, false, humanRecovery(false) ? display.options : undefined);
|
|
674
|
+
}
|
|
675
|
+
if (humanRecovery(display.json)) printRecovery(renderSummary(result, await recoveryEndInspection(result), display.options));
|
|
676
|
+
else {
|
|
677
|
+
console.log(JSON.stringify(publicOperation(result), null, 2));
|
|
678
|
+
if (!display.json) for (const line of stopAndArchiveSummary(result)) console.log(line);
|
|
679
|
+
}
|
|
567
680
|
}
|
|
568
|
-
else if (["completed", "cancelled"].includes(operation.phase)) console.log(`recovery is already ${operation.phase}`);
|
|
681
|
+
else if (["completed", "cancelled"].includes(operation.phase)) console.log(display.json ? JSON.stringify(publicOperation(operation, runner), null, 2) : `recovery is already ${operation.phase}`);
|
|
569
682
|
// #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
|
|
570
683
|
else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect))}`);
|
|
571
|
-
else if (runner === "unknown")
|
|
572
|
-
|
|
573
|
-
|
|
684
|
+
else if (runner === "unknown") {
|
|
685
|
+
if (display.json) console.log(JSON.stringify(publicOperation(operation, runner), null, 2));
|
|
686
|
+
else if (humanRecovery(false)) printRecoveryStatus(operation, "Runner ownership is unknown; nothing was started.", display.options);
|
|
687
|
+
else console.log(`whether a runner holds this operation could not be read; nothing was started; ${recoveryCommand(operation, "status")}`);
|
|
688
|
+
}
|
|
689
|
+
else if (runner) {
|
|
690
|
+
if (display.json) console.log(JSON.stringify(publicOperation(operation, runner), null, 2));
|
|
691
|
+
else if (humanRecovery(false)) printRecoveryStatus(operation, `runner ${runner} is still working on this operation`, display.options);
|
|
692
|
+
else console.log(`runner ${runner} is still working on this operation; ${recoveryCommand(operation, "status")}`);
|
|
693
|
+
}
|
|
694
|
+
else if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
|
|
695
|
+
else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
|
|
574
696
|
},
|
|
575
697
|
"recovery-run": async () => {
|
|
576
698
|
if (args.length !== 1 || process.env.AGENTHUB_RECOVERY_OPERATION !== args[0]) fail("recovery-run is an internal command");
|
|
@@ -803,8 +925,9 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
803
925
|
}
|
|
804
926
|
assertLifecycleAvailable();
|
|
805
927
|
const { one, rest } = takeFlags(args, ["--mode", "--model", "--session-id"], []);
|
|
806
|
-
|
|
807
|
-
if (
|
|
928
|
+
const adapter = definitionOf("qwen")!;
|
|
929
|
+
if (one["--session-id"] !== undefined && !adapter.session.crashIdentity?.({ sessionId: one["--session-id"] }).sessionId) fail("invalid Qwen session identity");
|
|
930
|
+
if (rest.length || (one["--mode"] !== undefined && !(adapter.start.forms as readonly string[]).includes(one["--mode"]))) fail("Qwen TUI is not offered before verification; usage: ahub qwen [--mode headless] [--model <alias>] [--session-id <id>]");
|
|
808
931
|
const hub = await connect();
|
|
809
932
|
try {
|
|
810
933
|
const reply = await hub.request({ t: "start", peer: "qwen", args: { mode: "headless", ...(one["--model"] ? { model: one["--model"] } : {}), ...(one["--session-id"] ? { sessionId: one["--session-id"] } : {}) }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 35_000);
|
|
@@ -1002,7 +1125,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1002
1125
|
|
|
1003
1126
|
watch: async () => {
|
|
1004
1127
|
const peer = args[0];
|
|
1005
|
-
if (peer !== "
|
|
1128
|
+
if (typeof peer !== "string" || !definitionOf(peer)?.capabilities?.watch) fail("usage: ahub watch kimi|qwen [--no-thoughts] [--color=auto|always|never]");
|
|
1006
1129
|
if (args.slice(1).some(arg => arg !== "--no-thoughts" && !/^--color=(auto|always|never)$/.test(arg))) fail("unsupported watch option");
|
|
1007
1130
|
const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice(8), { isTTY: !!process.stdout.isTTY, ...process.env });
|
|
1008
1131
|
if (color instanceof Error) fail(color.message);
|
|
@@ -1189,6 +1312,12 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1189
1312
|
finally { hub.close(); }
|
|
1190
1313
|
return;
|
|
1191
1314
|
}
|
|
1315
|
+
if (sub === "cancel") {
|
|
1316
|
+
const { one, rest: positional } = takeFlags(rest, ["--reason", "--superseded-by", "--revision"], []);
|
|
1317
|
+
const revision = one["--revision"] === undefined ? undefined : Number(one["--revision"]);
|
|
1318
|
+
if (positional.length !== 1 || !/^[1-9]\d*$/.test(positional[0]!) || !one["--reason"]?.trim() || (revision !== undefined && (!/^\d+$/.test(one["--revision"]!) || !Number.isSafeInteger(revision)))) fail("usage: ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]");
|
|
1319
|
+
return console.log(await taskOp("hub_task_cancel", { id: Number(positional[0]), reason: one["--reason"], ...(one["--superseded-by"] === undefined ? {} : { superseded_by: one["--superseded-by"] }), ...(revision === undefined ? {} : { revision }) }));
|
|
1320
|
+
}
|
|
1192
1321
|
if (sub === "show") return console.log(await taskOp("task_show", { id: rest[0] }));
|
|
1193
1322
|
if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
|
|
1194
1323
|
if (sub === "assign") {
|
|
@@ -1214,7 +1343,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1214
1343
|
appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
|
|
1215
1344
|
return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
|
|
1216
1345
|
}
|
|
1217
|
-
if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | escalate <id>");
|
|
1346
|
+
if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | cancel <id> --reason <text> | escalate <id>");
|
|
1218
1347
|
// `--class` is the explicit form. A first word that is a class name is still taken as the class (the documented
|
|
1219
1348
|
// short form), but said out loud: "review the auth module" would otherwise be filed as class review, silently.
|
|
1220
1349
|
const urgent = rest.includes("--urgent"), backlog = rest.includes("--backlog");
|
|
@@ -1324,11 +1453,11 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1324
1453
|
} finally { hub.close(); }
|
|
1325
1454
|
},
|
|
1326
1455
|
settings: async () => {
|
|
1327
|
-
const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo";
|
|
1456
|
+
const usage = "usage: ahub settings [list] [--json] | get <key> [--json] | set <key> <value|inherit> [--yes] [--preview] | undo | reload";
|
|
1328
1457
|
const flags = args.filter((arg) => arg.startsWith("--")), [sub = "list", key, text, ...extra] = args.filter((arg) => !arg.startsWith("--"));
|
|
1329
|
-
const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [] };
|
|
1458
|
+
const allowed: Record<string, string[]> = { list: ["--json"], get: ["--json"], set: ["--yes", "--preview", "--now"], undo: [], reload: [] };
|
|
1330
1459
|
if (!Object.hasOwn(allowed, sub) || flags.some((flag) => !allowed[sub]!.includes(flag)) || extra.length) fail(usage);
|
|
1331
|
-
if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo") && key !== undefined)) fail(usage);
|
|
1460
|
+
if ((sub === "get" && (!key || text !== undefined)) || (sub === "set" && (!key || text === undefined)) || ((sub === "list" || sub === "undo" || sub === "reload") && key !== undefined)) fail(usage);
|
|
1332
1461
|
const def = key === undefined ? undefined : settingDef(key);
|
|
1333
1462
|
if (typeof def === "string") fail(`${key}: ${def}`);
|
|
1334
1463
|
if (flags.includes("--now") && (typeof def !== "object" || def.group !== "Roster")) fail(usage);
|
|
@@ -1348,6 +1477,43 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1348
1477
|
}
|
|
1349
1478
|
return reply;
|
|
1350
1479
|
};
|
|
1480
|
+
if (sub === "reload") {
|
|
1481
|
+
let result = await request({ t: "settings_reload" });
|
|
1482
|
+
const confirmation = result.confirmation;
|
|
1483
|
+
if (Array.isArray(result.rejected)) for (const row of result.rejected.slice(0, 100)) console.log(terminalText(`${String(row.key).slice(0, 160)}: ${String(row.reason).slice(0, 1000)}`));
|
|
1484
|
+
if (confirmation !== undefined) {
|
|
1485
|
+
if (!confirmation || typeof confirmation.token !== "string" || !confirmation.token || confirmation.token.length > 256 || !Array.isArray(confirmation.keys) || !confirmation.keys.length || confirmation.keys.length > 200 || confirmation.keys.some((key: unknown) => typeof key !== "string" || key.length > 160)) fail("invalid reload confirmation; guarded settings were not applied");
|
|
1486
|
+
const fullValue = (value: unknown): boolean => {
|
|
1487
|
+
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 16_384) return false;
|
|
1488
|
+
if (value === "(unset)") return true;
|
|
1489
|
+
try { JSON.parse(value); return true; } catch { return false; }
|
|
1490
|
+
};
|
|
1491
|
+
if (Buffer.byteLength(JSON.stringify(result), "utf8") > 131_072 || new Set(confirmation.keys).size !== confirmation.keys.length || !Array.isArray(confirmation.changes) || confirmation.changes.length !== confirmation.keys.length || confirmation.changes.some((change: unknown, index: number) => {
|
|
1492
|
+
if (!change || typeof change !== "object" || Array.isArray(change)) return true;
|
|
1493
|
+
const row = change as Record<string, unknown>;
|
|
1494
|
+
return row.key !== confirmation.keys[index] || !fullValue(row.before) || !fullValue(row.after) || (row.effect !== undefined && (typeof row.effect !== "string" || Buffer.byteLength(row.effect, "utf8") > 2048));
|
|
1495
|
+
})) fail("reload preview is incomplete or unavailable; upgrade the running hub and run ahub settings reload again to review every full value; guarded settings were not applied");
|
|
1496
|
+
const token = confirmation.token;
|
|
1497
|
+
console.log("Guarded settings awaiting confirmation:");
|
|
1498
|
+
for (const change of confirmation.changes) {
|
|
1499
|
+
console.log(` ${terminalText(change.key)}: ${terminalText(change.before)} -> ${terminalText(change.after)}`);
|
|
1500
|
+
if (change.effect !== undefined) console.log(` ${terminalText(change.effect)}`);
|
|
1501
|
+
}
|
|
1502
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) fail("guarded settings need an interactive person's terminal; run ahub settings reload there; guarded settings were not applied");
|
|
1503
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
1504
|
+
const ended = new AbortController();
|
|
1505
|
+
rl.once("close", () => ended.abort());
|
|
1506
|
+
let accepted = false;
|
|
1507
|
+
try { accepted = (await rl.question("Apply guarded settings? [y/N] ", { signal: AbortSignal.any([AbortSignal.timeout(30_000), ended.signal]) })).trim().toLowerCase() === "y"; }
|
|
1508
|
+
catch { /* EOF, cancellation and a missed confirmation deadline grant nothing. */ }
|
|
1509
|
+
finally { rl.close(); }
|
|
1510
|
+
if (!accepted) return console.log("guarded settings were not applied");
|
|
1511
|
+
result = await request({ t: "settings_reload", confirm: token });
|
|
1512
|
+
if (result.confirmation !== undefined) fail("reload candidate changed; run ahub settings reload again to review it; guarded settings were not applied");
|
|
1513
|
+
if (Array.isArray(result.rejected)) for (const row of result.rejected.slice(0, 100)) console.log(terminalText(`${String(row.key).slice(0, 160)}: ${String(row.reason).slice(0, 1000)}`));
|
|
1514
|
+
}
|
|
1515
|
+
return console.log(paint([{ text: result.text }], false));
|
|
1516
|
+
}
|
|
1351
1517
|
if (sub === "undo") {
|
|
1352
1518
|
const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo);
|
|
1353
1519
|
const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
|
|
@@ -1375,15 +1541,16 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1375
1541
|
}
|
|
1376
1542
|
const view = await request({ t: "settings_get" });
|
|
1377
1543
|
const rows = (view.rows as SettingRow[]).filter((row) => sub === "list" || row.key === key);
|
|
1378
|
-
if (flags.includes("--json")) return console.log(JSON.stringify(sub === "get" ? rows[0] : { rows, ...(view.undo ? { undo: view.undo } : {}) }, null, 2));
|
|
1544
|
+
if (flags.includes("--json")) return console.log(JSON.stringify(sub === "get" ? rows[0] : { rows, pendingRestart: view.pendingRestart ?? [], ...(view.undo ? { undo: view.undo } : {}) }, null, 2));
|
|
1379
1545
|
const width = Math.max(...rows.map((row) => row.key.length)), valueWidth = Math.max(...rows.map((row) => settingText(row.value).length));
|
|
1380
1546
|
let group = "";
|
|
1381
1547
|
for (const row of rows) {
|
|
1382
1548
|
if (sub === "list" && row.group !== group) console.log(`${group ? "\n" : ""}${group = row.group}`);
|
|
1383
|
-
const facts = [`from ${row.source}`, row.applies === "live" ? "applies at once" : row.applies === "
|
|
1549
|
+
const facts = [`from ${row.source}`, `file: ${settingText(row.fileValue === undefined ? row.pending ?? row.value : row.fileValue)}`, row.applies === "live" ? "applies at once" : row.applies === "next use" ? "applies at next use" : "pending restart", ...(row.restartReason ? [row.restartReason] : []), ...(row.note ? [row.note] : [])];
|
|
1384
1550
|
console.log(` ${row.key.padEnd(width)} ${settingText(row.value).padEnd(valueWidth)} ${facts.join("; ")}`);
|
|
1385
|
-
if (sub === "get") console.log(` ${row.label}; written to ${row.file}; takes ${row.type === "boolean" ? "true, false" : row.type === "peers" ? "a comma list of peer ids" : row.type === "roles" ? "up to two comma-separated catalog ids" : (row.values ?? []).join(", ")}${row.file === "this hub run" ? "" : ", or inherit to remove the machine-local value"}${row.risk === "raises" ? "; a dashboard needs a settings session (ahub ui --settings) to raise it" : ""}`);
|
|
1551
|
+
if (sub === "get") console.log(` ${row.label}; written to ${row.file}; takes ${row.type === "boolean" ? "true, false" : row.type === "integer" || row.type === "number" ? `a number from ${row.min ?? 0} to ${row.max ?? "the configured bound"}` : row.type === "peers" ? "a comma list of peer ids" : row.type === "roles" ? "up to two comma-separated catalog ids" : (row.values ?? []).join(", ")}${row.file === "this hub run" ? "" : ", or inherit to remove the machine-local value"}${row.risk === "raises" ? "; a dashboard needs a settings session (ahub ui --settings) to raise it" : ""}`);
|
|
1386
1552
|
}
|
|
1553
|
+
if (sub === "list" && view.pendingRestart?.length) console.log(`\npending restart: ${view.pendingRestart.map((entry: { key: string }) => entry.key).join(", ")}`);
|
|
1387
1554
|
if (sub === "list" && view.undo) console.log(`\nahub settings undo puts back the last write (${view.undo})`);
|
|
1388
1555
|
} finally { hub.close(); }
|
|
1389
1556
|
},
|
|
@@ -1835,6 +2002,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1835
2002
|
row(verifiedJcodeCommand(config.jcode_cmd, cwd), "jcode", "CLI/source identity only; provider, project MCP and native teardown require separate receipts; native policy unmanaged");
|
|
1836
2003
|
}
|
|
1837
2004
|
if (config.roster.qwen === true) {
|
|
2005
|
+
// ponytail: keep this bounded CLI-version probe and 0.25.0 pin in the A1 guard baseline; A2 moves every version probe together when VersionContext gains its observed input.
|
|
1838
2006
|
const v = version(config.qwen_cmd[0]!);
|
|
1839
2007
|
row(v === "0.25.0", "qwen", v === "0.25.0" ? "CLI reports 0.25.0; native ACP identity/modes and provider access require a separate live check" : "Qwen Code 0.25.0 executable not confirmed; provider and ACP readiness unknown");
|
|
1840
2008
|
} // issue #83
|
package/src/cli/output.ts
CHANGED
|
@@ -7,9 +7,13 @@ import { formatReport, formatTaskReport, type Report, type TaskReport } from "..
|
|
|
7
7
|
|
|
8
8
|
export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
|
|
9
9
|
export interface StatusOutput {
|
|
10
|
+
pendingRestart?: readonly { key: string; reason: string }[];
|
|
11
|
+
taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
|
|
12
|
+
pendingTaskOffers?: readonly { task: number; peer: string }[];
|
|
13
|
+
isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
|
|
10
14
|
memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
|
|
11
15
|
taskWip?: Record<string, { wip: number; wipLimit: number }>;
|
|
12
|
-
pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>;
|
|
16
|
+
pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
|
|
13
17
|
tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[]; switchyard?: string;
|
|
14
18
|
}
|
|
15
19
|
export interface BudgetOutput { budget: Record<string, any>; gate?: number }
|
|
@@ -185,14 +189,21 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
185
189
|
if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
|
|
186
190
|
const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
|
|
187
191
|
if (data.cwd) out.push(...lines(data.cwd, columns));
|
|
192
|
+
for (const pending of data.pendingRestart ?? []) out.push(...detail("pending restart", `${pending.key}: ${pending.reason}`, columns, "attention"));
|
|
193
|
+
for (const stall of data.taskStalls ?? []) out.push(...detail(`task #${stall.task} stalled`, `${stall.cause.kind}${stall.cause.peer ? ` ${stall.cause.peer}` : ""}${stall.cause.dependency ? ` #${stall.cause.dependency}` : ""}: ${stall.cause.code}; configure the peer, ahub task assign ${stall.task} <peer>, or ahub task cancel ${stall.task} --reason <text>`, columns, "attention"));
|
|
194
|
+
for (const offer of data.pendingTaskOffers ?? []) out.push(...detail("task offer", `open ${offer.peer}: task #${offer.task} is waiting for it`, columns, "attention"));
|
|
188
195
|
if (data.memoryProfile) {
|
|
189
196
|
const memory = data.memoryProfile;
|
|
190
197
|
const profile = memory.profile === "generation" ? `generation ${memory.generation?.slice(0, 8) ?? "unknown"}` : memory.profile ?? "unbound";
|
|
191
198
|
out.push(...detail("memory", `${profile}; ${memory.state ?? "unknown"}; agent-mem ${memory.agentMemVersion ?? "unknown"}${memory.skippedRows ? `; malformed legacy rows skipped: ${memory.skippedRows}` : ""}${memory.error ? `; ${memory.error}` : ""}`, columns, memory.state === "unavailable" ? "attention" : undefined));
|
|
192
199
|
}
|
|
200
|
+
if (data.planner) out.push(...detail("planner", `${data.planner.peers?.join(", ") || "nobody"} (${data.planner.source ?? "unknown"})${data.planner.source === "automatic" || data.planner.source === "none" ? "; pin it with ahub settings set planner <peer>" : ""}`, columns, data.planner.source === "none" ? "attention" : undefined));
|
|
193
201
|
if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
|
|
194
202
|
for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
|
|
195
203
|
for (const crash of data.crash ?? []) out.push(...detail("crash recovery", crash, columns, "attention"));
|
|
204
|
+
for (const warning of data.isolatedChecks?.orphans ?? []) out.push(...detail("isolated check warning", warning, columns, "attention"));
|
|
205
|
+
for (const id of data.isolatedChecks?.unknownReceipts ?? []) out.push(...detail("check receipt", `${id}: unknown; receipt storage unavailable`, columns, "attention"));
|
|
206
|
+
if (data.isolatedChecks?.maintenanceUnavailable) out.push(...detail("check maintenance", "unavailable; saved run outcomes are unchanged", columns, "attention"));
|
|
196
207
|
const s = initialConsoleState(); s.panel = 1; s.peers = data.peers; s.budget = data.budget ?? {};
|
|
197
208
|
const head = Object.values(data.peers).some(p => p.permissionMode && p.permissionMode !== "ask") ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : [...TABLES[1]!];
|
|
198
209
|
head.push("CONTEXT");
|
|
@@ -206,7 +217,9 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
206
217
|
if (p.flowRoles) out.push(...detail(`${id} flow roles`, p.flowRoles.join(", ") || "none", columns));
|
|
207
218
|
if (p.catalogRoles) { out.push(...detail(`${id} catalog roles`, Array.isArray(p.catalogRoles.value) ? p.catalogRoles.value.join(", ") || "none" : "unconfirmed", columns)); if (p.catalogRoles.pending !== undefined) out.push(...detail(`${id} pending roles`, p.catalogRoles.pending.join(", ") || "none", columns, "attention")); }
|
|
208
219
|
if (p.liveAccepted?.length) { settling = true; out.push(...detail("settling", p.liveAccepted.map((v: string) => shortId(v, columns, full)).join(", "), columns, "attention")); }
|
|
209
|
-
if (p.
|
|
220
|
+
if (p.heldRows?.length) {
|
|
221
|
+
for (const row of p.heldRows) out.push(...detail("held", `${id}: needs_review ${shortId(row.id, columns, full)}; from ${row.senders.join(",")}; kind ${row.kinds.join(",")}; age ${duration(Math.max(0, now - row.createdAt))}${row.context ? `; ${row.context}` : ""}; ahub queue show ${row.id}`, columns, "failure"));
|
|
222
|
+
} else if (p.heldBy) {
|
|
210
223
|
const note = flat(p.holdNote ?? "");
|
|
211
224
|
const command = `ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`;
|
|
212
225
|
const normalized = note.startsWith(`held by needs_review ${p.heldBy}`)
|
package/src/cli/preview.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { definitionOf, kindOf } from "../adapters/registry.ts";
|
|
1
2
|
import { jcodeCommand } from "../adapters/jcode.ts";
|
|
2
3
|
import { qwenSessionId } from "../hub/crash.ts";
|
|
3
4
|
import { readFileSync } from "node:fs";
|
|
@@ -7,7 +8,7 @@ import { childEnv } from "../hub/child-process.ts";
|
|
|
7
8
|
import { relayModelIds } from "../models/relay.ts";
|
|
8
9
|
import { buildPiLaunch } from "../pi/launch.ts";
|
|
9
10
|
import { startModeOf } from "../hub/start-mode.ts";
|
|
10
|
-
import { buildLaunch, buildKimiLaunch,
|
|
11
|
+
import { buildLaunch, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks, type Launch } from "./launch.ts";
|
|
11
12
|
import { kimiMcpPlan } from "./kimi-mcp.ts";
|
|
12
13
|
|
|
13
14
|
const REDACTED = "[redacted]";
|
|
@@ -71,14 +72,16 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
|
|
|
71
72
|
launch = { cmd: cmd[0]!, args: cmd.slice(1) };
|
|
72
73
|
unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
|
|
73
74
|
unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
|
|
74
|
-
} else if (tool === "qwen") {
|
|
75
|
+
} else if (kindOf(tool) === "qwen") {
|
|
75
76
|
const mode = value("--mode") ?? "headless", model = value("--model");
|
|
76
|
-
|
|
77
|
+
const adapter = definitionOf(tool)!;
|
|
78
|
+
if (!(adapter.start.forms as readonly string[]).includes(mode)) throw new Error("Qwen TUI is not offered before verification");
|
|
77
79
|
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Qwen option");
|
|
78
80
|
const sessionId = value("--session-id");
|
|
79
|
-
if (sessionId !== undefined && !
|
|
81
|
+
if (sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId }).sessionId) throw new Error("invalid Qwen session identity");
|
|
80
82
|
if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
|
|
81
|
-
launch =
|
|
83
|
+
launch = adapter.start.launch({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(import.meta.dir, "main.js"),
|
|
84
|
+
userArgs: [], unattended: false, env: {}, preview: true, nativeCommand: nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model: model ? REDACTED : undefined });
|
|
82
85
|
unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
|
|
83
86
|
} else if (tool === "kimi") {
|
|
84
87
|
const model = value("--model");
|