@staix/agent-hub 0.12.44 → 0.13.0

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 (197) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +83 -116
  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 +15 -0
  6. package/docs/agent-notes/adapters.md +63 -46
  7. package/docs/agent-notes/budget.md +7 -2
  8. package/docs/agent-notes/bus.md +6 -4
  9. package/docs/agent-notes/daemon.md +15 -8
  10. package/docs/agent-notes/local-worker.md +6 -4
  11. package/docs/agent-notes/tasks.md +18 -5
  12. package/docs/agent-notes/tests.md +11 -9
  13. package/docs/dogfood.md +92 -0
  14. package/docs/events.md +14 -1
  15. package/docs/operations.md +349 -26
  16. package/docs/profiles.md +2 -2
  17. package/docs/quickstart.md +12 -2
  18. package/docs/sdlc.md +61 -13
  19. package/docs/security.md +24 -12
  20. package/docs/smoke.md +9 -4
  21. package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
  22. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  24. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  25. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  26. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  27. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  28. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  29. package/docs/verification/2026-10-22-code-facts.md +78 -0
  30. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  31. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  32. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  33. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  34. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  35. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  36. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  37. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  39. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  40. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  44. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  45. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  48. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  53. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  54. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  56. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  57. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  58. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  60. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  65. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  66. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  68. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  71. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  72. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  73. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  74. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  75. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  76. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  77. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  78. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  79. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  80. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  81. package/docs/verified.json +19 -16
  82. package/package.json +5 -1
  83. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  84. package/plugins/agent-hub/server.js +10120 -9623
  85. package/src/adapters/adapter-api.ts +2 -0
  86. package/src/adapters/agy/definition.ts +3 -1
  87. package/src/adapters/agy/entry.ts +5 -4
  88. package/src/adapters/claude-channel.ts +32 -12
  89. package/src/adapters/codex-appserver.ts +19 -2
  90. package/src/adapters/codex-review-isolation.ts +12 -5
  91. package/src/adapters/compat.ts +140 -0
  92. package/src/adapters/conformance/acp.ts +114 -0
  93. package/src/adapters/conformance/codex.ts +191 -0
  94. package/src/adapters/conformance/enrollment.ts +43 -0
  95. package/src/adapters/conformance/factories.ts +17 -0
  96. package/src/adapters/conformance/fake-paths.ts +12 -0
  97. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  98. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  99. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  100. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  101. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  102. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  103. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  104. package/src/adapters/conformance/index.ts +11 -0
  105. package/src/adapters/conformance/kit.ts +50 -0
  106. package/src/adapters/conformance/local-pi.ts +100 -0
  107. package/src/adapters/conformance/owned-acp.ts +44 -0
  108. package/src/adapters/definitions/builtin-metadata.ts +25 -7
  109. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  110. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  111. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  112. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  113. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  114. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  115. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  116. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  117. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  118. package/src/adapters/definitions/qwen-native.ts +7 -3
  119. package/src/adapters/definitions/qwen.ts +5 -1
  120. package/src/adapters/external-metadata.ts +48 -0
  121. package/src/adapters/external-module-scan.ts +126 -0
  122. package/src/adapters/external-modules.ts +240 -0
  123. package/src/adapters/jcode.ts +72 -31
  124. package/src/adapters/kimi-review.ts +6 -3
  125. package/src/adapters/pi.ts +2 -0
  126. package/src/adapters/public.ts +7 -0
  127. package/src/adapters/registry-types.ts +13 -1
  128. package/src/adapters/registry.ts +52 -15
  129. package/src/cli/agents.ts +40 -0
  130. package/src/cli/console-review.ts +54 -0
  131. package/src/cli/console-state.ts +124 -29
  132. package/src/cli/console-symbols.ts +74 -0
  133. package/src/cli/console-usage.ts +89 -0
  134. package/src/cli/console.ts +26 -1
  135. package/src/cli/help.ts +36 -21
  136. package/src/cli/identity.ts +30 -7
  137. package/src/cli/init.ts +109 -11
  138. package/src/cli/main.ts +145 -16
  139. package/src/cli/output.ts +44 -1
  140. package/src/cli/preview.ts +1 -1
  141. package/src/cli/sdlc-release-guard.ts +82 -0
  142. package/src/cli/upgrade-labels.ts +1 -1
  143. package/src/cli/upgrade-presentation.ts +1 -1
  144. package/src/cli/upgrade-restore-state.ts +3 -2
  145. package/src/cli/upgrade-runtime.ts +4 -1
  146. package/src/hub/action-class.ts +362 -0
  147. package/src/hub/action-policy.ts +106 -0
  148. package/src/hub/board.ts +72 -11
  149. package/src/hub/budget.ts +71 -2
  150. package/src/hub/bus.ts +43 -3
  151. package/src/hub/checks.ts +80 -31
  152. package/src/hub/code-facts.ts +223 -0
  153. package/src/hub/config-apply.ts +28 -11
  154. package/src/hub/config-reload.ts +8 -5
  155. package/src/hub/config-trust.ts +7 -4
  156. package/src/hub/control-client.ts +4 -1
  157. package/src/hub/daemon.ts +332 -61
  158. package/src/hub/dogfood-read.ts +199 -0
  159. package/src/hub/dogfood.ts +163 -0
  160. package/src/hub/envelope.ts +1 -1
  161. package/src/hub/events.ts +11 -2
  162. package/src/hub/facts.ts +76 -13
  163. package/src/hub/hub-tools.ts +45 -7
  164. package/src/hub/inbox-polling.ts +25 -0
  165. package/src/hub/instance-config.ts +17 -5
  166. package/src/hub/instance-execution.ts +20 -8
  167. package/src/hub/instances.ts +75 -2
  168. package/src/hub/peers.ts +9 -0
  169. package/src/hub/plan-conformance.ts +83 -0
  170. package/src/hub/profiles.ts +7 -2
  171. package/src/hub/release-worktree.ts +56 -0
  172. package/src/hub/review-brief.ts +36 -0
  173. package/src/hub/review-findings.ts +104 -0
  174. package/src/hub/roster.ts +31 -5
  175. package/src/hub/sdlc-queue.ts +48 -11
  176. package/src/hub/sdlc-release-authorization.ts +173 -0
  177. package/src/hub/sdlc-release.ts +16 -1
  178. package/src/hub/sdlc.ts +71 -18
  179. package/src/hub/settings.ts +10 -3
  180. package/src/hub/snapshots.ts +101 -14
  181. package/src/hub/symbol-normalizer.ts +26 -0
  182. package/src/hub/task-code-symbols.ts +90 -0
  183. package/src/hub/task-sweep.ts +3 -2
  184. package/src/hub/tasks.ts +556 -88
  185. package/src/hub/test-integrity.ts +162 -0
  186. package/src/hub/ui.ts +2 -2
  187. package/src/hub/usage-rollup.ts +361 -0
  188. package/src/local/sandbox.ts +5 -3
  189. package/src/ui/index.html +560 -78
  190. package/src/ui/office-model.ts +3 -1
  191. package/src/ui/peer-palette.ts +44 -0
  192. package/templates/AGENTS.block.md +1 -1
  193. package/templates/profiles/dev.json +6 -1
  194. package/templates/profiles/general.json +6 -1
  195. package/templates/profiles/research.json +6 -1
  196. package/templates/profiles/writing.json +6 -1
  197. package/templates/release-guard.block.md +6 -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
