@staix/agent-hub 0.12.43 → 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 (180) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +228 -0
  5. package/docs/agent-notes/README.md +14 -0
  6. package/docs/agent-notes/adapters.md +61 -42
  7. package/docs/agent-notes/budget.md +2 -2
  8. package/docs/agent-notes/daemon.md +16 -10
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +13 -6
  11. package/docs/agent-notes/tests.md +9 -7
  12. package/docs/operations.md +163 -12
  13. package/docs/quickstart.md +10 -2
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +11 -6
  16. package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  19. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  20. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  21. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  22. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  23. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  24. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  25. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  27. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  28. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  29. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  30. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  35. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  36. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  39. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  44. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  55. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  56. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  57. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  60. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  61. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  62. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  68. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  71. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  72. package/docs/verified.json +38 -20
  73. package/package.json +5 -1
  74. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  75. package/plugins/agent-hub/server.js +10287 -9560
  76. package/src/adapters/acp-owned-group.ts +124 -0
  77. package/src/adapters/acp.ts +92 -25
  78. package/src/adapters/adapter-api.ts +2 -0
  79. package/src/adapters/agy/definition.ts +4 -4
  80. package/src/adapters/agy/entry.ts +4 -1
  81. package/src/adapters/channel-peer.ts +143 -0
  82. package/src/adapters/claude-channel.ts +21 -12
  83. package/src/adapters/codex-appserver.ts +17 -2
  84. package/src/adapters/configured-acp.ts +124 -0
  85. package/src/adapters/conformance/acp.ts +114 -0
  86. package/src/adapters/conformance/codex.ts +191 -0
  87. package/src/adapters/conformance/enrollment.ts +43 -0
  88. package/src/adapters/conformance/factories.ts +17 -0
  89. package/src/adapters/conformance/fake-paths.ts +12 -0
  90. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  91. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  92. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  93. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  94. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  95. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  96. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  97. package/src/adapters/conformance/index.ts +11 -0
  98. package/src/adapters/conformance/kit.ts +50 -0
  99. package/src/adapters/conformance/local-pi.ts +100 -0
  100. package/src/adapters/conformance/owned-acp.ts +44 -0
  101. package/src/adapters/definitions/builtin-metadata.ts +60 -39
  102. package/src/adapters/definitions/native-channel-state.ts +41 -0
  103. package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
  104. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  105. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  106. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  107. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  108. package/src/adapters/definitions/qwen.ts +7 -9
  109. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  110. package/src/adapters/external-metadata.ts +48 -0
  111. package/src/adapters/external-module-scan.ts +126 -0
  112. package/src/adapters/external-modules.ts +240 -0
  113. package/src/adapters/jcode.ts +4 -0
  114. package/src/adapters/local-worker.ts +4 -0
  115. package/src/adapters/pi.ts +6 -0
  116. package/src/adapters/public.ts +7 -0
  117. package/src/adapters/registry-types.ts +28 -16
  118. package/src/adapters/registry.ts +115 -17
  119. package/src/adapters/reviewer-print.ts +4 -0
  120. package/src/cli/agy-mcp.ts +3 -2
  121. package/src/cli/configured-acp-entry.ts +58 -0
  122. package/src/cli/console-state.ts +9 -4
  123. package/src/cli/facts-hook.ts +2 -1
  124. package/src/cli/help.ts +27 -16
  125. package/src/cli/identity.ts +8 -7
  126. package/src/cli/launch.ts +5 -4
  127. package/src/cli/main.ts +101 -32
  128. package/src/cli/memory.ts +13 -10
  129. package/src/cli/output.ts +1 -0
  130. package/src/cli/preview.ts +9 -0
  131. package/src/cli/recovery-guidance.ts +1 -1
  132. package/src/cli/terminal-recovery.ts +16 -13
  133. package/src/cli/upgrade-labels.ts +1 -1
  134. package/src/cli/upgrade-presentation.ts +1 -1
  135. package/src/cli/upgrade-restore-state.ts +6 -4
  136. package/src/cli/upgrade-runtime.ts +25 -7
  137. package/src/cli/upgrade.ts +3 -2
  138. package/src/hub/board.ts +37 -8
  139. package/src/hub/budget.ts +9 -2
  140. package/src/hub/bus.ts +6 -2
  141. package/src/hub/claude-state.ts +2 -37
  142. package/src/hub/conductor.ts +8 -6
  143. package/src/hub/config-apply.ts +37 -17
  144. package/src/hub/config-reload.ts +42 -11
  145. package/src/hub/config-trust.ts +1 -1
  146. package/src/hub/context-window.ts +6 -0
  147. package/src/hub/control-client.ts +4 -1
  148. package/src/hub/crash.ts +8 -6
  149. package/src/hub/daemon.ts +412 -328
  150. package/src/hub/facts.ts +76 -13
  151. package/src/hub/hub-tools.ts +57 -5
  152. package/src/hub/instance-config.ts +33 -12
  153. package/src/hub/instance-execution.ts +23 -11
  154. package/src/hub/instances.ts +47 -22
  155. package/src/hub/lifecycle-schema.ts +5 -3
  156. package/src/hub/memory-profile.ts +16 -3
  157. package/src/hub/no-writes.ts +50 -0
  158. package/src/hub/peers.ts +9 -0
  159. package/src/hub/permission-mode.ts +9 -7
  160. package/src/hub/plan-conformance.ts +83 -0
  161. package/src/hub/review-findings.ts +104 -0
  162. package/src/hub/roster.ts +35 -6
  163. package/src/hub/routing.ts +21 -20
  164. package/src/hub/sdlc-queue.ts +19 -4
  165. package/src/hub/sdlc-release.ts +16 -1
  166. package/src/hub/sdlc.ts +27 -12
  167. package/src/hub/settings.ts +54 -16
  168. package/src/hub/snapshots.ts +32 -6
  169. package/src/hub/start-mode.ts +10 -8
  170. package/src/hub/task-sweep.ts +8 -2
  171. package/src/hub/tasks.ts +358 -60
  172. package/src/hub/ui.ts +2 -2
  173. package/src/memory/capture.ts +2 -1
  174. package/src/ui/index.html +563 -77
  175. package/src/ui/office-model.ts +3 -1
  176. package/src/ui/peer-palette.ts +44 -0
  177. package/templates/AGENTS.block.md +1 -1
  178. package/templates/profiles/general.json +1 -1
  179. package/templates/profiles/research.json +1 -1
  180. package/templates/profiles/writing.json +1 -1
