@staix/agent-hub 0.12.31 → 0.12.33
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 +20 -1
- package/README.md +14 -7
- package/docs/agent-notes/adapters.md +7 -4
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +13 -8
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +4 -3
- package/docs/agent-notes/tests.md +1 -0
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +141 -25
- package/docs/quickstart.md +10 -6
- package/docs/security.md +18 -9
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +211 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verification/2026-10-18-0.12.33.md +42 -0
- package/docs/verified.json +24 -16
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1296 -26
- package/src/adapters/acp.ts +5 -6
- 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/pi.ts +6 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/console-state.ts +74 -5
- package/src/cli/console.ts +35 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +13 -3
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +200 -39
- package/src/cli/output.ts +2 -0
- 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/reset.ts +13 -1
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -75
- 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 +227 -0
- package/src/cli/upgrade-restore-state.ts +6 -2
- package/src/cli/upgrade-runtime.ts +360 -199
- package/src/cli/upgrade.ts +278 -25
- package/src/hub/bus.ts +144 -14
- 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 +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +577 -179
- package/src/hub/deferred-hub.ts +60 -0
- package/src/hub/delivery-journal.ts +6 -0
- package/src/hub/events.ts +2 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +10 -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/restart.ts +12 -1
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +50 -36
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/supervision.ts +3 -2
- package/src/hub/tasks.ts +29 -9
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +23 -7
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { validateRawConfig, type ConfigLayer } from "./config-apply.ts";
|
|
5
|
+
import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
|
|
6
|
+
import { CLASSES } from "./board.ts";
|
|
7
|
+
|
|
8
|
+
export const CONFIG_SOURCE_FILES = ["config.json", "config.local.json", "routing.toml", "routing.local.toml"] as const;
|
|
9
|
+
export type ConfigSourceName = typeof CONFIG_SOURCE_FILES[number];
|
|
10
|
+
export type ConfigSourceTexts = Record<ConfigSourceName, string | null>;
|
|
11
|
+
export interface ConfigSources {
|
|
12
|
+
texts: ConfigSourceTexts;
|
|
13
|
+
layers: ConfigLayer[];
|
|
14
|
+
trustLayers: { name: ConfigSourceName; raw: Record<string, unknown> }[];
|
|
15
|
+
routing: Routing;
|
|
16
|
+
fingerprint: string;
|
|
17
|
+
}
|
|
18
|
+
const MAX_CONFIG_BYTES = 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/** Capture once; a missing file is distinct from a read failure. Errors never quote file contents. */
|
|
21
|
+
export function configSourceTexts(cwd: string): ConfigSourceTexts {
|
|
22
|
+
return Object.fromEntries(CONFIG_SOURCE_FILES.map(name => {
|
|
23
|
+
try {
|
|
24
|
+
const text = readFileSync(join(cwd, ".agenthub", name), "utf8");
|
|
25
|
+
if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) throw new Error("oversized");
|
|
26
|
+
return [name, text];
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [name, null];
|
|
29
|
+
throw new Error(`.agenthub/${name}: configuration unavailable or oversized`);
|
|
30
|
+
}
|
|
31
|
+
})) as ConfigSourceTexts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const configFingerprint = (texts: ConfigSourceTexts): string => createHash("sha256").update(JSON.stringify(texts)).digest("hex");
|
|
35
|
+
|
|
36
|
+
/** Validate only the hub-owned routing schema; Switchyard target/route fields remain extensible. */
|
|
37
|
+
function validateRoutingRaw(value: unknown): void {
|
|
38
|
+
const bad = (key: string): never => { throw new Error(`.agenthub/routing.toml: ${key} is invalid or unknown`); };
|
|
39
|
+
const table = (v: unknown, key: string): Record<string, unknown> => {
|
|
40
|
+
if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(v).length > 256) return bad(key);
|
|
41
|
+
if (Object.keys(v).some(name => ["__proto__", "constructor", "prototype"].includes(name))) return bad(key);
|
|
42
|
+
return v as Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
const text = (v: unknown, key: string) => { if (typeof v !== "string" || !v || v.length > 4096 || /[\x00-\x1f\x7f]/.test(v)) bad(key); };
|
|
45
|
+
const numeric = (v: unknown, key: string, min = 1, max = Number.MAX_SAFE_INTEGER) => { if (typeof v !== "number" || !Number.isSafeInteger(v) || v < min || v > max) bad(key); };
|
|
46
|
+
const choice = (v: unknown, key: string, choices: readonly string[]) => { if (typeof v !== "string" || !choices.includes(v)) bad(key); };
|
|
47
|
+
const closed = (v: unknown, key: string, fields: Record<string, (v: unknown, key: string) => void>) => {
|
|
48
|
+
for (const [name, item] of Object.entries(table(v, key))) { if (!Object.hasOwn(fields, name)) bad(`${key}.${name}`); fields[name]!(item, `${key}.${name}`); }
|
|
49
|
+
};
|
|
50
|
+
const peers = (v: unknown, key: string) => {
|
|
51
|
+
if (!Array.isArray(v) || v.length > 128 || new Set(v).size !== v.length || v.some(peer => typeof peer !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(peer))) bad(key);
|
|
52
|
+
};
|
|
53
|
+
const strings = (v: unknown, key: string) => { if (!Array.isArray(v) || v.length > 256 || v.some(x => typeof x !== "string" || x.length > 4096)) bad(key); };
|
|
54
|
+
const bool = (v: unknown, key: string) => { if (typeof v !== "boolean") bad(key); };
|
|
55
|
+
const openValue = (v: unknown, key: string, depth = 0): void => {
|
|
56
|
+
if (depth > 16) bad(key);
|
|
57
|
+
if (typeof v === "string") { if (v.length > 16_384) bad(key); }
|
|
58
|
+
else if (typeof v === "number") { if (!Number.isFinite(v)) bad(key); }
|
|
59
|
+
else if (typeof v === "boolean" || v === null) return;
|
|
60
|
+
else if (Array.isArray(v)) { if (v.length > 1024) bad(key); for (const item of v) openValue(item, key, depth + 1); }
|
|
61
|
+
else for (const item of Object.values(table(v, key))) openValue(item, key, depth + 1);
|
|
62
|
+
};
|
|
63
|
+
const switchyard = (v: unknown, key: string, required: "id" | "type") => {
|
|
64
|
+
for (const [name, item] of Object.entries(table(v, key))) {
|
|
65
|
+
if (!name || name.length > 128 || ["__proto__", "constructor", "prototype"].includes(name)) bad(key);
|
|
66
|
+
const entry = table(item, `${key}.${name}`); text(entry[required], `${key}.${name}.${required}`); openValue(entry, `${key}.${name}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
closed(value, "routing", {
|
|
70
|
+
local: (v, k) => closed(v, k, { route: text, fixed_model: text }),
|
|
71
|
+
targets: (v, k) => switchyard(v, k, "id"), routes: (v, k) => switchyard(v, k, "type"),
|
|
72
|
+
// parseRouting delegates these tables to the existing parseHubRoutes validator.
|
|
73
|
+
hub_routes: (v, k) => { table(v, k); },
|
|
74
|
+
stay_switch: (v, k) => choice(v, k, ["off", "shadow", "enforce"]), max_switch_prefill_tokens: numeric,
|
|
75
|
+
signals: (v, k) => closed(v, k, { pii_patterns: strings, long_context_tokens: numeric, pii_screen: (x, p) => choice(x, p, ["off", "local"]) }),
|
|
76
|
+
constraints: (v, k) => closed(v, k, { pii: (x, p) => choice(x, p, ["local_only", "off"]), long_context: (x, p) => choice(x, p, ["skip_local", "off"]), budget_paused: (x, p) => choice(x, p, ["skip_peer", "off"]) }),
|
|
77
|
+
pi: (v, k) => closed(v, k, { dgx_max_context_tokens: numeric, mlx_max_context_tokens: numeric, efficient_wait_ms: (x, p) => numeric(x, p, 0, 119999) }),
|
|
78
|
+
classes: (v, k) => { for (const [name, item] of Object.entries(table(v, k))) {
|
|
79
|
+
if (!(CLASSES as readonly string[]).includes(name)) bad(`${k}.${name}`);
|
|
80
|
+
closed(item, `${k}.${name}`, { peers, escalate_to: peers, route: text, fixed_model: text, local_allowed: bool, pi_backend: (x, p) => choice(x, p, ["dgx", "mlx"]) });
|
|
81
|
+
} },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
|
|
86
|
+
export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing): ConfigSources {
|
|
87
|
+
const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
|
|
88
|
+
for (const name of CONFIG_SOURCE_FILES) {
|
|
89
|
+
const text = texts[name];
|
|
90
|
+
if (text === null) continue;
|
|
91
|
+
let raw: Record<string, unknown>;
|
|
92
|
+
try {
|
|
93
|
+
const value: unknown = name.endsWith(".json") ? JSON.parse(text) : Bun.TOML.parse(text);
|
|
94
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
|
95
|
+
raw = value as Record<string, unknown>;
|
|
96
|
+
} catch {
|
|
97
|
+
if (!fallbackRouting) throw new Error(`.agenthub/${name}: invalid ${name.endsWith(".json") ? "JSON object" : "TOML"}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (name === "config.json" || name === "config.local.json") {
|
|
101
|
+
if (!fallbackRouting) {
|
|
102
|
+
try { validateRawConfig(raw); }
|
|
103
|
+
catch (error) { throw new Error(`.agenthub/${name}: ${(error as Error).message}`); }
|
|
104
|
+
}
|
|
105
|
+
layers.push({ name, raw });
|
|
106
|
+
}
|
|
107
|
+
trustLayers.push({ name, raw });
|
|
108
|
+
}
|
|
109
|
+
let routing = fallbackRouting;
|
|
110
|
+
if (!routing) {
|
|
111
|
+
const base = texts["routing.toml"] ?? readFileSync(new URL("../../templates/routing.toml", import.meta.url), "utf8");
|
|
112
|
+
validateRoutingRaw(Bun.TOML.parse(base));
|
|
113
|
+
let overlay;
|
|
114
|
+
try { overlay = texts["routing.local.toml"] === null ? undefined : parseOverlay(texts["routing.local.toml"]); }
|
|
115
|
+
catch { throw new Error(".agenthub/routing.local.toml: invalid routing overlay"); }
|
|
116
|
+
try { routing = parseRouting(base, overlay); }
|
|
117
|
+
catch { throw new Error(".agenthub/routing.toml / routing.local.toml: invalid effective routing"); }
|
|
118
|
+
}
|
|
119
|
+
return { texts, layers, trustLayers, routing, fingerprint: configFingerprint(texts) };
|
|
120
|
+
}
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { lstatSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { childEnv } from "./child-process.ts";
|
|
4
4
|
import { hubGitSync } from "./git.ts";
|
|
5
5
|
|
|
@@ -31,6 +31,15 @@ export const MACHINE_LOCAL = [
|
|
|
31
31
|
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
32
32
|
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection"] as const;
|
|
33
33
|
|
|
34
|
+
/** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
|
|
35
|
+
export function configRepositoryPresent(cwd: string): boolean {
|
|
36
|
+
for (let at = cwd; ; at = dirname(at)) {
|
|
37
|
+
try { lstatSync(join(at, ".git")); return true; }
|
|
38
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return true; }
|
|
39
|
+
if (dirname(at) === at) return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
/**
|
|
35
44
|
* Why `.agenthub/<name>` must not set machine-local fields, or undefined when git confirms nobody committed it.
|
|
36
45
|
* Unknown is not untracked: no repository, no git, or a git error all refuse. The directory itself counts too (a
|
|
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
8
8
|
|
|
9
9
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
10
|
export const PROTOCOL = 16;
|
|
11
|
-
export const DAEMON_FEATURES = ["task_cancel"] as const;
|
|
11
|
+
export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub"] as const;
|
|
12
12
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
13
13
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
14
14
|
|
package/src/hub/crash.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import { chmodSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
@@ -47,7 +48,7 @@ export function removeSessions(stateDir: string, instanceId?: string): void {
|
|
|
47
48
|
if (instanceId === undefined || readSessions(stateDir)?.instanceId === instanceId) rmSync(fileOf(stateDir), { force: true });
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
export
|
|
51
|
+
export { qwenSessionId } from "../adapters/definitions/qwen-native.ts";
|
|
51
52
|
|
|
52
53
|
const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
|
|
53
54
|
|
|
@@ -59,6 +60,12 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
|
|
|
59
60
|
return records.map(({ peer, meta }) => {
|
|
60
61
|
const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
|
|
61
62
|
const model = str(launch.model);
|
|
63
|
+
const adapter = definitionOf(peer);
|
|
64
|
+
if (adapter?.transport === "acp" && adapter.start.create) {
|
|
65
|
+
const sessionId = adapter.session.crashIdentity?.(meta).sessionId;
|
|
66
|
+
if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ahub ${peer}` };
|
|
67
|
+
return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
|
|
68
|
+
}
|
|
62
69
|
switch (peer) {
|
|
63
70
|
case "claude":
|
|
64
71
|
return { peer, how: "claude: the Claude Code plugin reconnects by itself while that session is still open" };
|
|
@@ -66,11 +73,6 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
|
|
|
66
73
|
return { peer, how: `codex: its app-server died with the hub; run ahub codex again${str(meta.threadId) ? ` (its conversation was thread ${meta.threadId})` : ""}` };
|
|
67
74
|
case "jcode":
|
|
68
75
|
return { peer, how: "jcode: native 0.94.0 load does not prove the restored id; reopen the original conversation with its native client, no automatic fresh replacement" };
|
|
69
|
-
case "qwen": {
|
|
70
|
-
const sessionId = meta.sessionId;
|
|
71
|
-
if (!qwenSessionId(sessionId)) return { peer, how: "qwen: saved ACP session identity unavailable; start it deliberately with ahub qwen" };
|
|
72
|
-
return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: "qwen: the recorded conversation can be loaded again (ACP session/load)" };
|
|
73
|
-
}
|
|
74
76
|
case "kimi": {
|
|
75
77
|
const sessionId = str(meta.sessionId);
|
|
76
78
|
if (!sessionId) return { peer, how: "kimi: no session id was recorded; start it again with ahub kimi" };
|