+
@@ -1,4 +1,5 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { assessVersion, COMPAT, type CompatVerdict } from "./compat.ts";
2
3
  import { createConnection } from "node:net";
3
4
  import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
@@ -12,21 +13,25 @@ export interface JcodeOptions extends Omit<AcpOptions, "cmd" | "qwen025" | "expe
12
13
  command: string[];
13
14
  home: string;
14
15
  providerProfile?: string;
16
+ /** The hub peer id this Jcode acts for: "jcode" for the base peer, an instance id for an extra one (#685). */
17
+ peer?: string;
18
+ /** Initial native provider (`-p`), passed exactly as the person wrote it (#685). The hub never handles model credentials. */
19
+ provider?: string;
15
20
  /** How long a native steer may take to be acknowledged (#572); default 5 s. */
16
21
  steerTimeoutMs?: number;
17
22
  }
18
- export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: string, cwd: string, model?: string, profile?: string): string[] {
23
+ export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: string, cwd: string, model?: string, profile?: string, provider?: string, peer = "jcode"): string[] {
19
24
  if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
20
- return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
21
- ...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", "agent-hub-jcode"] : ["--mcp-tools", "eager"])];
25
+ return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(provider ? ["-p", provider] : []), ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
26
+ ...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", `agent-hub-${peer}`] : ["--mcp-tools", "eager"])];
22
27
  }