@@ -1,16 +1,7 @@
1
1
  import type { AdapterDefinition } from "../registry-types.ts";
2
2
  import type { NativePreviewBuilder } from "./native-cli-preview-common.ts";
3
- import { previewClaude } from "./native-cli-preview-claude.ts";
4
- import { previewCodex } from "./native-cli-preview-codex.ts";
5
- import { previewKimi } from "./native-cli-preview-kimi.ts";
6
- import { previewPi } from "./native-cli-preview-pi.ts";
7
- import { previewQwen } from "./native-cli-preview-qwen.ts";
8
- import { previewJcode } from "./native-cli-preview-jcode.ts";
9
3
 
10
- // Private implementations of the fixed builtin set; neither config nor an external definition can add callbacks.
11
- const previews: Readonly<Record<string, NativePreviewBuilder>> = Object.freeze({
12
- claude: previewClaude, codex: previewCodex, kimi: previewKimi, pi: previewPi, qwen: previewQwen, jcode: previewJcode,
13
- });
4
+ /** The host admits builtin-only preview ports before calling this projection. */
14
5
  export function builtinPreview(definition: AdapterDefinition): NativePreviewBuilder | undefined {
15
- return Object.hasOwn(previews, definition.kind) ? previews[definition.kind] : undefined;
6
+ return definition.cli.preview;
16
7
  }
@@ -0,0 +1,14 @@
1
+ import type { AdapterDefinition } from "../registry-types.ts";
2
+
3
+ /** Legacy native shell marker defaults; the host validates markers and conflicts before selection. */
4
+ export function nativeCliMarkerPeer(channel: boolean, appserver: boolean): string | undefined {
5
+ return channel ? "claude" : appserver ? "codex" : undefined;
6
+ }
7
+
8
+ /** Historical identity for captures whose host did not supply an agent id. */
9
+ export function defaultCapturePeer(): string { return "local"; }
10
+
11
+ /** Released recovery receipts retain their native/channel group vocabulary. */
12
+ export function nativeRestoreGroup(transport: AdapterDefinition["transport"] | undefined): "claude" | "native" {
13
+ return transport === "claude-channel" ? "claude" : "native";
14
+ }
@@ -1,14 +1,17 @@
1
+ import { runQwenEntry } from "./native-cli-entry-qwen.ts";
2
+ import { previewQwen } from "./native-cli-preview-qwen.ts";
1
3
  import { QWEN_DISPLAY } from "./native-display.ts";
