@staix/agent-hub 0.12.22 → 0.12.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/cli/pet.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { recoveryLock, recoveryRunner } from "../hub/recovery-store.ts";
|
|
4
|
+
import { processLiveness } from "../pi/process-signature.ts";
|
|
5
|
+
import { characterAttentionLine } from "./attention.ts";
|
|
6
|
+
import type { CharacterId } from "../pet/frame.ts";
|
|
7
|
+
import type { NeedItem, NeedsSummary } from "../ui/needs.ts";
|
|
8
|
+
|
|
9
|
+
const id = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
|
|
10
|
+
const count = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
11
|
+
|
|
12
|
+
/** Parsed status metadata is copied by allowlist; source text and extra fields are never rendered. */
|
|
13
|
+
export function parseNeedsSummary(raw: unknown): NeedsSummary | undefined {
|
|
14
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
15
|
+
const row = raw as Record<string, unknown>;
|
|
16
|
+
if (!count(row.act) || !count(row.watch) || typeof row.state !== "string" || !["waiting", "blocked", "review", "working", "idle", "sleeping"].includes(row.state)) return;
|
|
17
|
+
let first: NeedItem | undefined;
|
|
18
|
+
if (row.first !== undefined) {
|
|
19
|
+
if (!row.first || typeof row.first !== "object" || Array.isArray(row.first)) return;
|
|
20
|
+
const item = row.first as Record<string, unknown>;
|
|
21
|
+
if (typeof item.kind !== "string" || typeof item.level !== "string" || !["approval", "default", "storage", "recovery", "delivery", "review", "budget", "offline"].includes(item.kind) || !["act", "watch"].includes(item.level)) return;
|
|
22
|
+
if (item.peer !== undefined && !id(item.peer)) return;
|
|
23
|
+
if (item.id !== undefined && !id(item.id) && !(count(item.id) && item.id > 0)) return;
|
|
24
|
+
if (item.until !== undefined && !count(item.until) || item.since !== undefined && !count(item.since)) return;
|
|
25
|
+
first = { kind: item.kind as NeedItem["kind"], level: item.level as NeedItem["level"],
|
|
26
|
+
...(item.peer !== undefined ? { peer: item.peer as string } : {}), ...(item.id !== undefined ? { id: item.id as string | number } : {}),
|
|
27
|
+
...(item.until !== undefined ? { until: item.until as number } : {}), ...(item.since !== undefined ? { since: item.since as number } : {}) };
|
|
28
|
+
}
|
|
29
|
+
if ((row.act + row.watch > 0) !== !!first) return;
|
|
30
|
+
if (row.act > 0 && first?.level !== "act" || row.act === 0 && first?.level === "act") return;
|
|
31
|
+
if (row.act === 0 && ["waiting", "blocked", "review"].includes(row.state) || row.act > 0 && ["working", "idle", "sleeping"].includes(row.state)) return;
|
|
32
|
+
if (first?.level === "watch" && !["recovery", "budget", "offline"].includes(first.kind) || first?.level === "act" && ["budget", "offline"].includes(first.kind)) return;
|
|
33
|
+
if (row.state === "waiting" && !["approval", "default"].includes(first?.kind ?? "")) return;
|
|
34
|
+
if (row.state === "blocked" && !["storage", "recovery", "delivery"].includes(first?.kind ?? "")) return;
|
|
35
|
+
if (row.state === "review" && first?.kind !== "review") return;
|
|
36
|
+
return { act: row.act, watch: row.watch, state: row.state as NeedsSummary["state"], ...(first ? { first } : {}) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PetView { summary?: NeedsSummary; unavailable: boolean; runnerUnknown?: boolean }
|
|
40
|
+
export interface PetRecoveryReader { lock(): string | undefined; runner(id: string): number | "unknown" | undefined }
|
|
41
|
+
const recoveryReader: PetRecoveryReader = { lock: recoveryLock, runner: recoveryRunner };
|
|
42
|
+
|
|
43
|
+
/** A recovery lock is read-only evidence; this function never resumes or changes an operation. */
|
|
44
|
+
export function petView(status: unknown, reader: PetRecoveryReader = recoveryReader): PetView {
|
|
45
|
+
const row = status && typeof status === "object" && !Array.isArray(status) ? status as Record<string, unknown> : undefined;
|
|
46
|
+
const base = parseNeedsSummary(row?.needs);
|
|
47
|
+
let operation: string | undefined;
|
|
48
|
+
try { operation = reader.lock(); }
|
|
49
|
+
catch { return { summary: base, unavailable: true, runnerUnknown: true }; }
|
|
50
|
+
if (!operation) return { summary: base, unavailable: !base };
|
|
51
|
+
if (!id(operation)) return { summary: base, unavailable: true, runnerUnknown: true };
|
|
52
|
+
let runner: number | "unknown" | undefined;
|
|
53
|
+
try { runner = reader.runner(operation); } catch { runner = "unknown"; }
|
|
54
|
+
const active = runner !== undefined;
|
|
55
|
+
const item: NeedItem = { kind: "recovery", level: active ? "watch" : "act", id: operation };
|
|
56
|
+
const reported = row?.recovery;
|
|
57
|
+
const included = base && reported && typeof reported === "object" && (reported as Record<string, unknown>).operationId === operation && base.watch > 0 ? 1 : 0;
|
|
58
|
+
const act = (base?.act ?? 0) + (active ? 0 : 1), watch = (base?.watch ?? 0) - included + (active ? 1 : 0);
|
|
59
|
+
const first = base?.first;
|
|
60
|
+
const precedes = !active && (!first || first.level === "watch" || !["approval", "default", "storage"].includes(first.kind));
|
|
61
|
+
const selected = precedes || first?.kind === "recovery" && first.id === operation ? item : first ?? item;
|
|
62
|
+
const state = !active && base?.state !== "waiting" ? "blocked" : base?.state ?? "sleeping";
|
|
63
|
+
return { summary: { act, watch, state, first: selected }, unavailable: !base, ...(runner === "unknown" ? { runnerUnknown: true } : {}) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function readPet(stateDir: string, reader: PetRecoveryReader = recoveryReader, alive = processLiveness): PetView {
|
|
67
|
+
let status: unknown;
|
|
68
|
+
try {
|
|
69
|
+
const row = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
|
|
70
|
+
const summary = parseNeedsSummary(row.needs);
|
|
71
|
+
const expired = summary?.first?.kind === "approval" && summary.first.until !== undefined && summary.first.until <= Date.now();
|
|
72
|
+
if (!expired && Number.isSafeInteger(row.pid) && row.pid > 0 && alive(row.pid, row.pidSignature) === "live") status = row;
|
|
73
|
+
} catch { /* Missing, malformed or unconfirmed owner data is unavailable, never an empty live hub. */ }
|
|
74
|
+
return petView(status, reader);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderPet(view: PetView, short = false, character: CharacterId = "stai"): string {
|
|
78
|
+
if (short) return view.summary?.act ? `ahub!${view.summary.act}` : "";
|
|
79
|
+
if (!view.summary) return "attention unavailable";
|
|
80
|
+
const value = view.summary;
|
|
81
|
+
const line = characterAttentionLine({ ...value, items: value.first ? [value.first] : [] }, character, 0, false, false);
|
|
82
|
+
return line + (view.unavailable ? " (status unavailable)" : "") + (view.runnerUnknown ? " (runner unknown)" : "");
|
|
83
|
+
}
|
package/src/cli/preview.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { jcodeCommand } from "../adapters/jcode.ts";
|
|
2
|
+
import { qwenSessionId } from "../hub/crash.ts";
|
|
1
3
|
import { readFileSync } from "node:fs";
|
|
2
4
|
import { join, resolve } from "node:path";
|
|
3
5
|
import { loadConfig } from "../hub/daemon.ts";
|
|
4
6
|
import { childEnv } from "../hub/child-process.ts";
|
|
5
7
|
import { relayModelIds } from "../models/relay.ts";
|
|
6
8
|
import { buildPiLaunch } from "../pi/launch.ts";
|
|
7
|
-
import {
|
|
9
|
+
import { startModeOf } from "../hub/start-mode.ts";
|
|
10
|
+
import { buildLaunch, buildKimiLaunch, buildQwenLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks, type Launch } from "./launch.ts";
|
|
11
|
+
import { kimiMcpPlan } from "./kimi-mcp.ts";
|
|
8
12
|
|
|
9
13
|
const REDACTED = "[redacted]";
|
|
10
14
|
const UNRESOLVED = "[unresolved]";
|
|
@@ -18,11 +22,12 @@ function nativeCommand(command: string[], trusted: string[]): string[] {
|
|
|
18
22
|
return command.map((value, i) => value === trusted[i] ? value : REDACTED);
|
|
19
23
|
}
|
|
20
24
|
/** Read-only preview. Inputs passed to shared native builders are redacted before serialization. */
|
|
21
|
-
export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
|
|
25
|
+
export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen" | "jcode", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
|
|
22
26
|
const args = raw.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
|
|
23
27
|
const config = loadConfig(cwd);
|
|
24
28
|
const unresolved: { field: string; reason: string }[] = [];
|
|
25
29
|
let launch: Launch;
|
|
30
|
+
let kimiMetadata: { mode: "tui" | "headless"; mcp: { file: string; action: string; changed: boolean; tracked: boolean } } | undefined;
|
|
26
31
|
let envNames = Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
|
|
27
32
|
const value = (flag: string) => {
|
|
28
33
|
const at = args.indexOf(flag);
|
|
@@ -57,12 +62,41 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
|
|
|
57
62
|
const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
|
|
58
63
|
launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
|
|
59
64
|
unresolved.push({ field: "proxyUrl", reason: "assigned when the native adapter starts; preview does not start it" });
|
|
65
|
+
} else if (tool === "jcode") {
|
|
66
|
+
const mode = value("--mode") ?? "headless", model = value("--model"), profile = value("--provider-profile"), session = value("--session-id");
|
|
67
|
+
if (mode !== "headless") throw new Error("Jcode TUI driving is unverified");
|
|
68
|
+
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--provider-profile", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Jcode option");
|
|
69
|
+
if (session !== undefined && !qwenSessionId(session)) throw new Error("invalid Jcode session identity");
|
|
70
|
+
const cmd = jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
|
|
71
|
+
launch = { cmd: cmd[0]!, args: cmd.slice(1) };
|
|
72
|
+
unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
|
|
73
|
+
unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
|
|
74
|
+
} else if (tool === "qwen") {
|
|
75
|
+
const mode = value("--mode") ?? "headless", model = value("--model");
|
|
76
|
+
if (mode !== "headless") throw new Error("Qwen TUI is not offered before verification");
|
|
77
|
+
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
|
+
const sessionId = value("--session-id");
|
|
79
|
+
if (sessionId !== undefined && !qwenSessionId(sessionId)) throw new Error("invalid Qwen session identity");
|
|
80
|
+
if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
|
|
81
|
+
launch = buildQwenLaunch(nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model ? REDACTED : undefined);
|
|
82
|
+
unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
|
|
60
83
|
} else if (tool === "kimi") {
|
|
61
84
|
const model = value("--model");
|
|
62
|
-
|
|
63
|
-
|
|
85
|
+
const mode = value("--mode") ?? startModeOf(config.peers, "kimi");
|
|
86
|
+
if (mode !== "tui" && mode !== "headless") throw new Error("invalid Kimi mode");
|
|
87
|
+
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Kimi option");
|
|
88
|
+
if (mode === "headless") {
|
|
89
|
+
launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
|
|
90
|
+
unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
|
|
91
|
+
} else {
|
|
92
|
+
const native = buildKimiTuiLaunch(config.kimi_cmd, model ? REDACTED : undefined);
|
|
93
|
+
launch = { ...native, cmd: native.cmd === "kimi" ? "kimi" : REDACTED, args: native.args.map(arg => arg === "--model" || arg === REDACTED ? arg : REDACTED) };
|
|
94
|
+
envNames = Object.keys(nativeLaunchEnv("kimi", childEnv())).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
|
|
95
|
+
}
|
|
96
|
+
const { file, action, changed, tracked } = kimiMcpPlan(cwd, stateDir, join(import.meta.dir, "../../plugins/agent-hub/server.js"), mode);
|
|
97
|
+
kimiMetadata = { mode, mcp: { file, action, changed, tracked } };
|
|
64
98
|
} else {
|
|
65
|
-
const mode = value("--mode") ?? "
|
|
99
|
+
const mode = value("--mode") ?? startModeOf(config.peers, "pi"); // #269: the same default `ahub pi` takes
|
|
66
100
|
const backend = value("--backend") ?? config.pi.backend;
|
|
67
101
|
if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend) || (value("--session-id") && value("--session-file"))) throw new Error("invalid Pi options");
|
|
68
102
|
for (let i = 0; i < args.length; i += 2) if (!["--mode", "--backend", "--model", "--session-id", "--session-file"].includes(args[i]!)) throw new Error("unknown Pi option");
|
|
@@ -87,5 +121,5 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
|
|
|
87
121
|
unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: tool === "claude" ? "injected when an existing daemon instance is read at launch; preview does not read runtime state" : "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
|
|
88
122
|
unresolved.push({ field: "env.AGENTHUB_LAUNCH_ID", reason: "allocated only for a verified Orca terminal launch; preview does not query Orca, allocate identity or write launch records" });
|
|
89
123
|
}
|
|
90
|
-
return { tool, cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
|
|
124
|
+
return { tool, ...(kimiMetadata ?? {}), cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
|
|
91
125
|
}
|
package/src/cli/reset.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { Bus } from "../hub/bus.ts";
|
|
6
6
|
import { DeliveryJournal } from "../hub/delivery-journal.ts";
|
|
7
7
|
import type { Project } from "../hub/registry.ts";
|
|
8
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
|
|
8
9
|
|
|
9
10
|
/** Agent session resume pointers. `pi-sessions/` holds Pi transcripts, history rather than pointers, and stays. */
|
|
10
11
|
export const SESSION_POINTERS = ["sessions.json", "claude-session.json", "claude-context.json"];
|
|
@@ -60,6 +61,7 @@ export function planReset(stateDir: string, projectId: string): ResetPlan {
|
|
|
60
61
|
const quiet = !existsSync(`${file}-wal`) || !existsSync(`${file}-shm`);
|
|
61
62
|
const db = quiet ? new Database(`file:${file.replace(/[%?#]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}?immutable=1`, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) : new Database(file, { readonly: true });
|
|
62
63
|
try {
|
|
64
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
63
65
|
const has = (table: string) => !!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
64
66
|
const peers = (table: string) => has(table) ? (db.query(`SELECT peer FROM ${table} ORDER BY peer`).all() as { peer: string }[]).map((row) => row.peer) : [];
|
|
65
67
|
plan.budgetPauses = peers("budget_pauses");
|
|
@@ -121,6 +123,7 @@ export function resetRuntime(stateDir: string, project: Project): { settled: str
|
|
|
121
123
|
} finally { bus.closeJournal(); }
|
|
122
124
|
const db = new Database(file);
|
|
123
125
|
try {
|
|
126
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
124
127
|
db.transaction(() => {
|
|
125
128
|
for (const table of ["budget_pauses", "conductor_holds"]) {
|
|
126
129
|
if (db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) db.run(`DELETE FROM ${table}`);
|
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 { 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 }
|
|
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; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: 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. */
|
|
@@ -15,31 +15,3 @@ export function backendLabel(backend: BackendRow): string {
|
|
|
15
15
|
export function contextLine(reading: ContextView, time = (at: number) => new Date(at).toISOString()): string {
|
|
16
16
|
return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${time(reading.measuredAt)}` : ""})`;
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
export function peerLine(id: string, p: PeerRow): string {
|
|
20
|
-
return ` ${id.padEnd(8)} ${(p.state ?? "unknown").padEnd(8)} queued ${p.queued ?? 0}` +
|
|
21
|
-
// Which queued messages the peer will not wait out the batch window for (issue #41).
|
|
22
|
-
(p.queuedImportant ? ` (${p.queuedImportant} important)` : "") +
|
|
23
|
-
(p.needsReview ? ` needs review ${p.needsReview}` : "") +
|
|
24
|
-
(p.liveAccepted?.length ? ` awaiting settlement ${p.liveAccepted.join(", ")} (adapter completion; Claude: reply or hub_delivery_done; task state is independent)` : "") +
|
|
25
|
-
(p.heldBy ? ` ${p.holdNote ?? `held by needs_review ${p.heldBy}; ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`}` : "") +
|
|
26
|
-
(p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
|
|
27
|
-
(p.attached === false ? " disconnected" : "") +
|
|
28
|
-
(p.toolsOnly ? ` ${p.toolsOnly}` : "") +
|
|
29
|
-
(p.permissionMode && p.permissionMode !== "ask" ? ` permission: ${p.permissionMode}` : "") +
|
|
30
|
-
(p.paused ? ` (${p.paused})` : "") +
|
|
31
|
-
(p.servedBy ? ` last call: ${p.servedBy}` : "") +
|
|
32
|
-
// Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
|
|
33
|
-
// meant reading status.json by hand.
|
|
34
|
-
(p.requestedModel ? ` model: ${p.requestedModel}` : "") +
|
|
35
|
-
(p.context ? ` ${contextLine(p.context)}` : "");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function backendLine(backend: BackendRow): string {
|
|
39
|
-
return ` model ${backendLabel(backend)} ${backend.state ?? "unknown"} active ${backend.active ?? 0}` +
|
|
40
|
-
(backend.requestedModel ? ` requested ${backend.requestedModel}` : "") +
|
|
41
|
-
(backend.actualModel ? ` actual ${backend.actualModel}` : "") +
|
|
42
|
-
(backend.provider ? ` provider ${backend.provider}` : "") +
|
|
43
|
-
(backend.coolingUntil ? ` cooling down until ${backend.coolingUntil} after ${backend.failures ?? "?"} failures` : "") +
|
|
44
|
-
(backend.failingUntil ? ` last dispatch failed, no load moves until ${backend.failingUntil}` : "");
|
|
45
|
-
}
|
package/src/cli/tail-render.ts
CHANGED
|
@@ -8,6 +8,7 @@ export function renderTailEvent(e: BusEvent): string {
|
|
|
8
8
|
}
|
|
9
9
|
if (e.t === "overflow") return ` ! ${e.peer}'s queue is full: dropped ${e.env.id} (from ${e.env.from})`;
|
|
10
10
|
if (e.t === "stale") return ` . dropped ${e.env.id} (from ${e.env.from}) for ${e.peer}: ${e.reason}`;
|
|
11
|
+
if (e.t === "off_roster") return ` . ${e.env.id} retained off roster for ${e.peers.join(", ")}`;
|
|
11
12
|
if (e.t === "quiet") return ` . ${e.env.id} (from ${e.env.from}) not delivered to ${e.peers.join(", ")}: turn-free cohort`;
|
|
12
13
|
const { env } = e;
|
|
13
14
|
const note = e.dropped === "hop" ? " [not delivered: hop limit]" : e.dropped === "fyi" ? " [fyi: record only]" : "";
|
|
@@ -154,7 +154,7 @@ export interface RecordedTerminalLaunch {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
const MAX_WAIT_MS = 10 * 60 * 1000;
|
|
157
|
-
const MAX_COMMAND_MS = 30_000;
|
|
157
|
+
export const MAX_COMMAND_MS = 30_000;
|
|
158
158
|
/** #215: a replacement's readiness is awaited in slices this long, checking between them whether its launcher still runs. */
|
|
159
159
|
const EXIT_SLICE_MS = 5_000;
|
|
160
160
|
const ALLOWED_ENV = ["CODEX_HOME", "CLAUDE_CONFIG_DIR"] as const;
|
|
@@ -273,6 +273,32 @@ function defaultRunner(executable: string): CommandRunner {
|
|
|
273
273
|
};
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/** The Orca executable a start or a recovery would run (`orca`, or what the environment pins). */
|
|
277
|
+
export const orcaExecutable = (): string => resolveOrcaExecutable();
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* #269: the Orca worktree a terminal for this project can be opened in: the one this process runs in, or the one the
|
|
281
|
+
* last recorded `ahub <peer>` launch of this project used. Undefined when there is none or the record does not read.
|
|
282
|
+
* ponytail: the environment's worktree is taken as this project's; a hub started from another project's Orca terminal
|
|
283
|
+
* (`ahub --project`, the dashboard manager's start, an upgrade or restart that brings several projects up from one
|
|
284
|
+
* terminal) opens the terminal there and `ahub <peer>` then fails its root readback. Ask Orca for the worktree's path
|
|
285
|
+
* and compare it if that case shows up.
|
|
286
|
+
*/
|
|
287
|
+
export function startTerminalWorktree(projectRoot: string, stateDir: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
288
|
+
if (env.ORCA_WORKTREE_ID) return env.ORCA_WORKTREE_ID;
|
|
289
|
+
const records = launchRecords(stateDir);
|
|
290
|
+
return records === "unreadable" ? undefined : records.rows.filter((row) => row.projectRoot === projectRoot).at(-1)?.worktreeId;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** #269: a new Orca terminal running `command` (a complete, already quoted command line); the terminal's handle. */
|
|
294
|
+
export async function openStartTerminal(worktreeId: string, command: string, title: string, options?: CommandRunner | TerminalRecoveryOptions): Promise<string> {
|
|
295
|
+
const config = normalizeOptions(options);
|
|
296
|
+
const selector = worktreeId.startsWith("id:") ? worktreeId : `id:${worktreeId}`;
|
|
297
|
+
const handle = nestedString(terminalObject(await run(config.runner, ["terminal", "create", "--worktree", selector, "--command", command, "--title", title, "--json"])), ["handle"]);
|
|
298
|
+
if (!handle) throw new Error("Orca create returned no terminal handle");
|
|
299
|
+
return handle;
|
|
300
|
+
}
|
|
301
|
+
|
|
276
302
|
const RECORD_FILE = "terminal-recovery.json";
|
|
277
303
|
|
|
278
304
|
export function recordPath(stateDir: string): string {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { qwenSessionId } from "../hub/crash.ts";
|
|
2
|
+
import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
1
3
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
2
4
|
import { homedir } from "node:os";
|
|
3
5
|
import { basename, join, resolve } from "node:path";
|
|
@@ -83,11 +85,17 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
|
|
|
83
85
|
...(binding.sessionFile ? ["--session-file", binding.sessionFile] : ["--session-id", binding.sessionId])];
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
|
|
89
|
+
|
|
86
90
|
async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL): Promise<any> {
|
|
87
|
-
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id },
|
|
91
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id }, RECOVERY_CONTROL_TIMEOUT_MS, protocol);
|
|
88
92
|
try {
|
|
89
|
-
const reply = await client.request(message,
|
|
90
|
-
if (reply.ok === false)
|
|
93
|
+
const reply = await client.request(message, RECOVERY_CONTROL_TIMEOUT_MS);
|
|
94
|
+
if (reply.ok === false) {
|
|
95
|
+
// An older hub has only its exact SQLite message; preserve compatibility without trusting arbitrary text.
|
|
96
|
+
if (isDatabaseBusyReply(reply)) throw new RecoveryDatabaseBusy();
|
|
97
|
+
throw new Error(reply.error ?? "recovery control request failed");
|
|
98
|
+
}
|
|
91
99
|
return reply;
|
|
92
100
|
} finally { client.close(); }
|
|
93
101
|
}
|
|
@@ -148,7 +156,8 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
|
148
156
|
// pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
|
|
149
157
|
// caller reads that as not inspected, never as stopped.
|
|
150
158
|
const claim = registeredProjects().find((p) => p.id === project.id);
|
|
151
|
-
const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null });
|
|
159
|
+
const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, RECOVERY_CONTROL_TIMEOUT_MS);
|
|
160
|
+
if (base.failure?.code === "database_busy") throw new RecoveryDatabaseBusy();
|
|
152
161
|
const control = readControl(project.stateDir);
|
|
153
162
|
const sourceProtocol = control?.protocol;
|
|
154
163
|
const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
|
|
@@ -161,11 +170,16 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
|
161
170
|
...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}),
|
|
162
171
|
};
|
|
163
172
|
let status = base.status;
|
|
164
|
-
if (!status &&
|
|
173
|
+
if (!status && control && sourceProtocol === PROTOCOL) return {
|
|
174
|
+
state: "unavailable", peers: [], blockers: ["runtime changed during recovery inspection"],
|
|
175
|
+
...(control.instanceId ? { instanceId: control.instanceId } : {}), protocol: control.protocol,
|
|
176
|
+
};
|
|
177
|
+
if (!status && legacySupported && sourceProtocol !== PROTOCOL) {
|
|
165
178
|
try {
|
|
166
179
|
const readback = await rpc(project, { t: "status" }, sourceProtocol);
|
|
167
180
|
status = readback.status;
|
|
168
|
-
} catch {
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (isDatabaseBusy(error)) throw error;
|
|
169
183
|
// server.stop() runs before state-file removal, so a stopping hub briefly refuses
|
|
170
184
|
// connections with its manifest still on disk. Never infer stopped from a connect
|
|
171
185
|
// failure: callers poll again and see "stopped" once the manifest is gone.
|
|
@@ -176,7 +190,8 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
|
176
190
|
if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
|
|
177
191
|
let response: any;
|
|
178
192
|
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId }, sourceProtocol ?? PROTOCOL); }
|
|
179
|
-
catch {
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (isDatabaseBusy(error)) throw error;
|
|
180
195
|
// A stop can complete between authenticated status and the second read. Never infer stopped
|
|
181
196
|
// from a connection failure: the coordinator will inspect the ownership manifest again.
|
|
182
197
|
return { state: "unavailable", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
|
|
@@ -224,6 +239,10 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
224
239
|
const reconnectOnly: string[] = [];
|
|
225
240
|
for (const peer of source.peers) {
|
|
226
241
|
if (peer.state === "offline") continue;
|
|
242
|
+
if (peer.id === "kimi" && peer.args?.mode === "tui") {
|
|
243
|
+
blockers.push("kimi: its pull-only terminal has no automatic restore; end it in that terminal, then make a new recovery plan (its session is not resumed by the hub)");
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
227
246
|
if (peer.id === "claude") {
|
|
228
247
|
// #206: managed means a live `ahub claude` launcher recorded for this daemon and a session record written by
|
|
229
248
|
// that launch. Anything else cannot be bound to the attached session, so its record never becomes a target.
|
|
@@ -251,14 +270,17 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
251
270
|
continue;
|
|
252
271
|
}
|
|
253
272
|
}
|
|
273
|
+
if (peer.id === "jcode") { blockers.push("jcode: source-pinned native load cannot prove conversation identity; end the peer and make a new recovery plan before effects"); continue; }
|
|
254
274
|
if (peer.id === "codex" || peer.id === "claude") {
|
|
255
275
|
const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
|
|
256
276
|
if (!session) blockers.push(`${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
|
|
257
277
|
else sessions[peer.id] = session;
|
|
278
|
+
} else if (peer.id === "qwen" || peer.id === "jcode") {
|
|
279
|
+
if (!qwenSessionId(peer.sessionId)) blockers.push(`${peer.id}: saved ACP session identity unavailable; end the peer and make a new recovery plan`);
|
|
258
280
|
} else if (peer.id === "pi" && peer.args?.mode === "tui") {
|
|
259
281
|
if (!peer.sessionId) blockers.push("pi: original session ID is unknown; manual-required; next action: reconnect the original Pi session and make a new recovery plan");
|
|
260
282
|
else sessions.pi = { sessionId: peer.sessionId, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args.backend ? { backend: peer.args.backend } : {}), ...(peer.args.model ? { model: peer.args.model } : {}) };
|
|
261
|
-
} else if (peer.id !== "kimi" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
|
|
283
|
+
} else if (peer.id !== "kimi" && peer.id !== "qwen" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
|
|
262
284
|
}
|
|
263
285
|
const terminals = await inspectTerminals(project.root, sessions, {
|
|
264
286
|
...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
|
|
@@ -435,6 +457,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
435
457
|
// A probe that did not answer (killed, a null exit code maps to 1) says nothing about the target: retry.
|
|
436
458
|
if (!Number.isInteger(read)) throw new Error(`the staged target's control protocol could not be read (probe exit ${protocol.code}); staged package retained, runtimes unchanged; resume again once it can run`);
|
|
437
459
|
if (read !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
460
|
+
if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "qwen" && peer.state !== "offline"))) {
|
|
461
|
+
const catalog = join(target.root, "src/hub/peer-catalog.ts");
|
|
462
|
+
const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
|
|
463
|
+
if (!peers || !/["']qwen["']/.test(peers)) throw new FinalRefusal("target does not support Qwen; end Qwen and make a new plan, or choose a supporting target; runtimes unchanged");
|
|
464
|
+
}
|
|
465
|
+
if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "jcode" && peer.state !== "offline"))) {
|
|
466
|
+
const catalog = join(target.root, "src/hub/peer-catalog.ts");
|
|
467
|
+
const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
|
|
468
|
+
if (!peers || !/["']jcode["']/.test(peers)) throw new FinalRefusal("target does not support Jcode; end it and make a new plan, or choose a supporting target; runtimes unchanged");
|
|
469
|
+
}
|
|
438
470
|
// Reconnect-only (#206) and fresh sessions (#215) need a target hub that reads recovery-waivers.json.
|
|
439
471
|
if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
|
|
440
472
|
throw new FinalRefusal(`target ${op.plan.version} predates recovery waivers, so its hub could never accept a reconnect-only Claude or a fresh Codex start; staged package retained, runtimes unchanged; choose a newer target once this operation is cancelled or ended`);
|
|
@@ -498,11 +530,25 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
498
530
|
const live = await inspectRecovery(planned.project);
|
|
499
531
|
if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
|
|
500
532
|
if (group === "native") {
|
|
501
|
-
for (const peer of planned.source.peers.filter((p) => ["kimi", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
|
|
533
|
+
for (const peer of planned.source.peers.filter((p) => ["kimi", "qwen", "jcode", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
|
|
502
534
|
const current = live.peers.find((p) => p.id === peer.id);
|
|
503
535
|
if (current && current.state !== "offline") continue;
|
|
504
536
|
const args = [peer.id];
|
|
505
|
-
|
|
537
|
+
if (peer.id === "kimi" && peer.args?.mode === "tui") throw new Error("Kimi's pull-only terminal cannot be restored as a headless worker; end it in that terminal and make a new recovery plan");
|
|
538
|
+
for (const key of ["mode", "backend", "model", "route"]) if (!(peer.id === "kimi" && key === "mode") && typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
|
|
539
|
+
if (peer.id === "kimi") {
|
|
540
|
+
// ponytail: a staged target's fixed catalog is the capability signal; replace this source check with
|
|
541
|
+
// an explicit recovery capability once targets publish one. Older targets have only headless Kimi.
|
|
542
|
+
const modeFile = join(op.targetRoot!, "src/hub/start-mode.ts");
|
|
543
|
+
const modes = existsSync(modeFile) ? /export const START_MODE_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(modeFile, "utf8"))?.[1] : undefined;
|
|
544
|
+
if (modes && /["']kimi["']/.test(modes)) args.push("--mode", "headless");
|
|
545
|
+
}
|
|
546
|
+
// #269: `ahub pi` without a mode now takes Pi's start mode (tui by default). This Pi ran headless (a terminal
|
|
547
|
+
// one is filtered out above), so a snapshot that recorded no mode still restores it headless.
|
|
548
|
+
if (peer.id === "pi" && typeof peer.args?.mode !== "string") args.push("--mode", "headless");
|
|
549
|
+
if (peer.id === "qwen" && peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
550
|
+
if (peer.id === "jcode" && peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
551
|
+
if (peer.id === "jcode" && peer.args?.providerProfile) args.push("--provider-profile", peer.args.providerProfile);
|
|
506
552
|
if (peer.id === "pi") {
|
|
507
553
|
const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
|
|
508
554
|
if (typeof sessionFile === "string") args.push("--session-file", sessionFile);
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
5
|
-
import { PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
6
|
+
import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
6
7
|
import { hubHome } from "../hub/project.ts";
|
|
7
8
|
import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
|
|
8
9
|
|
|
@@ -82,6 +83,7 @@ export interface RecoveryOperation {
|
|
|
82
83
|
pluginInstalled?: boolean;
|
|
83
84
|
globalInstalled?: boolean;
|
|
84
85
|
error?: string;
|
|
86
|
+
failure?: { code: "database_busy"; retryable: true; site?: string[] };
|
|
85
87
|
/** #215: every human disposition, in order. Operator reasons only; never task or message text. */
|
|
86
88
|
audit?: { at: number; action: "fresh-session" | "stop-and-archive"; reason: string; peer?: string; projects: string[] }[];
|
|
87
89
|
disposition?: { choice: "stop-and-archive"; at: number; projects: Record<string, string> };
|
|
@@ -288,6 +290,7 @@ export function registeredProjects(home = hubHome()): Project[] {
|
|
|
288
290
|
if (!existsSync(path)) return [];
|
|
289
291
|
const db = new Database(path, { readonly: true });
|
|
290
292
|
try {
|
|
293
|
+
db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
|
|
291
294
|
// #226: a registry no current release opened yet has no signature column, and this read never migrates.
|
|
292
295
|
const signed = (db.query("PRAGMA table_info(projects)").all() as { name: string }[]).some((c) => c.name === "pid_signature");
|
|
293
296
|
return (db.query(`SELECT ${PROJECT_COLUMNS}${signed ? ", pid_signature" : ""} FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
|
|
@@ -433,10 +436,11 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
433
436
|
if (op.disposition) {
|
|
434
437
|
// The stop-and-archive in progress keeps its own cause; resume never runs an operation being abandoned.
|
|
435
438
|
op.error ??= `a stop-and-archive of this operation is partway; ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
|
|
439
|
+
delete op.failure;
|
|
436
440
|
op.phase = "blocked"; save();
|
|
437
441
|
return op;
|
|
438
442
|
}
|
|
439
|
-
op.phase = "running"; delete op.error; save();
|
|
443
|
+
op.phase = "running"; delete op.error; delete op.failure; save();
|
|
440
444
|
step("stage");
|
|
441
445
|
const target = await driver.stage(op);
|
|
442
446
|
if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new FinalRefusal("staged target changed since operation started");
|
|
@@ -494,7 +498,13 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
494
498
|
await driver.commit(project, id, planned.source.instanceId!);
|
|
495
499
|
const deadline = driver.now() + 30_000;
|
|
496
500
|
do {
|
|
497
|
-
live = await driver.inspect(project);
|
|
501
|
+
try { live = await driver.inspect(project); }
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (!isDatabaseBusy(error)) throw error;
|
|
504
|
+
// A committed source may still be stopping. Contention is an
|
|
505
|
+
// unconfirmed read here, not a refusal; keep the existing bound.
|
|
506
|
+
live = { state: "unavailable", peers: [], blockers: ["database_busy"] };
|
|
507
|
+
}
|
|
498
508
|
if (live.state === "stopped") break;
|
|
499
509
|
if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
|
|
500
510
|
await driver.sleep(100);
|
|
@@ -556,6 +566,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
556
566
|
releaseRecoveryLock(id, home); op.phase = "completed"; step("completed");
|
|
557
567
|
} catch (error) {
|
|
558
568
|
// #215: every runner error ends with the choices status shows, read from every open source, never only this one.
|
|
569
|
+
if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
|
|
559
570
|
if (error instanceof FinalRefusal) op.final = true;
|
|
560
571
|
const next = nextActionsText(op, await liveProjects(op, driver.inspect));
|
|
561
572
|
op.phase = "blocked"; op.error = `${error instanceof Error ? error.message : "recovery failed"}; ${next}`; save();
|
|
@@ -575,7 +586,7 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
|
|
|
575
586
|
...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
|
|
576
587
|
pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
|
|
577
588
|
...(!targetReadsWaivers(op) && op.projects.some((p) => failedPeers(op, p).length) ? { freshSession: `not offered: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session` } : {}),
|
|
578
|
-
...(op.error ? { error: op.error } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
|
|
589
|
+
...(op.error ? { error: op.error } : {}), ...(op.failure ? { failure: op.failure } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
|
|
579
590
|
}
|
|
580
591
|
|
|
581
592
|
/** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
|
|
@@ -605,8 +616,12 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
605
616
|
const state = live[planned.project.id];
|
|
606
617
|
if (state?.recovery?.operationId === id && state.recovery.phase !== "released") await driver.abort(planned.project, id, planned.source.instanceId!);
|
|
607
618
|
}
|
|
619
|
+
delete op.error; delete op.failure;
|
|
608
620
|
op.phase = "cancelled"; op.step = "cancelled"; op.updatedAt = driver.now();
|
|
609
621
|
writeOperation(id, op, home); releaseRecoveryLock(id, home);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (isDatabaseBusy(error)) throw new RecoveryDatabaseBusy();
|
|
624
|
+
throw error;
|
|
610
625
|
} finally { releaseRunner(); }
|
|
611
626
|
}
|
|
612
627
|
|
|
@@ -658,6 +673,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
658
673
|
(progress.fresh ??= {})[peer] = { lost: binding.sessionId, reason, at };
|
|
659
674
|
}
|
|
660
675
|
(op.audit ??= []).push({ at, action: "fresh-session", reason, peer, projects: failed.map((p) => p.id) });
|
|
676
|
+
delete op.error; delete op.failure;
|
|
661
677
|
op.updatedAt = at; writeOperation(id, op, home);
|
|
662
678
|
return op;
|
|
663
679
|
}
|
|
@@ -687,6 +703,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
687
703
|
abandoned(op); // #227: in the same write as the disposition
|
|
688
704
|
(op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
|
|
689
705
|
// Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
|
|
706
|
+
delete op.failure;
|
|
690
707
|
op.updatedAt = driver.now(); writeOperation(id, op, home);
|
|
691
708
|
try {
|
|
692
709
|
for (const { planned, live, act } of acts) {
|
|
@@ -699,12 +716,13 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
699
716
|
op.updatedAt = driver.now(); writeOperation(id, op, home);
|
|
700
717
|
}
|
|
701
718
|
} catch (error) {
|
|
719
|
+
if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
|
|
702
720
|
op.phase = "blocked"; op.updatedAt = driver.now();
|
|
703
721
|
op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
|
|
704
722
|
writeOperation(id, op, home);
|
|
705
723
|
throw error;
|
|
706
724
|
}
|
|
707
|
-
delete op.error;
|
|
725
|
+
delete op.error; delete op.failure;
|
|
708
726
|
op.phase = "cancelled"; op.step = "disposed: stop-and-archive (abandoned, not completed)"; op.updatedAt = driver.now();
|
|
709
727
|
writeOperation(id, op, home); releaseRecoveryLock(id, home);
|
|
710
728
|
return op;
|
package/src/cli/watch.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Activity } from "../hub/activity.ts";
|
|
2
|
+
import { paint, stateTone, terminalText, wrap } from "./console-state.ts";
|
|
3
|
+
|
|
4
|
+
export function renderActivity(peer: string, item: Activity, columns: number, color: boolean, noThoughts = false): string[] {
|
|
5
|
+
if (noThoughts && item.kind === "thought") return [];
|
|
6
|
+
const width = Math.max(20, Math.floor(columns));
|
|
7
|
+
const time = new Date(item.at).toISOString().slice(11, 19);
|
|
8
|
+
const header = `${time} ${peer} ${item.kind} (turn ${item.turn})`;
|
|
9
|
+
const tone = item.kind === "turn_end" ? stateTone("idle") : "info";
|
|
10
|
+
return [...wrap(header, width).map(text => paint([{ text, tone }], color)),
|
|
11
|
+
...wrap(terminalText(item.text), width - 4, 0).map(text => paint([{ text: ` ${text}` }], color))];
|
|
12
|
+
}
|
|
13
|
+
export interface WatchClient {
|
|
14
|
+
request(msg: Record<string, unknown>): Promise<any>;
|
|
15
|
+
onPush: (msg: any) => void;
|
|
16
|
+
onClose: (code: number, reason: string) => void;
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
19
|
+
/** This view only watches and reads status. No prompt, permission or command request exists here. */
|
|
20
|
+
export async function attachWatch(client: WatchClient, peer: string, write: (text: string) => void, options: { columns: () => number; color: boolean; noThoughts: boolean }): Promise<void> {
|
|
21
|
+
client.onPush = msg => {
|
|
22
|
+
if (msg.t !== "activity" || msg.peer !== peer || !msg.item || typeof msg.item.text !== "string" || !Number.isFinite(msg.item.at) || !Number.isSafeInteger(msg.item.turn)) return;
|
|
23
|
+
for (const line of renderActivity(peer, msg.item, options.columns(), options.color, options.noThoughts)) write(line);
|
|
24
|
+
};
|
|
25
|
+
const watched = await client.request({ t: "watch", peer });
|
|
26
|
+
if (watched.ok !== true) throw new Error(watched.error ?? "hub does not support watch; upgrade it before using this view");
|
|
27
|
+
const status = await client.request({ t: "status" });
|
|
28
|
+
if (status.ok === false) throw new Error(status.error ?? "watch connection closed");
|
|
29
|
+
const row = status.status?.peers?.[peer];
|
|
30
|
+
if (row) for (const line of wrap(`${peer}: ${row.state ?? "unknown"}; context ${row.context?.used ?? "unknown"}; q or Ctrl+C quits (approvals: ahub console)`, options.columns())) write(line);
|
|
31
|
+
}
|