23
28
  export interface JcodeTuiBinding { launchId: string; sessionId: string }
24
29
  export type JcodeTuiState = { state: "waiting"; binding: JcodeTuiBinding } |
25
30
  { state: "ready"; binding: JcodeTuiBinding; launch: { cmd: string; args: string[]; env: NodeJS.ProcessEnv } };
26
31
 
27
- export function jcodeTuiCommand(command: string[], socket: string, sessionId: string, model?: string, profile?: string): string[] {
32
+ export function jcodeTuiCommand(command: string[], socket: string, sessionId: string, model?: string, profile?: string, provider?: string): string[] {
28
33
  if (!command.length || command.some(word => typeof word !== "string" || !word)) throw new Error("jcode_cmd must name an executable argv");
29
- return [...command, "--socket", socket, "--resume", sessionId, "--no-update", "--no-selfdev",
34
+ return [...command, "--socket", socket, "--resume", sessionId, "--no-update", "--no-selfdev", ...(provider ? ["-p", provider] : []),
30
35
  ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : [])];
31
36
  }
32
37
 
@@ -65,17 +70,8 @@ export class JcodeOptionMismatch extends Error {
65
70
  constructor(readonly option: "--session-id" | "--model" | "--provider-profile") { super("Jcode launch options differ from its running session"); }
66
71
  }
67
72
  const JCODE_OWNED_ENV = { JCODE_DISABLE_CLAUDE_MCP: "1", JCODE_NO_TELEMETRY: "1" } as const;
68
- /** The oldest native release accepted; its ACP, serve and MCP contract was verified live on 0.98.1 (docs/verification). */
69
- export const JCODE_MIN_VERSION = "0.98.0";
70
- /** The first native release assumed to break that contract: the next major. Lower it when a release is shown to break. */
71
- export const JCODE_BREAKING_VERSION = "1.0.0";
72
- const release = (version: string) => /^(\d+)\.(\d+)\.(\d+)$/.exec(version)?.slice(1).map(Number);
73
- const compare = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]!;
74
- /** A plain release from JCODE_MIN_VERSION up to, not including, JCODE_BREAKING_VERSION; the minor is not pinned (#529). */
75
- export function jcodeVersionSupported(version: unknown): boolean {
76
- const v = typeof version === "string" ? release(version) : undefined;
77
- return !!v && compare(v, release(JCODE_MIN_VERSION)!) >= 0 && compare(v, release(JCODE_BREAKING_VERSION)!) < 0;
78
- }
73
+ /** #691: the verdict lives in `src/adapters/compat.ts` (line, verified versions, the person's trust); never pin here. */
74
+ export const jcodeVersionSupported = (version: unknown): boolean => assessVersion("jcode", version).ok;
79
75
 
80
76
  /** A native steer the server did not take, with nothing sent: the message may safely stay queued. */
81
77
  class SteerRefused extends Error {}
@@ -137,20 +133,56 @@ function nativeSteer(socket: string, cwd: string, sessionId: string, text: strin
137
133
  }
138
134
 
139
135
  /** The wrapper contract preserves every configured prefix argument before the native probe. */