2
4
  import { QWEN_RECOVERY_SESSION } from "./native-recovery.ts";
3
5
  import { AcpPeer } from "../acp.ts";
4
6
  import type { AdapterDefinition } from "../registry-types.ts";
5
- import { buildQwenLaunch, qwenNativeIdentity, qwenSessionId, QWEN_MODE_IDS } from "./qwen-native.ts";
7
+ import { buildQwenLaunch, qwenSessionId } from "./qwen-native.ts";
6
8
  import { shellQuote } from "../../cli/shell-quote.ts";
7
9
 
8
10
  /** Production Qwen policy. Legacy AcpPeer callers opt in separately through qwen025. */
9
11
  export const QWEN_DEFINITION: AdapterDefinition = {
10
12
  api: 1,
11
13
  kind: "qwen",
14
+ projection: { roleRank: 5, helpRank: 5, plannerEligible: true },
12
15
  displayName: "Qwen Code",
13
16
  family: "qwen",
14
17
  transport: "acp",
@@ -20,7 +23,6 @@ export const QWEN_DEFINITION: AdapterDefinition = {
20
23
  daemonPort: "acp",
21
24
  forms: ["headless"],
22
25
  defaultForm: "headless",
23
- ownsTerminal: () => false,
24
26
  conductorStartable: true,
25
27
  hubSwitches: [],
26
28
  ownedFlags: [],
@@ -40,12 +42,8 @@ export const QWEN_DEFINITION: AdapterDefinition = {
40
42
  conductorStartArgs: args => args.length ? new Error("conductor starts accept no launch overrides; use ahub qwen") : {},
41
43
  },
42
44
  identity: { mcp: "acp-session" },
45
+ instances: { mode: "headless" },
43
46
  permission: {
44
- modes: {
45
- ask: { kind: "acp-mode", modeId: QWEN_MODE_IDS.ask },
46
- "ask-when-needed": { kind: "acp-mode", modeId: QWEN_MODE_IDS["ask-when-needed"] },
47
- "never-ask": { kind: "acp-mode", modeId: QWEN_MODE_IDS["never-ask"] },
48
- },
49
47
  grants: {
50
48
  ask: "every write, edit and shell command asks",
51
49
  "ask-when-needed": "Qwen auto-edit approves file edits; shell commands ask; NO hub sandbox",
@@ -69,7 +67,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
69
67
  turnEnd: "adapter",
70
68
  budget: { account: "qwen" },
71
69
  capabilities: { watch: true },
72
- cli: { command: "qwen", launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
70
+ cli: { command: "qwen", entry: { lifecycleAdmission: true, execute: runQwenEntry }, preview: previewQwen, usage: [["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]", "start optional Qwen Code 0.25.0 ACP (roster.qwen, default off)"]], launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
73
71
  display: QWEN_DISPLAY,
74
- hooks: { agentIdentity: init => { qwenNativeIdentity("qwen", true, init); }, recallPlatform: "qwen" },
72
+ hooks: { recallPlatform: "qwen" },
75
73
  };
@@ -6,18 +6,14 @@ import { createReviewer, createCodexReviewer } from "./native-factories.ts";
6
6
  import { CODEX_DISPLAY, KIMI_DISPLAY } from "./native-display.ts";
7
7
 
8
8
  export { claudeReviewerBackend };
9
- /** No launch path is exposed by metadata-only placeholders. */
10
- function unavailableLaunch(): never { throw new Error("Reviewer unavailable: verified runtime support is deferred to #533"); }
11
-
12
9
  function reviewerVariant(kind: string, displayName: string, reviewer: NonNullable<AdapterDefinition["reviewer"]>, display: AdapterDefinition["display"]): AdapterDefinition {
13
10
  return {
14
11
  api: 1, kind, family: kind, displayName, transport: "hub-native", roles: ["reviewer"],
15
12
  defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
16
- start: { ...(reviewer.backend ? { create: createReviewer, daemonPort: "reviewer-print" as const } : {}), forms: ["headless"], defaultForm: "headless", ownsTerminal: () => false,
17
- launch: unavailableLaunch,
13
+ start: { ...(reviewer.backend ? { create: createReviewer, daemonPort: "reviewer-print" as const } : {}), forms: ["headless"], defaultForm: "headless",
18
14
  conductorStartable: true, hubSwitches: [], ownedFlags: [] },
19
- identity: { mcp: "env" },
20
- permission: { modes: {}, readOnly: { kind: "hub-gate", allow: ["read", "search", "think"] }, unmanaged: "fresh read-only reviewer; an isolation receipt is required before startup" },
15
+ identity: { mcp: "env" }, instances: { mode: "headless" },
16
+ permission: { unmanaged: "fresh read-only reviewer; an isolation receipt is required before startup" },
21
17
  session: { idField: "sessionId", currentId: () => undefined, freshOnRestore: true, launchKind: "reviewer-print", crashPlan: () => ({ resume: {}, how: "starts a fresh isolated review session" }) },
22
18
  turnEnd: "adapter", budget: { account: kind }, routing: { reviewOnly: true },
23
19
  reviewer, cli: { command: kind, startWords: () => [] }, display,
@@ -0,0 +1,48 @@
1
+ import { types } from "node:util";
2
+ import { ADAPTER_API } from "./adapter-api.ts";
3
+ import type { AdapterDefinition } from "./registry-types.ts";
4
+ export interface ExternalAdapterMetadata { readonly kind: string; readonly displayName: string }
5
+ const MAX_MODULES = 16;
6
+ const fail = (reason: string): never => { throw new Error(`external adapter: ${reason}`); };
7
+ export function moduleDataObject(value: unknown): Record<string, unknown> {
8
+ if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value)
9
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value)) || Object.getOwnPropertySymbols(value).length) return fail("plain own-data object required");
10
+ const fields: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(value));
11
+ if (Object.values(fields).some(field => !Object.hasOwn(field, "value") || !field.enumerable)) return fail("accessors and hidden fields are unsupported");
12
+ return Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.value]));
13
+ }
14
+ export function moduleText(value: unknown, max: number): string {
15
+ if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value) > max || /[\p{Cc}\p{Cf}]/u.test(value)) return fail("bounded control-free text required");
16
+ return value;
17
+ }
18
+ export function moduleArray(value: unknown, max: number): unknown[] {
19
+ if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype || value.length > max
20
+ || Object.getOwnPropertySymbols(value).length) return fail("bounded array required");
21
+ const fields: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(value));
22
+ if (Object.keys(fields).length !== value.length + 1) return fail("dense array required");
23
+ return Array.from({ length: value.length }, (_, index) => {
24
+ const field = fields[String(index)];
25
+ if (!field || !Object.hasOwn(field, "value") || !field.enumerable) return fail("array data fields required");
26
+ return field.value;
27
+ });
28
+ }
29
+ /** Bounded inert metadata, usable by CLI/config readers without executing module callbacks. */
30
+ export function externalAdapterMetadata(raw: unknown): readonly ExternalAdapterMetadata[] {
31
+ if (raw === undefined) return Object.freeze([]);
32
+ const rows = moduleArray(raw, MAX_MODULES).map(value => {
33
+ const row = moduleDataObject(value), kind = row.kind;
34
+ if (Object.keys(row).some(key => !["kind", "displayName"].includes(key)) || typeof kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(kind)) return fail("invalid external display metadata");
35
+ return Object.freeze({ kind, displayName: moduleText(row.displayName, 256) });
36
+ });
37
+ if (new Set(rows.map(row => row.kind)).size !== rows.length) return fail("duplicate external display metadata");
38
+ return Object.freeze(rows);
39
+ }
40
+ export function externalMetadataDefinitions(raw: unknown): readonly AdapterDefinition[] {
41
+ return Object.freeze(externalAdapterMetadata(raw).map(({ kind, displayName }): AdapterDefinition => ({
42
+ api: ADAPTER_API, kind, family: kind, displayName, transport: "acp", roles: [], optional: true, defaultOnRoster: false,
43
+ trust: { onPrem: false, hubSandboxed: false }, identity: { mcp: "acp-session" }, permission: { genericAcp: {}, unmanaged: "external adapter metadata; authority belongs to the running daemon" },
44
+ start: { forms: ["headless"], defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
45
+ session: { idField: "sessionId", currentId: () => undefined }, turnEnd: "adapter",
46
+ cli: { command: kind, startWords: () => [kind] }, display: { palette: { fg: "white", bg: "black" } },
47
+ })));
48
+ }
@@ -0,0 +1,126 @@
1
+ interface Token { kind: "word" | "string" | "punct"; value: string }
2
+ const forbiddenNames = new Set(["require", "createRequire", "eval", "Function"]);
3
+ const globalNames = new Set(["globalThis", "global", "self", "window"]);
4
+ const regexAfterWords = new Set(["return", "throw", "case", "delete", "typeof", "void", "yield", "await", "in", "of", "else", "do"]);
5
+ const regexAfterPunctuation = new Set(["=", "(", "[", "{", ":", ",", ";", "!", "?", "&&", "||", "??", "=>", "+", "-", "*", "%", "&", "|", "^", "~", "<", ">"]);
6
+ const fail = (): never => { throw new Error("external adapter: module source scan refused (details withheld)"); };
7
+
8
+ /** Lex normalized JS, retaining literal tokens only for direct computed global/member access. */
9
+ function tokensOf(source: string): Token[] {
10
+ const tokens: Token[] = [];
11
+ let index = 0;
12
+ const controlParens: boolean[] = [];
13
+ let regexAllowed = true;
14
+ const push = (kind: Token["kind"], value: string) => {
15
+ const previous = tokens.at(-1);
16
+ if (kind === "punct" && value === "(") controlParens.push(previous?.kind === "word" && ["if", "while", "for", "with", "switch", "catch"].includes(previous.value));
17
+ const controlEnd = kind === "punct" && value === ")" ? controlParens.pop() === true : false;
18
+ tokens.push({ kind, value });
19
+ regexAllowed = controlEnd || (kind === "word" ? regexAfterWords.has(value) : kind === "punct" && regexAfterPunctuation.has(value));
20
+ };
21
+ const wordStart = (char: string) => /[$_\p{ID_Start}]/u.test(char);
22
+ const wordPart = (char: string) => /[$_\p{ID_Continue}]/u.test(char);
23
+ const quoted = (quote: string) => {
24
+ index++;
25
+ let value = "";
26
+ while (index < source.length) {
27
+ const char = source[index++]!;
28
+ if (char === quote) { push("string", value); return; }
29
+ if (char === "\\") {
30
+ const escaped = source[index++];
31
+ // Unknown escapes are not a proof of a statically named member. Transform normally canonicalizes them.
32
+ value += escaped === quote || escaped === "\\" ? escaped : "?";
33
+ } else value += char;
34
+ }
35
+ fail();
36
+ };
37
+ const regex = () => {
38
+ index++;
39
+ let characterClass = false;
40
+ while (index < source.length) {
41
+ const char = source[index++]!;
42
+ if (char === "\\") { index++; continue; }
43
+ if (char === "[") characterClass = true;
44
+ else if (char === "]") characterClass = false;
45
+ else if (char === "/" && !characterClass) {
46
+ while (index < source.length && /[a-z]/i.test(source[index]!)) index++;
47
+ push("string", "");
48
+ return;
49
+ }
50
+ }
51
+ fail();
52
+ };
53
+ const template = (depth: number) => {
54
+ if (depth > 128) fail();
55
+ index++;
56
+ let text = "";
57
+ while (index < source.length) {
58
+ const char = source[index]!;
59
+ if (source.charCodeAt(index) === 96) { index++; push("string", text); return; }
60
+ if (char === "\\") { index += 2; text += "?"; continue; }
61
+ if (char === "$" && source[index + 1] === "{") {
62
+ push("string", text); text = ""; index += 2;
63
+ push("punct", "(");
64
+ code(true, depth + 1);
65
+ push("punct", ")");
66
+ } else { text += char; index++; }
67
+ }
68
+ fail();
69
+ };
70
+ const code = (stopAtBrace: boolean, depth: number) => {
71
+ if (depth > 128) fail();
72
+ let braces = 0;
73
+ while (index < source.length) {
74
+ const char = source[index]!, next = source[index + 1];
75
+ if (/\s/u.test(char)) { index++; continue; }
76
+ if (char === "/" && next === "/") {
77
+ index += 2; while (index < source.length && source[index] !== "\n") index++;
78
+ continue;
79
+ }
80
+ if (char === "/" && next === "*") {
81
+ const end = source.indexOf("*/", index + 2);
82
+ if (end < 0) fail(); index = end + 2; continue;
83
+ }
84
+ if (char === "'" || char === '"') { quoted(char); continue; }
85
+ if (source.charCodeAt(index) === 96) { template(depth + 1); continue; }
86
+ if (char === "}" && stopAtBrace && braces === 0) { index++; return; }
87
+ if (char === "{") braces++;
88
+ if (char === "}") braces--;
89
+ if (char === "/" && regexAllowed) { regex(); continue; }
90
+ if (wordStart(char)) {
91
+ const start = index++;
92
+ while (index < source.length && wordPart(source[index]!)) index++;
93
+ push("word", source.slice(start, index)); continue;
94
+ }
95
+ const pair = char + (next ?? "");
96
+ if (["=>", "&&", "||", "??", "++", "--", "?."].includes(pair)) { push("punct", pair); index += 2; }
97
+ else { push("punct", char); index++; }
98
+ }
99
+ if (stopAtBrace) fail();
100
+ };
101
+ code(false, 0);
102
+ return tokens;
103
+ }
104
+
105
+ /**
106
+ * Conservative ESM dependency/code-loading inspection, not a sandbox or proof against obfuscated privileged code.
107
+ * Literal messages/comments are inert. Direct code-loading names are refused even when aliased or shadowed.
108
+ */
109
+ export function scanModuleSource(source: string): readonly Bun.Import[] {
110
+ try {
111
+ const transpiler = new Bun.Transpiler({ loader: "js", minifyWhitespace: true });
112
+ const imports = transpiler.scanImports(source);
113
+ if (imports.some(record => ["dynamic-import", "require-call", "require-resolve"].includes(record.kind))) fail();
114
+ const tokens = tokensOf(transpiler.transformSync(source));
115
+ for (const [index, token] of tokens.entries()) {
116
+ if (token.kind === "word" && forbiddenNames.has(token.value)) fail();
117
+ if (token.kind === "word" && token.value === "import" && tokens[index + 1]?.value === "(") fail();
118
+ if (token.kind === "string" && forbiddenNames.has(token.value) && tokens[index - 1]?.value === "[" && tokens[index + 1]?.value === "]") {
119
+ const owner = tokens[index - 2];
120
+ const importMeta = owner?.value === "meta" && tokens[index - 3]?.value === "." && tokens[index - 4]?.value === "import";
121
+ if (owner?.kind === "word" && globalNames.has(owner.value) || importMeta) fail();
122
+ }
123
+ }
124
+ return Object.freeze(imports.map(record => Object.freeze({ ...record })));
125
+ } catch { return fail(); }
126
+ }
@@ -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
+
@@ -286,3 +286,7 @@ export class JcodePeer extends AcpPeer {
286
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 } : {}) } };
287
287
  }
288
288
  }
289
+
290
+ export function isJcodePeer(value: unknown): value is JcodePeer {
291
+ return value instanceof JcodePeer;
292
+ }
@@ -490,3 +490,7 @@ function safeParse(json: string): Record<string, unknown> {
490
490
  return {};
491
491
  }
492
492
  }
493
+
494
+ export function isLocalPeer(value: unknown): value is LocalPeer {
495
+ return value instanceof LocalPeer;
496
+ }
@@ -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);
@@ -945,3 +947,7 @@ export class PiPeer extends BasePeer {
945
947
  void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
946
948
  }
947
949
  }
950
+
951
+ export function isPiPeer(value: unknown): value is PiPeer {
952
+ return value instanceof PiPeer;
953
+ }
@@ -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";