@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.
Files changed (137) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +9 -0
  6. package/docs/agent-notes/adapters.md +58 -44
  7. package/docs/agent-notes/budget.md +1 -1
  8. package/docs/agent-notes/daemon.md +9 -7
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +8 -5
  11. package/docs/agent-notes/tests.md +7 -7
  12. package/docs/operations.md +99 -7
  13. package/docs/quickstart.md +5 -1
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +6 -3
  16. package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
  17. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  18. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  19. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  20. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  21. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  22. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  23. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  24. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  25. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  27. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  28. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  29. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  30. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  35. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  36. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  38. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  39. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  43. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  44. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  47. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  56. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  57. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  59. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  60. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  68. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  70. package/docs/verified.json +19 -16
  71. package/package.json +5 -1
  72. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  73. package/plugins/agent-hub/server.js +9818 -9605
  74. package/src/adapters/adapter-api.ts +2 -0
  75. package/src/adapters/agy/definition.ts +3 -1
  76. package/src/adapters/claude-channel.ts +14 -5
  77. package/src/adapters/codex-appserver.ts +1 -0
  78. package/src/adapters/conformance/acp.ts +114 -0
  79. package/src/adapters/conformance/codex.ts +191 -0
  80. package/src/adapters/conformance/enrollment.ts +43 -0
  81. package/src/adapters/conformance/factories.ts +17 -0
  82. package/src/adapters/conformance/fake-paths.ts +12 -0
  83. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  84. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  85. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  86. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  87. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  88. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  89. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  90. package/src/adapters/conformance/index.ts +11 -0
  91. package/src/adapters/conformance/kit.ts +50 -0
  92. package/src/adapters/conformance/local-pi.ts +100 -0
  93. package/src/adapters/conformance/owned-acp.ts +44 -0
  94. package/src/adapters/definitions/builtin-metadata.ts +23 -6
  95. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  96. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  97. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  98. package/src/adapters/definitions/qwen.ts +4 -1
  99. package/src/adapters/external-metadata.ts +48 -0
  100. package/src/adapters/external-module-scan.ts +126 -0
  101. package/src/adapters/external-modules.ts +240 -0
  102. package/src/adapters/pi.ts +2 -0
  103. package/src/adapters/public.ts +7 -0
  104. package/src/adapters/registry-types.ts +10 -0
  105. package/src/adapters/registry.ts +51 -15
  106. package/src/cli/help.ts +21 -16
  107. package/src/cli/identity.ts +1 -1
  108. package/src/cli/main.ts +23 -5
  109. package/src/cli/upgrade-labels.ts +1 -1
  110. package/src/cli/upgrade-presentation.ts +1 -1
  111. package/src/cli/upgrade-restore-state.ts +3 -2
  112. package/src/cli/upgrade-runtime.ts +4 -1
  113. package/src/hub/board.ts +30 -4
  114. package/src/hub/config-apply.ts +18 -10
  115. package/src/hub/config-reload.ts +8 -5
  116. package/src/hub/config-trust.ts +1 -1
  117. package/src/hub/control-client.ts +4 -1
  118. package/src/hub/daemon.ts +95 -39
  119. package/src/hub/facts.ts +76 -13
  120. package/src/hub/hub-tools.ts +21 -5
  121. package/src/hub/instance-config.ts +17 -5
  122. package/src/hub/instance-execution.ts +20 -8
  123. package/src/hub/instances.ts +2 -1
  124. package/src/hub/peers.ts +9 -0
  125. package/src/hub/plan-conformance.ts +83 -0
  126. package/src/hub/review-findings.ts +104 -0
  127. package/src/hub/roster.ts +31 -5
  128. package/src/hub/sdlc-queue.ts +19 -4
  129. package/src/hub/sdlc-release.ts +16 -1
  130. package/src/hub/sdlc.ts +27 -12
  131. package/src/hub/settings.ts +5 -1
  132. package/src/hub/snapshots.ts +8 -6
  133. package/src/hub/tasks.ts +275 -45
  134. package/src/hub/ui.ts +2 -2
  135. package/src/ui/index.html +559 -78
  136. package/src/ui/office-model.ts +3 -1
  137. 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
+
@@ -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;
@@ -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
- export const ADAPTER_API = 1;
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 entries = Object.freeze([...ADAPTERS, ...configured]);
108
- const catalog = new Map([...definitions, ...configured.map(definition => [definition.kind, definition] as const)]);
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
- const ROLE_ORDER = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode", "agy"] as const;
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
- /** Internal fixed builtin CLI entries; public AdapterDefinition and NativeHooks expose no execution extension. */
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 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
+ ["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>", "assign approved/in-flight work without changing execution"],
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 const HELP: readonly HelpEntry[] = SECTIONS.flatMap(([section, rows]) => rows.map(([usage, description]) => ({ section, usage, description })));
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 entries = command === undefined ? HELP : HELP.filter(entry => helpCommands(entry).includes(command));
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) {
@@ -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
- console.log(await taskOp("hub_review", { id: Number(id), verdict, ...(one["--head"] ? { head: one["--head"] } : {}), note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
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
- const key = `roster.${peer}`, value = sub === "add";
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));