140
- export function verifiedJcodeCommand(command: string[], cwd: string): boolean {
136
+ export function jcodeVersion(command: string[], cwd: string): CompatVerdict {
141
137
  const [bin, ...prefix] = command;
142
- if (!bin) return false;
138
+ if (!bin) return assessVersion("jcode", undefined);
143
139
  try {
144
140
  const result = spawnSync(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: { ...peerChildEnv("jcode"), ...JCODE_OWNED_ENV }, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 });
145
- if (result.status !== 0) return false;
146
- const version = JSON.parse(result.stdout);
147
- return jcodeVersionSupported(version.semver);
148
- } catch { return false; }
141
+ return assessVersion("jcode", result.status === 0 ? COMPAT.jcode.parse(result.stdout) : undefined);
142
+ } catch { return assessVersion("jcode", undefined); }
143
+ }
144
+ export const verifiedJcodeCommand = (command: string[], cwd: string): boolean => jcodeVersion(command, cwd).ok;
145
+ /** Collects at most `cap` bytes; the chunk that would pass it, and every later one, is refused and not kept. */
146
+ export function boundedOutput(cap: number): { push(chunk: Buffer): boolean; text(): string; readonly size: number; readonly overflowed: boolean } {
147
+ const chunks: Buffer[] = [];
148
+ let size = 0, overflowed = false;
149
+ return {
150
+ push(chunk) { if (overflowed || size + chunk.length > cap) { overflowed = true; return false; } chunks.push(chunk); size += chunk.length; return true; },
151
+ text: () => Buffer.concat(chunks).toString("utf8"),
152
+ get size() { return size; },
153
+ get overflowed() { return overflowed; },
154
+ };
155
+ }
156
+ /**
157
+ * The daemon's preflight: the same probe without blocking the event loop (a slow wrapper must not stall peers). The probe owns its
158
+ * process group: at the deadline, or once stdout passes 64 KiB, the whole group is stopped, descendants included (kimi-review's pattern).
159
+ */
160
+ export function jcodeVersionAsync(command: string[], cwd: string, timeoutMs = 10_000): Promise<CompatVerdict> {
161
+ const [bin, ...prefix] = command;
162
+ if (!bin) return Promise.resolve(assessVersion("jcode", undefined));
163
+ return new Promise(resolve => {
164
+ let child: ChildProcess;
165
+ try { child = spawn(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: { ...peerChildEnv("jcode"), ...JCODE_OWNED_ENV }, detached: true, stdio: ["ignore", "pipe", "ignore"] }); }
166
+ catch { resolve(assessVersion("jcode", undefined)); return; }
167
+ const output = boundedOutput(64 * 1024);
168
+ let done = false;
169
+ const finish = async (code: number | null) => {
170
+ if (done) return; done = true; clearTimeout(timer);
171
+ try { await stopOwnedProcess(child, { group: true }); } catch { resolve(assessVersion("jcode", undefined)); return; }
172
+ resolve(assessVersion("jcode", code === 0 && !output.overflowed ? COMPAT.jcode.parse(output.text()) : undefined));
173
+ };
174
+ const timer = setTimeout(() => { void finish(null); }, timeoutMs);
175
+ child.once("spawn", () => trackGroup(child));
176
+ // Chunks after the cap or after settling are drained and discarded, never buffered.
177
+ child.stdout!.on("data", (bytes: Buffer) => { if (!done && !output.push(bytes)) void finish(null); });
178
+ child.once("error", () => { void finish(null); });
179
+ child.once("close", code => { void finish(code); });
180
+ });
149
181
  }
150
182
 
151
183
  /** Both processes are ours. The user's global daemon and its sockets are never stopped. */
152
184
  export class JcodePeer extends AcpPeer {
153
- private readonly launchId = crypto.randomUUID();
185
+ private readonly launchId: string;
154
186
  private socketIdentity: { dev: number; ino: number } | undefined;
155
187
  private readonly root: string;
156
188
  private readonly socket: string;
@@ -161,21 +193,28 @@ export class JcodePeer extends AcpPeer {
161
193
 
162
194
  constructor(private readonly jcode: JcodeOptions) {
163
195
  if (!isAbsolute(jcode.home)) throw new Error("jcode_home must be an absolute native data path");
196
+ const peer = jcode.peer ?? "jcode";
164
197
  jcodeCommand(jcode.command, "acp", "preview", jcode.cwd);
165
198
  // The name must end in ".sock": jcode names its debug socket by replacing that suffix, so "s" binds one path twice.
166
199
  const root = mkdtempSync(join(tmpdir(), "ahub-jcode-")), socket = join(root, "j.sock");
167
- const cmd = jcodeCommand(jcode.command, "acp", socket, jcode.cwd, jcode.launchModel, jcode.providerProfile);
168
- super("jcode", { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, ...JCODE_OWNED_ENV }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
169
- this.root = root; this.socket = socket;
200
+ const cmd = jcodeCommand(jcode.command, "acp", socket, jcode.cwd, jcode.launchModel, jcode.providerProfile, jcode.provider, peer);
201
+ const launchId = crypto.randomUUID();
202
+ // The ACP child gets peerChildEnv(this.id): the instance id, so its hub tools act as this peer and never as the base Jcode. The launch id
203
+ // goes with it: the daemon admits a hub-tools hello for this peer only when it carries the id of this peer's current owned launch (#368).
204
+ super(peer, { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, AGENTHUB_LAUNCH_ID: launchId, ...JCODE_OWNED_ENV }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
205
+ this.root = root; this.socket = socket; this.launchId = launchId;
170
206
  }
171
207
 
208
+ /** True only for the id of this peer's current owned launch, while it is not ending: the proof a hub-tools hello must carry (#368). */
209
+ ownsLaunch(launchId: unknown): boolean { return !this.ending && typeof launchId === "string" && launchId === this.launchId; }
210
+
172
211
  override async start(): Promise<void> {
173
212
  // The native loader otherwise imports Codex MCP into the user's store on first use.
174
213
  if (!existsSync(join(this.jcode.home, "mcp.json"))) throw new Error("initialize Jcode's global MCP store with its native client before enabling this peer; the hub never initializes or copies it");
175
214
  if (existsSync(this.socket)) { this.preserveRoot = true; throw new Error("Jcode private socket is occupied; no daemon was adopted or removed"); }
176
215
  this.ending = false;
177
- const [bin, ...args] = jcodeCommand(this.jcode.command, "serve", this.socket, this.jcode.cwd, this.jcode.launchModel, this.jcode.providerProfile);
178
- const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv("jcode"), JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, JCODE_READY_FD: "3", ...JCODE_OWNED_ENV }, stdio: ["ignore", "ignore", "ignore", "pipe"] });
216
+ const [bin, ...args] = jcodeCommand(this.jcode.command, "serve", this.socket, this.jcode.cwd, this.jcode.launchModel, this.jcode.providerProfile, this.jcode.provider, this.id);
217
+ const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv(this.id), JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, JCODE_READY_FD: "3", AGENTHUB_LAUNCH_ID: this.launchId, ...JCODE_OWNED_ENV }, stdio: ["ignore", "ignore", "ignore", "pipe"] });
179
218
  this.server = server; trackGroup(server);
