@staix/agent-hub 0.12.44 → 0.12.45
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 +22 -0
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +9 -0
- package/docs/agent-notes/adapters.md +58 -44
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/daemon.md +9 -7
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +8 -5
- package/docs/agent-notes/tests.md +7 -7
- package/docs/operations.md +99 -7
- package/docs/quickstart.md +5 -1
- package/docs/sdlc.md +26 -8
- package/docs/security.md +6 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +9818 -9605
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/claude-channel.ts +14 -5
- package/src/adapters/codex-appserver.ts +1 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +23 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen.ts +4 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +10 -0
- package/src/adapters/registry.ts +51 -15
- package/src/cli/help.ts +21 -16
- package/src/cli/identity.ts +1 -1
- package/src/cli/main.ts +23 -5
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/board.ts +30 -4
- package/src/hub/config-apply.ts +18 -10
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +95 -39
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +21 -5
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +2 -1
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +5 -1
- package/src/hub/snapshots.ts +8 -6
- package/src/hub/tasks.ts +275 -45
- package/src/hub/ui.ts +2 -2
- package/src/ui/index.html +559 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
3
|
+
import { isBuiltin } from "node:module";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { moduleDataObject as dataObject, moduleText as text, moduleArray as array } from "./external-metadata.ts";
|
|
6
|
+
import { ADAPTER_API } from "./adapter-api.ts";
|
|
7
|
+
import { CONFIGURED_ACP_RESERVED_COMMANDS } from "./configured-acp.ts";
|
|
8
|
+
import { ADAPTERS, assertDefinitionCoherence, assertNativeCapabilityScope, createProjectAdapterCatalog } from "./registry.ts";
|
|
9
|
+
import type { AdapterDefinition, LaunchPlan } from "./registry-types.ts";
|
|
10
|
+
import { configRefusal } from "../hub/config-trust.ts";
|
|
11
|
+
import { realPath } from "../hub/project.ts";
|
|
12
|
+
import { scanModuleSource } from "./external-module-scan.ts";
|
|
13
|
+
|
|
14
|
+
export interface AdapterModuleDeclaration { readonly path: string; readonly sha256: string }
|
|
15
|
+
interface ModuleConfigLayer { name: string; raw: Record<string, unknown> }
|
|
16
|
+
const MAX_MODULES = 16, MAX_MODULE_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
const ADAPTER_IMPORT = "@staix/agent-hub/adapter";
|
|
18
|
+
class ExternalAdapterRefusal extends Error {}
|
|
19
|
+
const fail = (reason: string): never => { throw new ExternalAdapterRefusal(`external adapter: ${reason}`); };
|
|
20
|
+
const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
21
|
+
export function adapterModuleDeclarations(raw: unknown): readonly AdapterModuleDeclaration[] {
|
|
22
|
+
if (raw === undefined) return Object.freeze([]);
|
|
23
|
+
return Object.freeze(array(raw, MAX_MODULES).map(value => {
|
|
24
|
+
const row = dataObject(value);
|
|
25
|
+
if (Object.keys(row).some(key => !["path", "sha256"].includes(key))) return fail("unknown module declaration field");
|
|
26
|
+
const path = text(row.path, 4096), hash = row.sha256;
|
|
27
|
+
if (typeof hash !== "string" || !/^[a-f0-9]{64}$/i.test(hash)) return fail("a SHA-256 content pin is required");
|
|
28
|
+
return Object.freeze({ path, sha256: hash.toLowerCase() });
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** This reads declarations only. It never imports code, including for reloads and CLI reads. */
|
|
33
|
+
export function readAdapterModuleDeclarations(cwd: string, layers: readonly ModuleConfigLayer[]) {
|
|
34
|
+
const ignored: string[] = [];
|
|
35
|
+
let modules: readonly AdapterModuleDeclaration[] = Object.freeze([]);
|
|
36
|
+
for (const { name, raw } of layers) {
|
|
37
|
+
if (raw.adapter_modules === undefined) continue;
|
|
38
|
+
const refusal = name !== "config.local.json" ? "modules are machine-local config.local.json values" : configRefusal(cwd, name);
|
|
39
|
+
if (refusal) { ignored.push(`adapter_modules in .agenthub/${name} ignored: ${refusal}`); continue; }
|
|
40
|
+
modules = adapterModuleDeclarations(raw.adapter_modules);
|
|
41
|
+
}
|
|
42
|
+
return { modules, ignored };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const inside = (root: string, file: string) => { const part = relative(root, file); return part !== "" && part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part); };
|
|
46
|
+
function regularPath(root: string, file: string) {
|
|
47
|
+
if (!inside(root, file)) return fail("module must be inside the project root");
|
|
48
|
+
let at = root;
|
|
49
|
+
const parts = relative(root, file).split(sep);
|
|
50
|
+
for (let index = 0; index < parts.length; index++) {
|
|
51
|
+
at = join(at, parts[index]!);
|
|
52
|
+
const stat = lstatSync(at);
|
|
53
|
+
if (stat.isSymbolicLink() || (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())) return fail("symlinked or nonregular module path refused");
|
|
54
|
+
}
|
|
55
|
+
return lstatSync(file);
|
|
56
|
+
}
|
|
57
|
+
function captureBundle(cwd: string, declaration: AdapterModuleDeclaration): Buffer {
|
|
58
|
+
const root = realPath(cwd), file = resolve(root, declaration.path);
|
|
59
|
+
const observed = regularPath(root, file);
|
|
60
|
+
if ((observed.mode & 0o002) !== 0) return fail("world-writable module refused");
|
|
61
|
+
if (observed.size > MAX_MODULE_BYTES) return fail("module exceeds the size bound");
|
|
62
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
63
|
+
try {
|
|
64
|
+
const before = fstatSync(fd);
|
|
65
|
+
if (!before.isFile() || before.dev !== observed.dev || before.ino !== observed.ino || (before.mode & 0o002) !== 0) return fail("module changed before capture");
|
|
66
|
+
const buffer = Buffer.alloc(MAX_MODULE_BYTES + 1);
|
|
67
|
+
let length = 0;
|
|
68
|
+
while (length < buffer.length) { const n = readSync(fd, buffer, length, buffer.length - length, length); if (!n) break; length += n; }
|
|
69
|
+
if (length > MAX_MODULE_BYTES) return fail("module exceeds the size bound");
|
|
70
|
+
const after = fstatSync(fd), current = regularPath(root, file);
|
|
71
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs
|
|
72
|
+
|| current.dev !== before.dev || current.ino !== before.ino || !inside(root, realPath(file))) return fail("module changed during capture");
|
|
73
|
+
const bytes = buffer.subarray(0, length);
|
|
74
|
+
if (digest(bytes) !== declaration.sha256) return fail("content hash mismatch");
|
|
75
|
+
return bytes;
|
|
76
|
+
} finally { closeSync(fd); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Conservative code-loading rejection, not an isolation boundary for approved daemon-privileged code. */
|
|
80
|
+
export function assertModuleBundle(source: string): void {
|
|
81
|
+
for (const dependency of scanModuleSource(source)) {
|
|
82
|
+
const allowed = dependency.path === ADAPTER_IMPORT || dependency.path !== "bun:test" && dependency.path !== "bun:ffi"
|
|
83
|
+
&& /^(?:node|bun):/.test(dependency.path) && (isBuiltin(dependency.path) || dependency.path === "bun:sqlite");
|
|
84
|
+
if (dependency.kind !== "import-statement" || !allowed) return fail("only static builtin and public adapter imports are supported");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Resolve scanned SDK imports against this running package, never project node_modules or process.cwd(). */
|
|
89
|
+
async function executableBundle(source: string): Promise<Uint8Array> {
|
|
90
|
+
const imports = scanModuleSource(source);
|
|
91
|
+
const compiled = await Bun.build({ entrypoints: ["a4-pin:entry"], target: "bun", format: "esm",
|
|
92
|
+
plugins: [{ name: "host-bound-adapter-imports", setup(build) {
|
|
93
|
+
build.onResolve({ filter: /^a4-pin:/ }, () => ({ path: "entry", namespace: "a4-pin" }));
|
|
94
|
+
build.onLoad({ filter: /.*/, namespace: "a4-pin" }, () => ({ contents: source, loader: "js" }));
|
|
95
|
+
build.onResolve({ filter: /.*/ }, args => {
|
|
96
|
+
if (!imports.some(item => item.path === args.path && item.kind === "import-statement")) return fail("unscanned import refused");
|
|
97
|
+
return { path: args.path === ADAPTER_IMPORT ? new URL("./public.ts", import.meta.url).href : args.path, external: true };
|
|
98
|
+
});
|
|
99
|
+
} }],
|
|
100
|
+
});
|
|
101
|
+
if (!compiled.success || compiled.outputs.length !== 1) return fail("bundle compilation refused (details withheld)");
|
|
102
|
+
return new Uint8Array(await compiled.outputs[0]!.arrayBuffer());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function snapshot(value: unknown, depth = 0): unknown {
|
|
106
|
+
if (depth > 12) return fail("definition nesting exceeds its bound");
|
|
107
|
+
if (typeof value === "function" || value === null || ["undefined", "string", "number", "boolean"].includes(typeof value)) return value;
|
|
108
|
+
if (Array.isArray(value)) return Object.freeze(array(value, 128).map(item => snapshot(item, depth + 1)));
|
|
109
|
+
return Object.freeze(Object.fromEntries(Object.entries(dataObject(value)).map(([key, member]) => [key, snapshot(member, depth + 1)])));
|
|
110
|
+
}
|
|
111
|
+
function only(value: unknown, keys: readonly string[]): Record<string, unknown> {
|
|
112
|
+
const fields = dataObject(value);
|
|
113
|
+
if (Object.keys(fields).some(key => !keys.includes(key))) return fail("field is outside the supported external definition subset");
|
|
114
|
+
return fields;
|
|
115
|
+
}
|
|
116
|
+
export function validateExternalDefinition(raw: unknown): AdapterDefinition {
|
|
117
|
+
const definition = snapshot(raw) as AdapterDefinition;
|
|
118
|
+
if (definition.api !== ADAPTER_API) return fail("adapter API mismatch");
|
|
119
|
+
const reserved = [...ADAPTERS.map(item => item.kind), ...CONFIGURED_ACP_RESERVED_COMMANDS, "user", "hub", "digest", "constructor", "prototype", "__proto__"];
|
|
120
|
+
if (typeof definition.kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(definition.kind) || reserved.includes(definition.kind)
|
|
121
|
+
|| ADAPTERS.some(builtin => definition.kind.startsWith(`${builtin.kind}-`))) return fail("kind is reserved, invalid or collides with a builtin");
|
|
122
|
+
text(definition.displayName, 256);
|
|
123
|
+
if (definition.family !== definition.kind || definition.trust?.onPrem !== false || definition.trust.hubSandboxed !== false
|
|
124
|
+
|| definition.defaultOnRoster !== false || definition.optional !== true) return fail("external kinds must retain their own family and optional off-roster cloud trust");
|
|
125
|
+
if (!Array.isArray(definition.roles) || !definition.roles.length || definition.roles.some(role => !["planner", "implementer", "verifier", "reviewer"].includes(role)) || new Set(definition.roles).size !== definition.roles.length) return fail("unsupported default roles");
|
|
126
|
+
if (definition.instances !== undefined) return fail("external adapter instances are unsupported in A4");
|
|
127
|
+
try { assertNativeCapabilityScope(definition); } catch { return fail("fixed-builtin capability refused"); }
|
|
128
|
+
only(definition, ["api", "kind", "family", "displayName", "transport", "roles", "optional", "defaultOnRoster", "trust", "start", "identity", "permission", "session", "turnEnd", "cli", "display"]);
|
|
129
|
+
only(definition.trust, ["onPrem", "hubSandboxed"]);
|
|
130
|
+
const start = only(definition.start, ["forms", "defaultForm", "daemonPort", "launch", "create", "preflight", "version", "conductorStartable", "hubSwitches", "ownedFlags", "exclusiveStartRefusal"]);
|
|
131
|
+
if (definition.transport !== "acp" || definition.turnEnd !== "adapter" || definition.start.daemonPort !== "acp"
|
|
132
|
+
|| array(definition.start.forms, 1).length !== 1 || definition.start.forms[0] !== "headless" || definition.start.defaultForm !== "headless"
|
|
133
|
+
|| typeof definition.start.conductorStartable !== "boolean" || typeof definition.start.create !== "function" || typeof definition.start.launch !== "function") return fail("A4 requires a headless ACP transport factory and launch plan");
|
|
134
|
+
for (const field of ["preflight", "version"] as const) if (start[field] !== undefined && typeof start[field] !== "function") return fail("invalid start callback");
|
|
135
|
+
if (array(start.hubSwitches, 0).length || array(start.ownedFlags, 0).length) return fail("external launch switches are unsupported");
|
|
136
|
+
if (start.exclusiveStartRefusal !== undefined) text(start.exclusiveStartRefusal, 2048);
|
|
137
|
+
if (only(definition.identity, ["mcp"]).mcp !== "acp-session") return fail("external ACP identity must be session-bound");
|
|
138
|
+
const permission = only(definition.permission, ["genericAcp", "grants", "unmanaged"]);
|
|
139
|
+
if (permission.genericAcp !== undefined) {
|
|
140
|
+
const policy = only(permission.genericAcp, ["modes", "readOnly"]);
|
|
141
|
+
if (policy.modes !== undefined) for (const value of Object.values(only(policy.modes, ["ask", "ask-when-needed", "never-ask"]))) text(value, 256);
|
|
142
|
+
if (policy.readOnly !== undefined) text(policy.readOnly, 256);
|
|
143
|
+
}
|
|
144
|
+
if (permission.grants !== undefined) for (const value of Object.values(only(permission.grants, ["ask", "ask-when-needed", "never-ask"]))) text(value, 2048);
|
|
145
|
+
if (permission.unmanaged !== undefined) text(permission.unmanaged, 2048);
|
|
146
|
+
const session = only(definition.session, ["idField", "currentId", "resumeById", "launchKind"]);
|
|
147
|
+
if (session.idField !== "sessionId" || typeof session.currentId !== "function" || session.resumeById !== undefined && typeof session.resumeById !== "boolean") return fail("invalid external session contract");
|
|
148
|
+
if (session.launchKind !== undefined && session.launchKind !== definition.kind) return fail("external launch kind must match its own kind");
|
|
149
|
+
const cli = only(definition.cli, ["command", "startWords"]);
|
|
150
|
+
if (cli.command !== definition.kind || typeof cli.startWords !== "function") return fail("external command must match its own kind");
|
|
151
|
+
let words: unknown;
|
|
152
|
+
try { words = definition.cli.startWords("headless"); } catch { return fail("command metadata refused (details withheld)"); }
|
|
153
|
+
const commandWords = array(words, 1).map(word => text(word, 64));
|
|
154
|
+
if (commandWords.length !== 1 || commandWords[0] !== definition.kind) return fail("headless command words must name only their bound kind");
|
|
155
|
+
const palette = only(only(definition.display, ["palette"]).palette, ["fg", "bg"]);
|
|
156
|
+
for (const color of [palette.fg, palette.bg]) if (typeof color !== "string" || !/^(?:[a-z]{1,32}|#[a-f0-9]{3,8})$/i.test(color)) return fail("invalid external display color");
|
|
157
|
+
try { assertDefinitionCoherence(definition); } catch { return fail("definition contracts are incoherent"); }
|
|
158
|
+
const invoke = <T>(name: string, callback: () => T): T => {
|
|
159
|
+
try { return callback(); } catch { return fail(`${name} refused (details withheld)`); }
|
|
160
|
+
};
|
|
161
|
+
return snapshot({ ...definition,
|
|
162
|
+
start: { ...definition.start,
|
|
163
|
+
create: (context: Parameters<NonNullable<AdapterDefinition["start"]["create"]>>[0]) => invoke("factory", () => definition.start.create!(context)),
|
|
164
|
+
launch: (context: Parameters<NonNullable<AdapterDefinition["start"]["launch"]>>[0]): LaunchPlan => invoke("launch", () => {
|
|
165
|
+
const plan = only(definition.start.launch!(context), ["cmd", "args", "metadata"]);
|
|
166
|
+
const cmd = text(plan.cmd, 4096), args = array(plan.args, 128).map(arg => text(arg, 4096));
|
|
167
|
+
const metadata = only(plan.metadata, ["packageEntrypoint", "command", "argv", "env"]);
|
|
168
|
+
text(metadata.packageEntrypoint, 4096); text(metadata.command, 16384);
|
|
169
|
+
array(metadata.argv, 129).forEach(arg => text(arg, 4096));
|
|
170
|
+
if (Object.keys(dataObject(metadata.env)).length) return fail("external native-home overrides are unsupported");
|
|
171
|
+
return snapshot({ cmd, args, metadata }) as LaunchPlan;
|
|
172
|
+
}),
|
|
173
|
+
...(definition.start.preflight ? { preflight: (context: Parameters<NonNullable<AdapterDefinition["start"]["preflight"]>>[0]) => {
|
|
174
|
+
try { if (definition.start.preflight!(context) === undefined) return undefined; } catch { /* Never forward module-authored exception/refusal bodies. */ }
|
|
175
|
+
return "external adapter preflight refused (details withheld)";
|
|
176
|
+
} } : {}),
|
|
177
|
+
...(definition.start.version ? { version: (context: Parameters<NonNullable<AdapterDefinition["start"]["version"]>>[0]) => {
|
|
178
|
+
try { if (definition.start.version!(context) === undefined) return undefined; } catch { /* Unknown is a refusal for an external native version check. */ }
|
|
179
|
+
return "external adapter version refused (details withheld)";
|
|
180
|
+
} } : {}),
|
|
181
|
+
},
|
|
182
|
+
session: { ...definition.session,
|
|
183
|
+
// ponytail: A4 has no verified controlled-recovery driver; bind native session/group restoration receipts before enabling it.
|
|
184
|
+
recoveryRefusal: () => "external module has no verified controlled-recovery driver; stop it explicitly and restart with its recorded native session id",
|
|
185
|
+
currentId: (peer: Parameters<AdapterDefinition["session"]["currentId"]>[0]) => {
|
|
186
|
+
try {
|
|
187
|
+
const value = definition.session.currentId(peer);
|
|
188
|
+
return typeof value === "string" && value.trim() && Buffer.byteLength(value) <= 4096 && !/[\p{Cc}\p{Cf}]/u.test(value) ? value : undefined;
|
|
189
|
+
} catch { return undefined; }
|
|
190
|
+
} },
|
|
191
|
+
// UI polls and CLI metadata never execute plugin code to render a headless command.
|
|
192
|
+
cli: { command: definition.kind, startWords: () => [definition.kind] },
|
|
193
|
+
}) as AdapterDefinition;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const MODULE_IMPORT_TIMEOUT_MS = 2000;
|
|
197
|
+
async function importVerifiedModule(executable: Uint8Array): Promise<unknown> {
|
|
198
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
199
|
+
try {
|
|
200
|
+
// ponytail: a deadline stops waiting, not privileged module side effects; cancellation needs an isolated host-port engine.
|
|
201
|
+
return await Promise.race([
|
|
202
|
+
import(`data:text/javascript;nonce=${randomUUID()};base64,${Buffer.from(executable).toString("base64")}`),
|
|
203
|
+
new Promise<never>((_resolve, reject) => { timer = setTimeout(() => reject(new ExternalAdapterRefusal("external adapter: module import timed out")), MODULE_IMPORT_TIMEOUT_MS); }),
|
|
204
|
+
]);
|
|
205
|
+
} finally { clearTimeout(timer); }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Only daemon startup calls this. Hash-checked bytes, not the mutable source path, reach import(). */
|
|
209
|
+
export interface ExternalAdapterRefusalRecord { readonly index: number; readonly reason: string }
|
|
210
|
+
export async function loadExternalAdapterDefinitions(cwd: string, layers: readonly ModuleConfigLayer[], onRefusal?: (refusal: ExternalAdapterRefusalRecord) => void): Promise<readonly AdapterDefinition[]> {
|
|
211
|
+
const { modules } = readAdapterModuleDeclarations(cwd, layers);
|
|
212
|
+
if (!modules.length) return Object.freeze([]);
|
|
213
|
+
let rawAdapters: unknown;
|
|
214
|
+
for (const { name, raw } of layers) if (name === "config.local.json" && !configRefusal(cwd, name) && raw.adapters !== undefined) rawAdapters = raw.adapters;
|
|
215
|
+
const existingKinds = new Set(createProjectAdapterCatalog(rawAdapters).definitions.map(definition => definition.kind));
|
|
216
|
+
const definitions: AdapterDefinition[] = [];
|
|
217
|
+
for (const [index, declaration] of modules.entries()) {
|
|
218
|
+
try {
|
|
219
|
+
if (configRefusal(cwd, "config.local.json")) return fail("module configuration provenance changed");
|
|
220
|
+
const local = regularPath(realPath(cwd), join(realPath(cwd), ".agenthub", "config.local.json"));
|
|
221
|
+
if ((local.mode & 0o002) !== 0) return fail("world-writable module configuration refused");
|
|
222
|
+
const bytes = captureBundle(cwd, declaration);
|
|
223
|
+
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
224
|
+
assertModuleBundle(source);
|
|
225
|
+
const executable = await executableBundle(source);
|
|
226
|
+
if (digest(bytes) !== declaration.sha256 || configRefusal(cwd, "config.local.json")) return fail("module pin or provenance changed before import");
|
|
227
|
+
const imported = await importVerifiedModule(executable);
|
|
228
|
+
const definition = validateExternalDefinition((imported as { definition?: unknown }).definition);
|
|
229
|
+
if ([...existingKinds].some(kind => definition.kind === kind || definition.kind.startsWith(`${kind}-`))) return fail("kind collides with a configured adapter namespace");
|
|
230
|
+
if (definitions.some(item => item.kind === definition.kind)) return fail("duplicate external kind");
|
|
231
|
+
definitions.push(definition);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const refusal = error instanceof ExternalAdapterRefusal ? error : new ExternalAdapterRefusal("external adapter: module could not be verified or loaded (details withheld)");
|
|
234
|
+
if (!onRefusal) throw refusal;
|
|
235
|
+
onRefusal(Object.freeze({ index, reason: refusal.message }));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return Object.freeze(definitions);
|
|
239
|
+
}
|
|
240
|
+
|
package/src/adapters/pi.ts
CHANGED
|
@@ -157,6 +157,8 @@ export class PiPeer extends BasePeer {
|
|
|
157
157
|
get sessionGeneration(): string | undefined {
|
|
158
158
|
return this.ownerClaimed && this.ownerPid && this.ownerSignature ? `${this.ownerPid}:${this.ownerSignature}:${this.sessionId ?? ""}` : undefined;
|
|
159
159
|
}
|
|
160
|
+
// ponytail: retain Pi's owner/session-only checkpoint fence in this slice; a later native-proof port can strengthen it.
|
|
161
|
+
readonly checkpointUsesSessionGeneration = false;
|
|
160
162
|
|
|
161
163
|
recoveryMetadata(): Record<string, unknown> {
|
|
162
164
|
const exists = !!this.sessionFile && existsSync(this.sessionFile);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { ADAPTER_API } from "./adapter-api.ts";
|
|
2
|
+
export { AcpPeer } from "./acp.ts";
|
|
3
|
+
export type { AcpOptions, PermissionRequest, PermissionOption } from "./acp.ts";
|
|
4
|
+
export { BasePeer } from "../hub/peers.ts";
|
|
5
|
+
export type { PeerAdapter, DeliveryReceipt } from "../hub/peers.ts";
|
|
6
|
+
export type { Envelope, PeerId, PeerState } from "../hub/envelope.ts";
|
|
7
|
+
export type * from "./registry-types.ts";
|
|
@@ -16,6 +16,9 @@ import type { LocalOptions } from "./local-worker.ts";
|
|
|
16
16
|
import type { PiOptions } from "./pi.ts";
|
|
17
17
|
import type { ClaudePrintOptions } from "./claude-print.ts";
|
|
18
18
|
|
|
19
|
+
import type { BuiltinCliEntry } from "./definitions/native-cli-entry-types.ts";
|
|
20
|
+
import type { NativePreviewBuilder } from "./definitions/native-cli-preview-common.ts";
|
|
21
|
+
|
|
19
22
|
export type StartForm = "tui" | "headless";
|
|
20
23
|
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print";
|
|
21
24
|
|
|
@@ -272,8 +275,14 @@ export interface AdapterDefinition {
|
|
|
272
275
|
validateEffective?(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void;
|
|
273
276
|
};
|
|
274
277
|
capabilities?: { manifest?(peer: PeerAdapter): Promise<CapabilityManifest>; watch?: boolean };
|
|
278
|
+
/** Stable host projection order and planner eligibility, independent of default task roles. */
|
|
279
|
+
projection?: { roleRank?: number; startRank?: number; helpRank?: number; plannerEligible?: boolean };
|
|
275
280
|
cli: {
|
|
276
281
|
command: string;
|
|
282
|
+
/** Fixed builtin CLI ports; project definitions cannot supply these callbacks. */
|
|
283
|
+
entry?: Readonly<Omit<BuiltinCliEntry, "command">>;
|
|
284
|
+
preview?: NativePreviewBuilder;
|
|
285
|
+
usage?: readonly (readonly [usage: string, description: string])[];
|
|
277
286
|
/** Configuration leaves that select this peer's next native launch command or provider profile. */
|
|
278
287
|
launchConfigKeys?: readonly string[];
|
|
279
288
|
/** Restrictions only; core identity remains authoritative. */
|
|
@@ -299,6 +308,7 @@ export type KindMap = Readonly<Record<string, { readonly kind: string; readonly
|
|
|
299
308
|
/** Immutable per-project resolver; exported globals remain builtin-only. */
|
|
300
309
|
export interface ProjectAdapterCatalog {
|
|
301
310
|
readonly definitions: readonly AdapterDefinition[];
|
|
311
|
+
readonly externalDefinitions: readonly AdapterDefinition[];
|
|
302
312
|
definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined;
|
|
303
313
|
kindOf(id: string, instances?: KindMap): string | undefined;
|
|
304
314
|
familyOf(id: string, instances?: KindMap): string | undefined;
|
package/src/adapters/registry.ts
CHANGED
|
@@ -11,14 +11,9 @@ import type { AdapterDefinition, KindMap, ProjectAdapterCatalog } from "./regist
|
|
|
11
11
|
import { types } from "node:util";
|
|
12
12
|
|
|
13
13
|
export type * from "./registry-types.ts";
|
|
14
|
-
|
|
14
|
+
import { ADAPTER_API } from "./adapter-api.ts";
|
|
15
|
+
export { ADAPTER_API };
|
|
15
16
|
|
|
16
|
-
/** Ordered compatibility projections stay typed independently of definition implementation inference. */
|
|
17
|
-
export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
|
|
18
|
-
export const OPTIONAL_PEERS = ["qwen", "jcode", "agy"] as const;
|
|
19
|
-
export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
|
|
20
|
-
export const START_MODE_PEERS = ["pi", "codex", "kimi", "jcode"] as const;
|
|
21
|
-
export const OFFICE_PEER_ORDER: readonly string[] = KNOWN_PEERS;
|
|
22
17
|
|
|
23
18
|
|
|
24
19
|
function immutable<T>(value: T, seen = new WeakSet<object>()): T {
|
|
@@ -38,6 +33,31 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
|
|
|
38
33
|
...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind === "jcode"),
|
|
39
34
|
AGY_DEFINITION,
|
|
40
35
|
]);
|
|
36
|
+
export interface AdapterLists {
|
|
37
|
+
readonly KNOWN_PEERS: readonly string[]; readonly OPTIONAL_PEERS: readonly string[]; readonly SUPPORTED_PEERS: readonly string[];
|
|
38
|
+
readonly START_MODE_PEERS: readonly string[]; readonly ROLE_ORDER: readonly string[]; readonly HELP_ORDER: readonly string[]; readonly PLANNER_PEERS: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
/** Pure builtin projections. Unranked definitions retain their source order after ranked definitions. */
|
|
41
|
+
export function deriveAdapterLists(definitions: readonly AdapterDefinition[]): AdapterLists {
|
|
42
|
+
const ordered = (field: "roleRank" | "startRank" | "helpRank", eligible = (_definition: AdapterDefinition) => true) => definitions
|
|
43
|
+
.map((definition, index) => ({ definition, index })).filter(({ definition }) => eligible(definition))
|
|
44
|
+
.sort((left, right) => {
|
|
45
|
+
const a = left.definition.projection?.[field], b = right.definition.projection?.[field];
|
|
46
|
+
if (a === undefined) return b === undefined ? left.index - right.index : 1;
|
|
47
|
+
if (b === undefined) return -1;
|
|
48
|
+
return a - b || left.index - right.index;
|
|
49
|
+
}).map(({ definition }) => definition.kind);
|
|
50
|
+
const KNOWN_PEERS = definitions.filter(definition => !definition.optional).map(definition => definition.kind);
|
|
51
|
+
const OPTIONAL_PEERS = definitions.filter(definition => definition.optional).map(definition => definition.kind);
|
|
52
|
+
return immutable({ KNOWN_PEERS, OPTIONAL_PEERS, SUPPORTED_PEERS: [...KNOWN_PEERS, ...OPTIONAL_PEERS],
|
|
53
|
+
START_MODE_PEERS: ordered("startRank", definition => definition.start.forms.includes("tui") && definition.start.forms.includes("headless")),
|
|
54
|
+
ROLE_ORDER: ordered("roleRank"), HELP_ORDER: ordered("helpRank"),
|
|
55
|
+
PLANNER_PEERS: definitions.filter(definition => definition.projection?.plannerEligible === true).map(definition => definition.kind),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export const { KNOWN_PEERS, OPTIONAL_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, ROLE_ORDER, PLANNER_PEERS } = deriveAdapterLists(ADAPTERS);
|
|
59
|
+
export const OFFICE_PEER_ORDER: readonly string[] = KNOWN_PEERS;
|
|
60
|
+
|
|
41
61
|
// Unavailable declarations are metadata, not supported native peers or launchers.
|
|
42
62
|
const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([]);
|
|
43
63
|
const definitions = new Map<string, AdapterDefinition>();
|
|
@@ -101,14 +121,21 @@ export function familyOf(id: string, instances?: KindMap): string | undefined {
|
|
|
101
121
|
}
|
|
102
122
|
|
|
103
123
|
/** Compile one captured, host-trusted declaration set; never register native policy globally. */
|
|
104
|
-
export function createProjectAdapterCatalog(rawAdapters?: unknown): ProjectAdapterCatalog {
|
|
124
|
+
export function createProjectAdapterCatalog(rawAdapters?: unknown, externalDefinitions: readonly AdapterDefinition[] = []): ProjectAdapterCatalog {
|
|
105
125
|
const configured = configuredAcpDefinitions(rawAdapters, [...definitions.keys()]);
|
|
106
126
|
for (const definition of configured) { assertDefinitionCoherence(definition); assertNativeCapabilityScope(definition); }
|
|
107
|
-
const
|
|
108
|
-
|
|
127
|
+
const seen = new Set([...definitions.keys(), ...configured.map(item => item.kind)]);
|
|
128
|
+
for (const definition of externalDefinitions) {
|
|
129
|
+
if (definition.api !== ADAPTER_API || seen.has(definition.kind)) throw new Error("external adapter kind or API collision");
|
|
130
|
+
assertDefinitionCoherence(definition); assertNativeCapabilityScope(definition);
|
|
131
|
+
seen.add(definition.kind);
|
|
132
|
+
}
|
|
133
|
+
const external = immutable([...externalDefinitions]);
|
|
134
|
+
const entries = Object.freeze([...ADAPTERS, ...configured, ...external]);
|
|
135
|
+
const catalog = new Map([...definitions, ...[...configured, ...external].map(definition => [definition.kind, definition] as const)]);
|
|
109
136
|
const kindOf = (id: string, instances?: KindMap): string | undefined => catalog.has(id) ? id : typeof id === "string" ? declaredKind(id, instances, catalog) : undefined;
|
|
110
137
|
const definitionOf = (id: string, instances?: KindMap): AdapterDefinition | undefined => resolvedDefinition(id, kindOf(id, instances), instances, catalog);
|
|
111
|
-
return Object.freeze({ definitions: entries, kindOf, definitionOf,
|
|
138
|
+
return Object.freeze({ definitions: entries, externalDefinitions: external, kindOf, definitionOf,
|
|
112
139
|
familyOf: (id: string, instances?: KindMap) => definitionOf(id, instances)?.family,
|
|
113
140
|
isSupportedPeer: (id: unknown, instances?: KindMap): id is string => {
|
|
114
141
|
if (typeof id !== "string") return false;
|
|
@@ -119,8 +146,7 @@ export function createProjectAdapterCatalog(rawAdapters?: unknown): ProjectAdapt
|
|
|
119
146
|
}
|
|
120
147
|
|
|
121
148
|
/** Compatibility projection order is observable in default role maps and planner selection. */
|
|
122
|
-
|
|
123
|
-
export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
|
|
149
|
+
|
|
124
150
|
|
|
125
151
|
/** Callers own their role arrays, independent of immutable native policy. */
|
|
126
152
|
export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
|
|
@@ -159,6 +185,12 @@ export function assertDefinitionCoherence(definition: AdapterDefinition): void {
|
|
|
159
185
|
if (definition.turnEnd === "native-hook" && definition.transport !== "claude-channel" && !definition.hooks?.factsHook) {
|
|
160
186
|
fail("turnEnd", "native-hook requires the Claude transport or a native factsHook");
|
|
161
187
|
}
|
|
188
|
+
const projection = definition.projection;
|
|
189
|
+
if (projection !== undefined) {
|
|
190
|
+
if (!projection || typeof projection !== "object" || Array.isArray(projection) || Object.keys(projection).some(key => !["roleRank", "startRank", "helpRank", "plannerEligible"].includes(key))) fail("projection", "invalid projection metadata");
|
|
191
|
+
for (const field of ["roleRank", "startRank", "helpRank"] as const) if (projection[field] !== undefined && (typeof projection[field] !== "number" || !Number.isFinite(projection[field]))) fail(`projection.${field}`, "rank must be finite");
|
|
192
|
+
if (projection.plannerEligible !== undefined && typeof projection.plannerEligible !== "boolean") fail("projection.plannerEligible", "boolean required");
|
|
193
|
+
}
|
|
162
194
|
const attach = definition.attach, pullOnly = attach?.pullOnlyForms;
|
|
163
195
|
if (pullOnly !== undefined) {
|
|
164
196
|
if (!Array.isArray(pullOnly) || pullOnly.some(form => !definition.start.forms.includes(form))) fail("attach.pullOnlyForms", "each pull-only form must be a supported start form");
|
|
@@ -199,9 +231,13 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
199
231
|
["session.crashIdentity", declared(definition.session, "crashIdentity")],
|
|
200
232
|
["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
|
|
201
233
|
["routing", definition.routing !== undefined],
|
|
234
|
+
["projection", definition.projection !== undefined],
|
|
202
235
|
["reviewerVariant", definition.reviewerVariant !== undefined],
|
|
203
236
|
["reviewer", definition.reviewer !== undefined],
|
|
204
237
|
["hooks.factsHook", declared(definition.hooks, "factsHook")],
|
|
238
|
+
["cli.entry", declared(definition.cli, "entry")],
|
|
239
|
+
["cli.preview", declared(definition.cli, "preview")],
|
|
240
|
+
["cli.usage", declared(definition.cli, "usage")],
|
|
205
241
|
];
|
|
206
242
|
const field = restricted.find(([, present]) => present)?.[0];
|
|
207
243
|
if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
|
|
@@ -236,8 +272,8 @@ export function builtinPreviewOf(id: string) {
|
|
|
236
272
|
/** Fixed builtin doctor metadata and pure observed-value projections; the CLI retains all probe effects. */
|
|
237
273
|
export { DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "./definitions/native-cli-doctor.ts";
|
|
238
274
|
|
|
239
|
-
/**
|
|
240
|
-
export const BUILTIN_CLI_ENTRIES = Object.freeze(builtinCliEntries.map(entry => {
|
|
275
|
+
/** Definition-owned CLI entries are restricted to fixed builtins, never config or external execution extensions. */
|
|
276
|
+
export const BUILTIN_CLI_ENTRIES = Object.freeze(builtinCliEntries(ADAPTERS).map(entry => {
|
|
241
277
|
const definition = definitionOf(entry.command);
|
|
242
278
|
if (!definition) throw new Error("builtin CLI entry has no adapter definition");
|
|
243
279
|
return Object.freeze({ ...entry, definition });
|
package/src/cli/help.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ADAPTERS, deriveAdapterLists, type AdapterDefinition } from "../adapters/registry.ts";
|
|
1
2
|
import { VERSION } from "../version.ts";
|
|
2
3
|
import { paint, wrap, type Span } from "./console-state.ts";
|
|
3
4
|
|
|
@@ -26,9 +27,10 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
26
27
|
["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only)"],
|
|
27
28
|
["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
|
|
28
29
|
["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
|
|
29
|
-
["ahub sdlc
|
|
30
|
+
["ahub sdlc accept-departure <task> <head> <reason...>", "accept current plan departures on that exact PR head (person only)"],
|
|
31
|
+
["ahub sdlc plan-review <task> <revision> <reason...> [--review full|delta|none --reviewers N] [--ci full|focused|reuse]", "record planner review, scope and host-read issue metadata"],
|
|
30
32
|
["ahub sdlc train report|configure|cut <release-id> [revision] [json]", "plan a release and freeze its approved scope after resource/quota admission"],
|
|
31
|
-
["ahub sdlc release-assign <task> <revision> <release-id|none> <reason
|
|
33
|
+
["ahub sdlc release-assign <task> <revision> <release-id|none> <reason...>", "assign approved/in-flight work without changing execution"],
|
|
32
34
|
["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
|
|
33
35
|
["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
|
|
34
36
|
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
|
|
@@ -49,15 +51,6 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
49
51
|
]],
|
|
50
52
|
["Agents", [
|
|
51
53
|
["ahub <configured-kind> [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]", "start a trusted config.local.json ACP kind (default off roster); native argv is withheld in previews"],
|
|
52
|
-
["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
|
|
53
|
-
["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
|
|
54
|
-
["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"],
|
|
55
|
-
["ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional Jcode (roster.jcode, default off): its TUI on the hub-owned ACP session, opened once that session is saved; --mode headless for ACP only; native policy unmanaged, no hub sandbox"],
|
|
56
|
-
["ahub agy [--print-command | --dry-run] [args...]", "optional Antigravity CLI 1.4.x TUI (1.4.2 verified) in this terminal (roster.agy, default off): pull-only via hub_inbox, implementer/verifier only; native permissions unmanaged, no hub sandbox"],
|
|
57
|
-
["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]", "start optional Qwen Code 0.25.0 ACP (roster.qwen, default off)"],
|
|
58
|
-
["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"],
|
|
59
|
-
["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
|
|
60
|
-
["ahub claude|codex|kimi|qwen|pi|local --as <instance> [options...]", "start an extra instance of that kind declared under instances in .agenthub/config.local.json"],
|
|
61
54
|
["ahub role list [--group <group>] | show <id>", "read bounded catalog guidance with layer and content hash"],
|
|
62
55
|
["ahub role assign <peer> <id> | clear <peer> [<id>]", "person only: add or clear catalog guidance, applied at the next peer start"],
|
|
63
56
|
["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
|
|
@@ -71,7 +64,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
71
64
|
["ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]", "human only: inspect or set a running peer mode; never-ask requires --yes; config never-ask waits for console y"],
|
|
72
65
|
["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
|
|
73
66
|
["ahub roster [--json] [--color=auto|always|never]", "human only: every known peer, participation, start mode and retained work"],
|
|
74
|
-
["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff"],
|
|
67
|
+
["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff; remove also deletes the roster key of an instance no longer declared"],
|
|
75
68
|
["ahub settings [list] [--json] | get <key> [--json]", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
|
|
76
69
|
["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "human only: change or reload validated settings, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
|
|
77
70
|
["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
|
|
@@ -94,7 +87,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
94
87
|
["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
|
|
95
88
|
["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
|
|
96
89
|
["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned [--created <time>]", "your later verdict on an approved task, kept in the research records (research must be on)"],
|
|
97
|
-
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>]", "give a review verdict on a task"],
|
|
90
|
+
["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>]", "give a review verdict on a task"],
|
|
98
91
|
["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
|
|
99
92
|
["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
|
|
100
93
|
["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
|
|
@@ -145,7 +138,18 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
145
138
|
["ahub manager", "internal: the dashboard manager process that ahub ui --all starts"],
|
|
146
139
|
]],
|
|
147
140
|
];
|
|
148
|
-
export
|
|
141
|
+
export function helpEntries(definitions: readonly AdapterDefinition[] = ADAPTERS): readonly HelpEntry[] {
|
|
142
|
+
const byKind = new Map(definitions.map(definition => [definition.kind, definition]));
|
|
143
|
+
const order = deriveAdapterLists(definitions).HELP_ORDER;
|
|
144
|
+
const native = order.flatMap(kind => byKind.get(kind)?.cli.usage ?? []);
|
|
145
|
+
const instanceKinds = order.filter(kind => byKind.get(kind)?.instances !== undefined);
|
|
146
|
+
const instances: readonly [string, string] = [`ahub ${instanceKinds.join("|")} --as <instance> [options...]`, "start an extra instance of that kind declared under instances in .agenthub/config.local.json"];
|
|
147
|
+
return SECTIONS.flatMap(([section, rows]) => {
|
|
148
|
+
const entries: readonly (readonly [string, string])[] = section === "Agents" ? [rows[0]!, ...native, ...(instanceKinds.length ? [instances] : []), ...rows.slice(1)] : rows;
|
|
149
|
+
return entries.map(([usage, description]) => ({ section, usage, description }));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export const HELP = helpEntries();
|
|
149
153
|
|
|
150
154
|
const COMMAND = /^ahub (-{0,2}[a-z][a-z-]*(?: ?\| ?-{0,2}[a-z][a-z-]*)*)/;
|
|
151
155
|
/** The command words a usage documents: `ahub pause|resume <peer>` documents pause and resume. */
|
|
@@ -159,9 +163,10 @@ const DESCRIPTION = 34;
|
|
|
159
163
|
* (80 to 100 columns). A usage too long for that column puts its description on the next line.
|
|
160
164
|
* With a command, only its entries; an unknown command renders nothing.
|
|
161
165
|
*/
|
|
162
|
-
export function renderHelp(columns: number, color: boolean, command?: string): string {
|
|
166
|
+
export function renderHelp(columns: number, color: boolean, command?: string, definitions?: readonly AdapterDefinition[]): string {
|
|
163
167
|
const width = Math.min(100, Math.max(80, columns || 80));
|
|
164
|
-
const
|
|
168
|
+
const model = definitions === undefined ? HELP : helpEntries(definitions);
|
|
169
|
+
const entries = command === undefined ? model : model.filter(entry => helpCommands(entry).includes(command));
|
|
165
170
|
const lines: Span[][] = command === undefined ? wrap(`agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory`, width, 0).map(text => [{ text }]) : [];
|
|
166
171
|
let section = "";
|
|
167
172
|
for (const entry of entries) {
|
package/src/cli/identity.ts
CHANGED
|
@@ -21,7 +21,7 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
|
|
|
21
21
|
|| (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
|
|
22
22
|
|| (command === "memory" && sub !== "graph")
|
|
23
23
|
|| (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
|
|
24
|
-
|| (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept"))
|
|
24
|
+
|| (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept" || args[0] === "accept-departure"))
|
|
25
25
|
|| (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet";
|
|
26
26
|
}
|
|
27
27
|
|
package/src/cli/main.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
|
|
|
3
3
|
import { effectiveInstanceRoles } from "../hub/instances.ts";
|
|
4
4
|
import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
|
|
5
5
|
import { readInstanceRoleConfig, readInstanceConfig, type InstanceRoleConfig } from "../hub/instance-config.ts";
|
|
6
|
+
import { staleInstanceKey } from "../hub/roster.ts";
|
|
6
7
|
import { parseConfiguredAcpArguments, runConfiguredAcpEntry, requireActiveConfiguredAcp } from "./configured-acp-entry.ts";
|
|
7
8
|
import type { NativeCliEntryContext } from "../adapters/definitions/native-cli-entry-types.ts";
|
|
8
9
|
import { BUILTIN_CLI_ENTRIES, createProjectAdapterCatalog, definitionOf, DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "../adapters/registry.ts";
|
|
@@ -1230,6 +1231,17 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1230
1231
|
if (!flags.rest.length || (scope === undefined) !== (reviewers === undefined)) fail("plan-review needs a reason and both --review and --reviewers when supplying review scope");
|
|
1231
1232
|
return console.log(await taskOp("hub_plan_review", { id, revision, reason: flags.rest.join(" "), ...(scope !== undefined ? { review: { scope, reviewers: Number(reviewers) } } : {}), ...(flags.one["--ci"] !== undefined ? { ci: flags.one["--ci"] } : {}) }));
|
|
1232
1233
|
}
|
|
1234
|
+
if (sub === "accept-departure") {
|
|
1235
|
+
const head = args[2], rawReason = args.slice(3).join(" "), reason = rawReason.trim();
|
|
1236
|
+
if (!Number.isSafeInteger(id) || id! <= 0 || typeof head !== "string" || !/^[a-f0-9]{40}$/.test(head) || !reason || rawReason.length > 2048) fail("usage: ahub sdlc accept-departure <task> <head> <reason...>");
|
|
1237
|
+
const hub = await connect();
|
|
1238
|
+
try {
|
|
1239
|
+
const reply = await hub.request({ t: "sdlc", op: "accept-departure", id, head, reason }, 30_000);
|
|
1240
|
+
if (!reply.ok) fail(reply.error);
|
|
1241
|
+
console.log(`task #${id}: plan departure accepted for the reviewed head`);
|
|
1242
|
+
} finally { hub.close(); }
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1233
1245
|
if (sub === "accept") {
|
|
1234
1246
|
// #481 S1: the person accepts the task's spec issue at its current body; agent shells are refused before this runs.
|
|
1235
1247
|
const flags = takeFlags(args.slice(2), ["--issue", "--repo"], []);
|
|
@@ -1315,10 +1327,15 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1315
1327
|
if (args.length !== 2 || !/^\d+$/.test(args[1]!)) fail("usage: ahub review brief <task id>");
|
|
1316
1328
|
return console.log(await taskOp("review_brief", { id: Number(args[1]) }));
|
|
1317
1329
|
}
|
|
1318
|
-
const { one, many, rest } = takeFlags(args, ["--head"], ["--unmet"]);
|
|
1330
|
+
const { one, many, rest } = takeFlags(args, ["--head", "--findings"], ["--unmet"]);
|
|
1319
1331
|
const [id, verdict, ...note] = rest;
|
|
1320
|
-
if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... | ahub review brief <id>");
|
|
1321
|
-
|
|
1332
|
+
if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>] | ahub review brief <id>");
|
|
1333
|
+
let findings: unknown;
|
|
1334
|
+
if (one["--findings"] !== undefined) {
|
|
1335
|
+
try { findings = JSON.parse(one["--findings"]); } catch { fail("--findings must be a JSON array"); }
|
|
1336
|
+
if (!Array.isArray(findings)) fail("--findings must be a JSON array");
|
|
1337
|
+
}
|
|
1338
|
+
console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(findings === undefined ? {} : { findings }), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
|
|
1322
1339
|
},
|
|
1323
1340
|
|
|
1324
1341
|
remember: async () => console.log(await taskOp("hub_remember", { text: freeText(args, "ahub remember <text>") })),
|
|
@@ -1390,7 +1407,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1390
1407
|
roster: async () => {
|
|
1391
1408
|
const options = outputOptions(), flags = args.filter(arg => arg.startsWith("--")), positional = outputArguments().filter(arg => arg !== "--yes" && arg !== "--now");
|
|
1392
1409
|
const [sub = "list", peer, ...extra] = positional;
|
|
1393
|
-
if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !peerDefinition(peer)) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
|
|
1410
|
+
if (!["list", "add", "remove"].includes(sub) || extra.length || (sub === "list" ? peer !== undefined : !peerDefinition(peer) && !(sub === "remove" && typeof peer === "string" && staleInstanceKey(peer, projectCatalog))) || flags.some(flag => !["--json", "--full", "--yes", "--now"].includes(flag) && !flag.startsWith("--color=")) || (sub !== "list" && options.json) || (sub !== "remove" && flags.some(flag => ["--yes", "--now"].includes(flag)))) fail("usage: ahub roster [--json] | add <peer> | remove <peer> [--yes] [--now]");
|
|
1394
1411
|
const hub = await connect();
|
|
1395
1412
|
try {
|
|
1396
1413
|
requireNamedPeerFeature(hub, peer);
|
|
@@ -1400,7 +1417,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1400
1417
|
if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
|
|
1401
1418
|
return options.json ? console.log(JSON.stringify(view.roster, null, 2)) : printOutput(renderRoster(view.roster, options.columns), options.color);
|
|
1402
1419
|
}
|
|
1403
|
-
|
|
1420
|
+
// A key whose instance is no longer declared is deleted (null), never set to false: there is no peer left for it to switch off (#225).
|
|
1421
|
+
const key = `roster.${peer}`, stale = sub === "remove" && !peerDefinition(peer) && staleInstanceKey(peer!, projectCatalog), value = stale ? null : sub === "add";
|
|
1404
1422
|
const preview = await request({ t: "settings_preview", key, value });
|
|
1405
1423
|
printOutput((preview.lines ?? []).map((text: string) => [{ text }]), options.color);
|
|
1406
1424
|
if (sub === "remove" && !flags.includes("--yes")) {
|
|
@@ -20,7 +20,7 @@ export function operationLines(op: RecoveryOperation, runner: number | "unknown"
|
|
|
20
20
|
const held = runner === "unknown" ? "; whether a runner holds it cannot be read" : runner ? `; runner ${runner} is working` : op.phase === "running" ? "; no runner holds it (the last one stopped mid-step)" : "";
|
|
21
21
|
const lines = [`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, ` ${op.phase} at "${stepLabel(op.step)}", updated ${ago(now - op.updatedAt)} ago${held}`];
|
|
22
22
|
for (const p of op.projects) {
|
|
23
|
-
const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value)}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
|
|
23
|
+
const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value, { authority: "historical" })}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
|
|
24
24
|
lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
|
|
25
25
|
}
|
|
26
26
|
// The error ends with the commands status would print; the menu below offers them instead.
|
|
@@ -76,7 +76,7 @@ export function renderOperation(op: RecoveryOperation, runner: number | "unknown
|
|
|
76
76
|
if (key.startsWith("restored:")) {
|
|
77
77
|
let outcome = "recorded outcome unavailable";
|
|
78
78
|
try {
|
|
79
|
-
const state = restoreReceiptStatus(value);
|
|
79
|
+
const state = restoreReceiptStatus(value, { authority: "historical" }); // display only: a revoked declaration must not hide the receipt (#225)
|
|
80
80
|
outcome = state === "done" ? "session restored" : state === "failed" ? "session restoration needs attention" : state.startsWith("waiting:") ? "waiting for the recorded session to attach" : "session restoration is pending";
|
|
81
81
|
} catch { /* Malformed historical metadata is unknown, never successful. */ }
|
|
82
82
|
output.push(...lines(` ${key.slice("restored:".length)}: ${outcome}`, "muted", options));
|