180
219
  try {
181
220
  await new Promise<void>((resolve, reject) => {
@@ -279,11 +318,13 @@ export class JcodePeer extends AcpPeer {
279
318
  : options.providerProfile !== undefined && options.providerProfile !== this.jcode.providerProfile ? "--provider-profile" : undefined;
280
319
  if (differs) throw new JcodeOptionMismatch(differs);
281
320
  if (!savedSession(this.jcode.home, sessionId)) return { state: "waiting", binding };
282
- const [cmd, ...args] = jcodeTuiCommand(this.jcode.command, this.socket, sessionId, this.jcode.launchModel, this.jcode.providerProfile);
283
- return { state: "ready", binding, launch: { cmd: cmd!, args, env: { JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, ...JCODE_OWNED_ENV } } };
321
+ const [cmd, ...args] = jcodeTuiCommand(this.jcode.command, this.socket, sessionId, this.jcode.launchModel, this.jcode.providerProfile, this.jcode.provider);
322
+ // The TUI is the person's own client of this peer's server. The shared project MCP entry names no peer (#685), so every launch
323
+ // path supplies its own id: the base Jcode's too, or its MCP child would fall back to the channel default.
324
+ return { state: "ready", binding, launch: { cmd: cmd!, args, env: { JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, AGENTHUB_PEER_ID: this.id, AGENTHUB_LAUNCH_ID: this.launchId, ...JCODE_OWNED_ENV } } };
284
325
  }
285
326
  override recoveryMetadata(): Record<string, unknown> {
286
- return { ...super.recoveryMetadata(), launch: { kind: "jcode", peer: "jcode", mode: "headless", ...(this.jcode.launchModel ? { model: this.jcode.launchModel } : {}), ...(this.jcode.providerProfile ? { providerProfile: this.jcode.providerProfile } : {}) } };
327
+ return { ...super.recoveryMetadata(), launch: { kind: "jcode", peer: this.id, mode: "headless", ...(this.jcode.launchModel ? { model: this.jcode.launchModel } : {}), ...(this.jcode.providerProfile ? { providerProfile: this.jcode.providerProfile } : {}) } };
287
328
  }
288
329
  }
289
330
 
@@ -1,4 +1,5 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
+ import { assessVersion, COMPAT } from "./compat.ts";
2
3
  import { createHash } from "node:crypto";
3
4
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
5
  import { homedir, tmpdir } from "node:os";
@@ -7,7 +8,7 @@ import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
7
8
  import { realPath } from "../hub/project.ts";
8
9
  import type { ReviewerBackend, ReviewerPrepareContext } from "./reviewer-print.ts";
9
10
 
10
- const VERSION = "2.2.0", MANAGED = "managed:kimi-code";
11
+ const MANAGED = "managed:kimi-code";
11
12
  const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
12
13
  const hash = (text: string) => createHash("sha256").update(text).digest("hex");
13
14
  /** Native discovery can walk above cwd; check lexical aliases as well as the physical temporary root. */
@@ -133,7 +134,9 @@ export const kimiReviewerBackend: ReviewerBackend = {
133
134
  const executable = realPath(Bun.which(native[0]!, { PATH: process.env.PATH }) ?? resolve(native[0]!));
134
135
  const command = [executable, ...native.slice(1)];
135
136
  const executableStat = lstatSync(executable);
136
- if (await version(command, cwd, env, context.signal) !== VERSION) throw new Error("unsupported Kimi version; receipt covers 2.2.0");
137
+ // #691: the verified versions and the person's trust live in compat.ts.
138
+ const verdict = assessVersion("kimi-review", COMPAT["kimi-review"].parse(await version(command, cwd, env, context.signal)));
139
+ if (!verdict.ok) throw new Error(verdict.reason);
137
140
  const fingerprints = [configFile, mcpFile, agentFile].map(file => hash(read(file, 2_097_152)));
138
141
  let cleaned = false;
139
142
  const cleanup = () => { if (cleaned) return; rmSync(root, { recursive: true, force: true }); cleaned = true; };
@@ -149,7 +152,7 @@ export const kimiReviewerBackend: ReviewerBackend = {
149
152
  && ["credentials", "oauth", "device_id"].every((name, index) => realPath(join(source, name)) === sources[index] && realPath(join(tool, name)) === sources[index]);
150
153
  } catch { return false; }
151
154
  },
152
- isolation: { backend: "kimi", version: VERSION, binary: executable, profile: "private-config-exact-two-tools-shared-native-oauth", readOnly: true, strictMcp: true, ephemeral: true,
155
+ isolation: { backend: "kimi", version: verdict.version, binary: executable, profile: "private-config-exact-two-tools-shared-native-oauth", readOnly: true, strictMcp: true, ephemeral: true,
153
156
  enforcement: "native-policy", freshSession: true, hubToolsOnly: true,
154
157
  evidence: ["#533 person ruling2026-10-19: native execution-time exact tool allowlist, no kernel sandbox", "private config/cwd/empty skills; shared OAuth directory symlinks and native lock/atomic rename", "task-bound in-process hub MCP; no full control token", "residual: interruption mid-refresh may require native re-login"] },
155
158
  };
@@ -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
 
@@ -199,7 +202,9 @@ export interface AdapterDefinition {
199
202
  };
200
203
  identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin" };
201
204
  /** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
202
- instances?: { mode: "external" | "headless"; roles?: readonly string[] };
205
+ instances?: { mode: "external" | "headless"; roles?: readonly string[];
206
+ /** Each declared instance runs its own native data home (`jcode_home`) and may pass its own provider, model and profile names (#685). Fixed builtins only. */
207
+ nativeHome?: true };
203
208
  attach?: {
204
209
  /** The authenticated external hello may claim this id; independent of MCP configuration shape. */
205
210
  externalHello?: boolean;
@@ -272,8 +277,14 @@ export interface AdapterDefinition {
272
277
  validateEffective?(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void;
273
278
  };
274
279
  capabilities?: { manifest?(peer: PeerAdapter): Promise<CapabilityManifest>; watch?: boolean };
280
+ /** Stable host projection order and planner eligibility, independent of default task roles. */
281
+ projection?: { roleRank?: number; startRank?: number; helpRank?: number; plannerEligible?: boolean };
275
282
  cli: {
276
283
  command: string;
284
+ /** Fixed builtin CLI ports; project definitions cannot supply these callbacks. */
285
+ entry?: Readonly<Omit<BuiltinCliEntry, "command">>;
286
+ preview?: NativePreviewBuilder;
287
+ usage?: readonly (readonly [usage: string, description: string])[];
277
288
  /** Configuration leaves that select this peer's next native launch command or provider profile. */
278
289
  launchConfigKeys?: readonly string[];
279
290
  /** Restrictions only; core identity remains authoritative. */
@@ -299,6 +310,7 @@ export type KindMap = Readonly<Record<string, { readonly kind: string; readonly
299
310
  /** Immutable per-project resolver; exported globals remain builtin-only. */
300
311
  export interface ProjectAdapterCatalog {
301
312
  readonly definitions: readonly AdapterDefinition[];
313
+ readonly externalDefinitions: readonly AdapterDefinition[];
302
314
  definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined;
303
315
  kindOf(id: string, instances?: KindMap): string | undefined;
304
316
  familyOf(id: string, instances?: KindMap): string | undefined;