@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
@@ -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
 
@@ -90,6 +93,7 @@ export interface VersionContext {
90
93
  env: Readonly<Record<string, string>>;
91
94
  }
92
95
 
96
+ /** Compatibility type for frozen A1 permission metadata, not a runtime permission port. */
93
97
  export type NativeModeSpec =
94
98
  | { kind: "acp-mode"; modeId: string }
95
99
  | { kind: "codex-approval"; policy: string }
@@ -100,7 +104,6 @@ export type NativeModeSpec =
100
104
  export interface QuotaReader {
101
105
  source: string;
102
106
  read(value: unknown): UsageWindow[];
103
- resetOnWindow?: boolean;
104
107
  }
105
108
 
106
109
  export interface ContextReader {
@@ -145,12 +148,7 @@ export interface NativeFactsPort {
145
148
 
146
149
  /** Only the frozen native seams are extensible; a definition cannot add an arbitrary daemon callback. */
147
150
  export interface NativeHooks {
148
- agentIdentity(init: unknown): void;
149
- onAttach(instance: string): void;
150
- onDetach(instance: string): void;
151
151
  factsHook: NativeFactsPort;
152
- conversationRevert(turnId: string): Promise<void>;
153
- replaceSession(req: StartRequest): Promise<unknown>;
154
152
  pluginInventory(ctx: { home: string; cwd?: string; env: Readonly<Record<string, string | undefined>> }): Addon[];
155
153
  recallSource: string;
156
154
  recallInjected: boolean;
@@ -182,8 +180,8 @@ export interface AdapterDefinition {
182
180
  start: {
183
181
  forms: readonly StartForm[];
184
182
  defaultForm: StartForm;
185
- ownsTerminal(form: StartForm): boolean;
186
- launch(ctx: LaunchContext): LaunchPlan;
183
+ /** A real native argv planner; absent when startup belongs to a separate verified host port. */
184
+ launch?(ctx: LaunchContext): LaunchPlan;
187
185
  create?(ctx: CreateContext): PeerAdapter;
188
186
  daemonPort?: DaemonEngine;
189
187
  preflight?(ctx: StartRequest): string | undefined;
@@ -203,16 +201,20 @@ export interface AdapterDefinition {
203
201
  conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
204
202
  };
205
203
  identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin" };
204
+ /** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
205
+ instances?: { mode: "external" | "headless"; roles?: readonly string[] };
206
206
  attach?: {
207
+ /** The authenticated external hello may claim this id; independent of MCP configuration shape. */
208
+ externalHello?: boolean;
207
209
  pullOnlyForms?: readonly StartForm[];
208
210
  recoveryLaunch?(instance: string): Record<string, unknown>;
209
211
  pullOnlyNotice?: string;
210
212
  admission?: { reservedRefusal: string; watchRefusal: string };
211
213
  };
212
214
  permission: {
213
- modes: Partial<Record<PermissionMode, NativeModeSpec>>;
215
+ /** Consumed configured-ACP mode capability; native runtime and instance ceiling use the same validated mapping. */
216
+ genericAcp?: { modes?: Partial<Record<PermissionMode, string>>; readOnly?: string };
214
217
  grants?: Partial<Record<PermissionMode, string>>;
215
- readOnly?: NativeModeSpec;
216
218
  unmanaged?: string;
217
219
  unmanagedForms?: readonly StartForm[];
218
220
  unmanagedNote?: string;
@@ -273,14 +275,14 @@ export interface AdapterDefinition {
273
275
  validateEffective?(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void;
274
276
  };
275
277
  capabilities?: { manifest?(peer: PeerAdapter): Promise<CapabilityManifest>; watch?: boolean };
276
- init?: {
277
- templateFragment?: string;
278
- mcpFile?: string;
279
- /** Pure planner; the CLI host owns every write and cleanup. */
280
- mcpPlan?(ctx: { projectRoot: string; stateDir: string; entrypoint: string; form: StartForm }): { file: string; tracked: boolean; changed: boolean; action: string };
281
- };
278
+ /** Stable host projection order and planner eligibility, independent of default task roles. */
279
+ projection?: { roleRank?: number; startRank?: number; helpRank?: number; plannerEligible?: boolean };
282
280
  cli: {
283
281
  command: string;
282
+ /** Fixed builtin CLI ports; project definitions cannot supply these callbacks. */
283
+ entry?: Readonly<Omit<BuiltinCliEntry, "command">>;
284
+ preview?: NativePreviewBuilder;
285
+ usage?: readonly (readonly [usage: string, description: string])[];
284
286
  /** Configuration leaves that select this peer's next native launch command or provider profile. */
285
287
  launchConfigKeys?: readonly string[];
286
288
  /** Restrictions only; core identity remains authoritative. */
@@ -302,3 +304,13 @@ export interface AdapterDefinition {
302
304
 
303
305
  /** Explicit project declarations, passed by the bound host. No process-global instance registration. */
304
306
  export type KindMap = Readonly<Record<string, { readonly kind: string; readonly roles?: readonly string[]; readonly start_mode?: "tui" | "headless"; readonly review_mode?: "fresh" }>>;
307
+
308
+ /** Immutable per-project resolver; exported globals remain builtin-only. */
309
+ export interface ProjectAdapterCatalog {
310
+ readonly definitions: readonly AdapterDefinition[];
311
+ readonly externalDefinitions: readonly AdapterDefinition[];
312
+ definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined;
313
+ kindOf(id: string, instances?: KindMap): string | undefined;
314
+ familyOf(id: string, instances?: KindMap): string | undefined;
315
+ isSupportedPeer(id: unknown, instances?: KindMap): id is string;
316
+ }
@@ -6,18 +6,14 @@ import { DAEMON_PORTS } from "./definitions/native-factories.ts";
6
6
  export { DAEMON_PORTS };
7
7
  import { NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN } from "./definitions/builtin-metadata.ts";
8
8
  import { QWEN_DEFINITION } from "./definitions/qwen.ts";
9
- import type { AdapterDefinition, KindMap } from "./registry-types.ts";
9
+ import { configuredAcpDefinitions } from "./configured-acp.ts";
10
+ import type { AdapterDefinition, KindMap, ProjectAdapterCatalog } from "./registry-types.ts";
10
11
  import { types } from "node:util";
11
12
 
12
13
  export type * from "./registry-types.ts";
13
- export const ADAPTER_API = 1;
14
+ import { ADAPTER_API } from "./adapter-api.ts";
15
+ export { ADAPTER_API };
14
16
 
15
- /** Ordered compatibility projections stay typed independently of definition implementation inference. */
16
- export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
17
- export const OPTIONAL_PEERS = ["qwen", "jcode", "agy"] as const;
18
- export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
19
- export const START_MODE_PEERS = ["pi", "codex", "kimi", "jcode"] as const;
20
- export const OFFICE_PEER_ORDER: readonly string[] = KNOWN_PEERS;
21
17
 
22
18
 
23
19
  function immutable<T>(value: T, seen = new WeakSet<object>()): T {
@@ -37,13 +33,38 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
37
33
  ...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind === "jcode"),
38
34
  AGY_DEFINITION,
39
35
  ]);
36
+ export interface AdapterLists {
37
+ readonly KNOWN_PEERS: readonly string[]; readonly OPTIONAL_PEERS: readonly string[]; readonly SUPPORTED_PEERS: readonly string[];
38
+ readonly START_MODE_PEERS: readonly string[]; readonly ROLE_ORDER: readonly string[]; readonly HELP_ORDER: readonly string[]; readonly PLANNER_PEERS: readonly string[];
39
+ }
40
+ /** Pure builtin projections. Unranked definitions retain their source order after ranked definitions. */
41
+ export function deriveAdapterLists(definitions: readonly AdapterDefinition[]): AdapterLists {
42
+ const ordered = (field: "roleRank" | "startRank" | "helpRank", eligible = (_definition: AdapterDefinition) => true) => definitions
43
+ .map((definition, index) => ({ definition, index })).filter(({ definition }) => eligible(definition))
44
+ .sort((left, right) => {
45
+ const a = left.definition.projection?.[field], b = right.definition.projection?.[field];
46
+ if (a === undefined) return b === undefined ? left.index - right.index : 1;
47
+ if (b === undefined) return -1;
48
+ return a - b || left.index - right.index;
49
+ }).map(({ definition }) => definition.kind);
50
+ const KNOWN_PEERS = definitions.filter(definition => !definition.optional).map(definition => definition.kind);
51
+ const OPTIONAL_PEERS = definitions.filter(definition => definition.optional).map(definition => definition.kind);
52
+ return immutable({ KNOWN_PEERS, OPTIONAL_PEERS, SUPPORTED_PEERS: [...KNOWN_PEERS, ...OPTIONAL_PEERS],
53
+ START_MODE_PEERS: ordered("startRank", definition => definition.start.forms.includes("tui") && definition.start.forms.includes("headless")),
54
+ ROLE_ORDER: ordered("roleRank"), HELP_ORDER: ordered("helpRank"),
55
+ PLANNER_PEERS: definitions.filter(definition => definition.projection?.plannerEligible === true).map(definition => definition.kind),
56
+ });
57
+ }
58
+ export const { KNOWN_PEERS, OPTIONAL_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, ROLE_ORDER, PLANNER_PEERS } = deriveAdapterLists(ADAPTERS);
59
+ export const OFFICE_PEER_ORDER: readonly string[] = KNOWN_PEERS;
60
+
40
61
  // Unavailable declarations are metadata, not supported native peers or launchers.
41
62
  const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([]);
42
63
  const definitions = new Map<string, AdapterDefinition>();
43
64
  for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
44
- assertDaemonEngine(definition);
65
+ assertDefinitionCoherence(definition);
45
66
  assertNativeCapabilityScope(definition);
46
- if (definition.reviewerVariant) { assertDaemonEngine(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
67
+ if (definition.reviewerVariant) { assertDefinitionCoherence(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
47
68
  if (definition.api !== ADAPTER_API || definitions.has(definition.kind)) throw new Error("invalid builtin adapter registry");
48
69
  definitions.set(definition.kind, definition);
49
70
  }
@@ -65,7 +86,7 @@ function ownField(value: unknown, key: string): unknown {
65
86
  return field && Object.hasOwn(field, "value") && field.enumerable ? field.value : undefined;
66
87
  }
67
88
  /** Declaration reads never execute caller accessors or inherit another project's mapping. */
68
- function declaredKind(id: string, instances: KindMap | undefined): string | undefined {
89
+ function declaredKind(id: string, instances: KindMap | undefined, catalog: ReadonlyMap<string, AdapterDefinition> = definitions): string | undefined {
69
90
  if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
70
91
  || ![Object.prototype, null].includes(Object.getPrototypeOf(instances))) return undefined;
71
92
  const field = Object.getOwnPropertyDescriptor(instances, id);
@@ -74,7 +95,7 @@ function declaredKind(id: string, instances: KindMap | undefined): string | unde
74
95
  if (!entry || typeof entry !== "object" || types.isProxy(entry) || Array.isArray(entry)
75
96
  || ![Object.prototype, null].includes(Object.getPrototypeOf(entry))) return undefined;
76
97
  const kind = Object.getOwnPropertyDescriptor(entry, "kind");
77
- if (!kind || !Object.hasOwn(kind, "value") || !kind.enumerable || typeof kind.value !== "string" || !definitions.has(kind.value)) return undefined;
98
+ if (!kind || !Object.hasOwn(kind, "value") || !kind.enumerable || typeof kind.value !== "string" || !catalog.has(kind.value)) return undefined;
78
99
  const prefix = `${kind.value}-`;
79
100
  return /^[a-z][a-z0-9-]{0,31}$/.test(id) && id.startsWith(prefix) && /^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length)) ? kind.value : undefined;
80
101
  }
@@ -84,7 +105,10 @@ export function kindOf(id: string, instances?: KindMap): string | undefined {
84
105
  }
85
106
  export function definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined {
86
107
  const kind = kindOf(id, instances);
87
- const definition = kind === undefined ? undefined : definitions.get(kind);
108
+ return resolvedDefinition(id, kind, instances, definitions);
109
+ }
110
+ function resolvedDefinition(id: string, kind: string | undefined, instances: KindMap | undefined, catalog: ReadonlyMap<string, AdapterDefinition>): AdapterDefinition | undefined {
111
+ const definition = kind === undefined ? undefined : catalog.get(kind);
88
112
  // #223: only a declared extra (never the builtin id) with start_mode "headless" runs as the kind's reviewer variant.
89
113
  const declaration = ownField(instances, id), roles = ownField(declaration, "roles");
90
114
  const reviewerOnly = Array.isArray(roles) && !types.isProxy(roles) && roles.length === 1 && Object.getOwnPropertyDescriptor(roles, "0")?.value === "reviewer";
@@ -96,9 +120,33 @@ export function familyOf(id: string, instances?: KindMap): string | undefined {
96
120
  return definitionOf(id, instances)?.family;
97
121
  }
98
122
 
123
+ /** Compile one captured, host-trusted declaration set; never register native policy globally. */
124
+ export function createProjectAdapterCatalog(rawAdapters?: unknown, externalDefinitions: readonly AdapterDefinition[] = []): ProjectAdapterCatalog {
125
+ const configured = configuredAcpDefinitions(rawAdapters, [...definitions.keys()]);
126
+ for (const definition of configured) { assertDefinitionCoherence(definition); assertNativeCapabilityScope(definition); }
127
+ const seen = new Set([...definitions.keys(), ...configured.map(item => item.kind)]);
128
+ for (const definition of externalDefinitions) {
129
+ if (definition.api !== ADAPTER_API || seen.has(definition.kind)) throw new Error("external adapter kind or API collision");
130
+ assertDefinitionCoherence(definition); assertNativeCapabilityScope(definition);
131
+ seen.add(definition.kind);
132
+ }
133
+ const external = immutable([...externalDefinitions]);
134
+ const entries = Object.freeze([...ADAPTERS, ...configured, ...external]);
135
+ const catalog = new Map([...definitions, ...[...configured, ...external].map(definition => [definition.kind, definition] as const)]);
136
+ const kindOf = (id: string, instances?: KindMap): string | undefined => catalog.has(id) ? id : typeof id === "string" ? declaredKind(id, instances, catalog) : undefined;
137
+ const definitionOf = (id: string, instances?: KindMap): AdapterDefinition | undefined => resolvedDefinition(id, kindOf(id, instances), instances, catalog);
138
+ return Object.freeze({ definitions: entries, externalDefinitions: external, kindOf, definitionOf,
139
+ familyOf: (id: string, instances?: KindMap) => definitionOf(id, instances)?.family,
140
+ isSupportedPeer: (id: unknown, instances?: KindMap): id is string => {
141
+ if (typeof id !== "string") return false;
142
+ const kind = kindOf(id, instances), runtime = kind === undefined ? undefined : catalog.get(kind), resolved = definitionOf(id, instances);
143
+ return runtime !== undefined && resolved !== undefined && (kind === id || resolved === runtime || resolved.reviewer?.unavailable === undefined);
144
+ },
145
+ });
146
+ }
147
+
99
148
  /** Compatibility projection order is observable in default role maps and planner selection. */
100
- const ROLE_ORDER = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode", "agy"] as const;
101
- export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
149
+
102
150
 
103
151
  /** Callers own their role arrays, independent of immutable native policy. */
104
152
  export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
@@ -126,6 +174,52 @@ export function assertDaemonEngine(definition: Pick<AdapterDefinition, "start">)
126
174
  if (definition.start.create && engine === undefined || engine !== undefined && !Object.hasOwn(DAEMON_PORTS, engine)) throw new Error("invalid adapter daemon engine");
127
175
  }
128
176
 
177
+ /** Structural registry contract only: native telemetry readers are never executed during loading. */
178
+ export function assertDefinitionCoherence(definition: AdapterDefinition): void {
179
+ const fail = (field: string, reason: string): never => { throw new Error(`${definition.kind}.${field}: ${reason}`); };
180
+ const nonempty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
181
+ assertDaemonEngine(definition);
182
+ if (definition.identity.mcp === "channel-plugin" && definition.transport !== "claude-channel" && definition.transport !== "ws-pull") {
183
+ fail("identity.mcp", "channel-plugin requires claude-channel or ws-pull transport");
184
+ }
185
+ if (definition.turnEnd === "native-hook" && definition.transport !== "claude-channel" && !definition.hooks?.factsHook) {
186
+ fail("turnEnd", "native-hook requires the Claude transport or a native factsHook");
187
+ }
188
+ const projection = definition.projection;
189
+ if (projection !== undefined) {
190
+ if (!projection || typeof projection !== "object" || Array.isArray(projection) || Object.keys(projection).some(key => !["roleRank", "startRank", "helpRank", "plannerEligible"].includes(key))) fail("projection", "invalid projection metadata");
191
+ for (const field of ["roleRank", "startRank", "helpRank"] as const) if (projection[field] !== undefined && (typeof projection[field] !== "number" || !Number.isFinite(projection[field]))) fail(`projection.${field}`, "rank must be finite");
192
+ if (projection.plannerEligible !== undefined && typeof projection.plannerEligible !== "boolean") fail("projection.plannerEligible", "boolean required");
193
+ }
194
+ const attach = definition.attach, pullOnly = attach?.pullOnlyForms;
195
+ if (pullOnly !== undefined) {
196
+ if (!Array.isArray(pullOnly) || pullOnly.some(form => !definition.start.forms.includes(form))) fail("attach.pullOnlyForms", "each pull-only form must be a supported start form");
197
+ }
198
+ if (definition.transport === "ws-pull" || definition.transport === "claude-channel" || definition.instances?.mode === "external") {
199
+ if (attach?.externalHello !== true) fail("attach.externalHello", "external transport or instance admission requires an explicit hello policy");
200
+ }
201
+ if (definition.transport === "ws-pull" && !pullOnly?.length) fail("attach.pullOnlyForms", "ws-pull requires a nonempty pull-only contract");
202
+ if (attach?.admission !== undefined) {
203
+ if (!attach.admission || typeof attach.admission !== "object") fail("attach.admission", "admission must declare its refusal policy");
204
+ if (!definition.start.forms.includes("tui") || !pullOnly?.includes("tui")) fail("attach.admission", "native admission requires a supported pull-only TUI form");
205
+ for (const field of ["reservedRefusal", "watchRefusal"] as const) {
206
+ if (!nonempty(attach.admission[field])) fail(`attach.admission.${field}`, "admission refusal text must be nonempty");
207
+ }
208
+ }
209
+ if (definition.budget !== undefined) {
210
+ if (!nonempty(definition.budget?.account)) fail("budget.account", "declared budget account must be a nonempty string");
211
+ for (const field of ["reader", "context"] as const) {
212
+ const reader = definition.budget[field];
213
+ if (reader === undefined) continue;
214
+ if (!reader || typeof reader !== "object") fail(`budget.${field}`, "telemetry reader must be an object");
215
+ if (!nonempty(reader.source)) fail(`budget.${field}.source`, "telemetry source must be a nonempty string");
216
+ if (typeof reader.read !== "function") fail(`budget.${field}.read`, "telemetry read must be callable");
217
+ }
218
+ }
219
+ if (definition.reviewerVariant && !definition.reviewerVariant.reviewer) fail("reviewerVariant.reviewer", "reviewer variant lacks reviewer policy");
220
+ if (definition.reviewer?.unavailable && (definition.start.create || definition.reviewer.backend)) fail("reviewer", "unavailable reviewer must remain metadata-only");
221
+ }
222
+
129
223
  /** Host-global native quirks are not capabilities a config-only or external definition may opt into. */
130
224
  export function assertNativeCapabilityScope(definition: AdapterDefinition): void {
131
225
  if (fixedBuiltinDefinitions.has(definition)) return;
@@ -137,9 +231,13 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
137
231
  ["session.crashIdentity", declared(definition.session, "crashIdentity")],
138
232
  ["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
139
233
  ["routing", definition.routing !== undefined],
234
+ ["projection", definition.projection !== undefined],
140
235
  ["reviewerVariant", definition.reviewerVariant !== undefined],
141
236
  ["reviewer", definition.reviewer !== undefined],
142
237
  ["hooks.factsHook", declared(definition.hooks, "factsHook")],
238
+ ["cli.entry", declared(definition.cli, "entry")],
239
+ ["cli.preview", declared(definition.cli, "preview")],
240
+ ["cli.usage", declared(definition.cli, "usage")],
143
241
  ];
144
242
  const field = restricted.find(([, present]) => present)?.[0];
145
243
  if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
@@ -174,8 +272,8 @@ export function builtinPreviewOf(id: string) {
174
272
  /** Fixed builtin doctor metadata and pure observed-value projections; the CLI retains all probe effects. */
175
273
  export { DOCTOR_TOOL_PROBES, DOCTOR_MEMORY_CAPTURE_PEERS, DOCTOR_PLUGIN_PROBE, nativeDoctorCheck, nativeDoctorRecoveryIdentity, nativeDoctorOptionalProbes } from "./definitions/native-cli-doctor.ts";
176
274
 
177
- /** Internal fixed builtin CLI entries; public AdapterDefinition and NativeHooks expose no execution extension. */
178
- export const BUILTIN_CLI_ENTRIES = Object.freeze(builtinCliEntries.map(entry => {
275
+ /** Definition-owned CLI entries are restricted to fixed builtins, never config or external execution extensions. */
276
+ export const BUILTIN_CLI_ENTRIES = Object.freeze(builtinCliEntries(ADAPTERS).map(entry => {
179
277
  const definition = definitionOf(entry.command);
180
278
  if (!definition) throw new Error("builtin CLI entry has no adapter definition");
181
279
  return Object.freeze({ ...entry, definition });
@@ -380,3 +380,7 @@ export class ReviewerPrintPeer extends BasePeer {
380
380
  if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childStop = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
381
381
  }
382
382
  }
383
+
384
+ export function isReviewerPrintPeer(value: unknown): value is ReviewerPrintPeer {
385
+ return value instanceof ReviewerPrintPeer;
386
+ }
@@ -1,3 +1,4 @@
1
+ import { AGY_DEFINITION } from "../adapters/agy/definition.ts";
1
2
  import type { Stats } from "node:fs";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
@@ -53,7 +54,7 @@ function shellUnquote(value: string): string | undefined {
53
54
  }
54
55
 
55
56
  function validateOptions(options: AgyMcpOptions) {
56
- const peer = options.peer ?? "agy";
57
+ const peer = options.peer ?? AGY_DEFINITION.kind;
57
58
  if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer)) throw new Error("agy peer id is invalid");
58
59
  for (const [name, value] of Object.entries({ projectRoot: options.projectRoot, stateDir: options.stateDir, serverJs: options.serverJs, hookScript: options.hookScript })) {
59
60
  if (typeof value !== "string" || !isAbsolute(value) || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) throw new Error(`${name} must be an absolute control-free path`);
@@ -326,7 +327,7 @@ export function cleanupAgyMcp(options: AgyMcpOptions): void {
326
327
  if (!present.length) return;
327
328
  if (present.length !== FILES.length) throw new Error("managed agy plugin is incomplete; inspect it before cleanup");
328
329
  const currentHooks = parseJson(readFileSync(join(plan.root, HOOKS_FILE)), HOOKS_FILE);
329
- const launch = managedHookLaunch(currentHooks, options, options.peer ?? "agy");
330
+ const launch = managedHookLaunch(currentHooks, options, options.peer ?? AGY_DEFINITION.kind);
330
331
  if (!launch || launch.instanceId !== options.instanceId || launch.launchId !== options.launchId) return;
331
332
  // A stale cleanup never removes files a newer config update has replaced.
332
333
  if (plan.changed) return;
@@ -0,0 +1,58 @@
1
+ import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
2
+ import type { InstancesConfig } from "../hub/instances.ts";
3
+ import { requireActiveInstance, requirePeerInstances } from "./launch.ts";
4
+ import { configuredAcpPreview } from "./preview.ts";
5
+
6
+ export interface ConfiguredAcpSelection { peer: string; mode: "headless"; sessionId?: string; preview: boolean }
7
+ export function parseConfiguredAcpArguments(kind: string, raw: readonly string[], catalog: ProjectAdapterCatalog, instances?: InstancesConfig): ConfiguredAcpSelection {
8
+ const definition = catalog.definitionOf(kind);
9
+ if (!definition?.permission.genericAcp) throw new Error("unknown configured ACP kind");
10
+ const values = new Map<string, string>();
11
+ let preview = false;
12
+ for (let index = 0; index < raw.length; index++) {
13
+ const arg = raw[index]!;
14
+ if (arg === "--print-command" || arg === "--dry-run") { if (preview) throw new Error("preview option may appear only once"); preview = true; continue; }
15
+ const equal = arg.indexOf("="), flag = equal < 0 ? arg : arg.slice(0, equal);
16
+ if (!["--as", "--mode", "--session-id"].includes(flag) || values.has(flag)) throw new Error("configured ACP accepts only --mode headless, --as, --session-id and one preview option");
17
+ const value = equal < 0 ? raw[++index] : arg.slice(equal + 1);
18
+ if (!value || value.startsWith("--") || Buffer.byteLength(value) > 4096 || /[\p{Cc}\p{Cf}]/u.test(value)) throw new Error(`${flag} needs a bounded control-free value`);
19
+ values.set(flag, value);
20
+ }
21
+ if (values.has("--mode") && values.get("--mode") !== "headless") throw new Error("configured ACP supports headless only");
22
+ const peer = values.get("--as") ?? kind;
23
+ if (values.has("--as") && (!instances || !Object.hasOwn(instances, peer) || instances[peer]?.kind !== kind || !catalog.definitionOf(peer, instances))) throw new Error("--as must name a declared instance of this configured kind");
24
+ return { peer, mode: "headless", ...(values.has("--session-id") ? { sessionId: values.get("--session-id")! } : {}), preview };
25
+ }
26
+ export interface ConfiguredAcpControl {
27
+ features: readonly string[];
28
+ request(message: Record<string, unknown>, timeout?: number): Promise<any>;
29
+ close(): void;
30
+ }
31
+ export async function requireActiveConfiguredAcp(hub: ConfiguredAcpControl, kind: string, peer: string, warn?: (text: string) => void): Promise<void> {
32
+ if (!hub.features.includes("configured_acp")) throw new Error("running hub lacks configured_acp; upgrade and restart it first");
33
+ if (peer !== kind) requirePeerInstances(hub.features);
34
+ const reply = await hub.request({ t: "status" });
35
+ const active = reply.status?.configuredAdapters;
36
+ if (!Array.isArray(active) || !active.includes(kind)) throw new Error(`running hub has not loaded ${kind}; restart it after declaring the adapter`);
37
+ if (peer !== kind) requireActiveInstance(reply.status, peer);
38
+ const namespace = `adapters.${kind}`;
39
+ const retained = [reply.status?.retainedConfig, reply.status?.pendingRestart].some(rows => Array.isArray(rows) && rows.some(row => {
40
+ const key = row && typeof row === "object" ? row.key : undefined;
41
+ return typeof key === "string" && (key === "adapters" || key === namespace || key.startsWith(`${namespace}.`));
42
+ }));
43
+ if (retained) warn?.(`${kind}: configured adapter changes are retained; this start uses the running definition. Restart the hub to apply the file's definition.`);
44
+ }
45
+ export async function runConfiguredAcpEntry(context: {
46
+ kind: string; raw: readonly string[]; catalog: ProjectAdapterCatalog; instances?: InstancesConfig;
47
+ connect(): Promise<ConfiguredAcpControl>; print(text: string): void; operationId?: string;
48
+ }): Promise<void> {
49
+ const selected = parseConfiguredAcpArguments(context.kind, context.raw, context.catalog, context.instances);
50
+ if (selected.preview) { context.print(JSON.stringify(configuredAcpPreview(context.kind, selected.peer, selected.sessionId !== undefined), null, 2)); return; }
51
+ const hub = await context.connect();
52
+ try {
53
+ await requireActiveConfiguredAcp(hub, context.kind, selected.peer, context.print);
54
+ const reply = await hub.request({ t: "start", peer: selected.peer, args: { mode: "headless", ...(selected.sessionId ? { sessionId: selected.sessionId } : {}) }, ...(context.operationId ? { operationId: context.operationId } : {}) }, 70_000);
55
+ if (!reply.ok) throw new Error(typeof reply.error === "string" ? reply.error : "configured ACP start refused");
56
+ context.print(reply.already ? `${selected.peer} is already attached` : `${selected.peer} attached (headless); use ahub say @${selected.peer} and the console for approvals`);
57
+ } finally { hub.close(); }
58
+ }
@@ -2,7 +2,7 @@ import { consoleNeeds, characterAttentionLine, attentionCommand } from "./attent
2
2
  import { CHARACTERS, petFrame, petSize, petFooterRows, type CharacterId, type PetSize, type PetPose } from "../pet/frame.ts";
3
3
  import { PEER_PALETTE } from "../ui/peer-palette.ts";
4
4
  import { PERMISSION_MODES, permissionBoundary } from "../hub/permission-mode.ts";
5
- import { PLANNER_PEERS, definitionOf, nativeStreamTone } from "../adapters/registry.ts";
5
+ import { PLANNER_PEERS, definitionOf, nativeStreamTone, createProjectAdapterCatalog } from "../adapters/registry.ts";
6
6
  import { taskProgress } from "../ui/task-progress.ts";
7
7
  import type { ProgressStage } from "../ui/task-progress.ts";
8
8
  import { sanitize } from "../hub/envelope.ts";
@@ -91,7 +91,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
91
91
  return true;
92
92
  }
93
93
  export function permissionDefaultText(row: Pick<PermissionDefault, "peer" | "source">): string {
94
- return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer)}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
94
+ return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer, undefined, createProjectAdapterCatalog())}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
95
95
  }
96
96
  /** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
97
97
  export function terminalText(value: unknown): string {
@@ -471,6 +471,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
471
471
  if (s.settingsEdit) {
472
472
  const edit = { ...s.settingsEdit }; s.settingsEdit = edit;
473
473
  if (key === "\x1b") { s.settingsEdit = undefined; return done(); }
474
+ const locked = s.settingsRows?.find(row => row.key === edit.key)?.locked;
475
+ if (locked) { s.settingsEdit = undefined; notify(s, locked, now); return done(); }
474
476
  if (edit.phase === "preview") return done();
475
477
  if (edit.phase === "confirm") {
476
478
  s.settingsEdit = undefined;
@@ -632,6 +634,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
632
634
  const row = selectedPanelRow(s);
633
635
  if (key === "u" && !s.settingsUndo) { notify(s, "nothing to undo", now); return done(); }
634
636
  if (key === "e" && !row) return done();
637
+ const locked = (key === "e" ? row : s.settingsRows?.find(row => row.key === s.settingsUndo))?.locked;
638
+ if (locked) { notify(s, locked, now); return done(); }
635
639
  const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
636
640
  s.settingsEdit = { token, undo: key === "u", ...(key === "e" ? { key: row.key } : {}), phase: key === "u" ? "preview" : "value", text: key === "e" ? settingValueText(row.fileValue) : "" };
637
641
  if (key === "u") effects.push({ type: "settings_preview", token, undo: true });
@@ -975,6 +979,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
975
979
  if (p.paused) summary.push(span(" paused", "attention"));
976
980
  if (typeof p.importantWaitingSince === "number") summary.push(span(` important waiting ${duration(now - p.importantWaitingSince)}`, "attention")); // #572 AC1: a busy ACP peer that cannot be steered
977
981
  if (p.toolsOnly) summary.push(span(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") ? ` pull-only, read ${typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never"}` : " tools-only: ahub claude", "attention"));
982
+ if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) summary.push(span(` ${flat(p.statusNotice.message)}`, "attention"));
978
983
  }
979
984
  if (s.pendingRestart?.length) summary.push(span(`${summary.length ? " | " : ""}restart pending: ${s.pendingRestart.map(row => row.key).join(", ")}`, "attention"));
980
985
  if (s.retainedConfig?.length) summary.push(span(`${summary.length ? " | " : ""}config retained: ${s.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, "attention"));
@@ -988,7 +993,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
988
993
  const neverPeer = s.confirm?.type === "permission_default" ? s.confirm.peer : s.confirm?.type === "command" && s.confirm.args[0] === "permission" && s.confirm.args[2] === "never-ask" ? s.confirm.args[1] : undefined;
989
994
  const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
990
995
  if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
991
- const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer), "attention")] : summary, columns),
996
+ const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer, undefined, createProjectAdapterCatalog()), "attention")] : summary, columns),
992
997
  fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : approvals, columns),
993
998
  fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
994
999
  const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
@@ -1006,7 +1011,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1006
1011
  const noticeLines = noticeShown && s.petEnabled && petSize(s.petSize, columns, rows) !== "line" ? wrap(`Notice: ${s.notice}`, columns).slice(0, Math.max(0, bodyHeight - 6)).map(text => [span(text, s.noticeTone ?? "failure")]) : [];
1007
1012
  const height = bodyHeight - noticeLines.length;
1008
1013
  const lines: Span[][] = [];
1009
- if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer)], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
1014
+ if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer, undefined, createProjectAdapterCatalog())], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
1010
1015
  else if (s.settingsEdit) {
1011
1016
  const edit = s.settingsEdit;
1012
1017
  const row = s.settingsRows?.find(row => row.key === edit.key);
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
2
3
  import { definitionOf } from "../adapters/registry.ts";
3
4
  // Managed Claude hook transport for turn-free facts, native observations and runtime permissions.
4
5
  // PreToolUse preserves facts additionalContext and adds permission decisions; other phases observe only.
@@ -101,7 +102,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
101
102
  if (import.meta.main) {
102
103
  try {
103
104
  const stateDir = process.env.AGENTHUB_STATE_DIR;
104
- const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? "claude", 2000, process.env.AGENTHUB_HOOK_EVENT) : undefined;
105
+ const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? defaultChannelPeer(undefined), 2000, process.env.AGENTHUB_HOOK_EVENT) : undefined;
105
106
  if (out) console.log(out);
106
107
  } catch {
107
108
  // a hook that fails must not get in the way of the tool call
package/src/cli/help.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ADAPTERS, deriveAdapterLists, type AdapterDefinition } from "../adapters/registry.ts";
1
2
  import { VERSION } from "../version.ts";
2
3
  import { paint, wrap, type Span } from "./console-state.ts";
3
4
 
@@ -26,9 +27,10 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
26
27
  ["ahub sdlc status [task] | explain <task> | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action (person only)"],
27
28
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
28
29
  ["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
29
- ["ahub sdlc plan-review <task> <revision> <reason...> [--review full|delta|none --reviewers N --ci full|focused|reuse]", "record planner review, scope and host-read issue metadata"],
30
+ ["ahub sdlc accept-departure <task> <head> <reason...>", "accept current plan departures on that exact PR head (person only)"],
31
+ ["ahub sdlc plan-review <task> <revision> <reason...> [--review full|delta|none --reviewers N] [--ci full|focused|reuse]", "record planner review, scope and host-read issue metadata"],
30
32
  ["ahub sdlc train report|configure|cut <release-id> [revision] [json]", "plan a release and freeze its approved scope after resource/quota admission"],
31
- ["ahub sdlc release-assign <task> <revision> <release-id|none> <reason>", "assign approved/in-flight work without changing execution"],
33
+ ["ahub sdlc release-assign <task> <revision> <release-id|none> <reason...>", "assign approved/in-flight work without changing execution"],
32
34
  ["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
33
35
  ["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
34
36
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
@@ -48,15 +50,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
48
50
  ["ahub recovery-run <operation-id>", "internal: the detached runner of an upgrade, restart or recovery resume"],
49
51
  ]],
50
52
  ["Agents", [
51
- ["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
52
- ["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
53
- ["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"],
54
- ["ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional Jcode (roster.jcode, default off): its TUI on the hub-owned ACP session, opened once that session is saved; --mode headless for ACP only; native policy unmanaged, no hub sandbox"],
55
- ["ahub agy [--print-command | --dry-run] [args...]", "optional Antigravity CLI 1.4.x TUI (1.4.2 verified) in this terminal (roster.agy, default off): pull-only via hub_inbox, implementer/verifier only; native permissions unmanaged, no hub sandbox"],
56
- ["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]", "start optional Qwen Code 0.25.0 ACP (roster.qwen, default off)"],
57
- ["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"],
58
- ["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
59
- ["ahub claude|codex|kimi|qwen|pi|local --as <instance> [options...]", "start an extra instance of that kind declared under instances in .agenthub/config.local.json"],
53
+ ["ahub <configured-kind> [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]", "start a trusted config.local.json ACP kind (default off roster); native argv is withheld in previews"],
60
54
  ["ahub role list [--group <group>] | show <id>", "read bounded catalog guidance with layer and content hash"],
61
55
  ["ahub role assign <peer> <id> | clear <peer> [<id>]", "person only: add or clear catalog guidance, applied at the next peer start"],
62
56
  ["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
@@ -70,7 +64,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
70
64
  ["ahub permission [<peer> [ask|ask-when-needed|never-ask]] [--yes]", "human only: inspect or set a running peer mode; never-ask requires --yes; config never-ask waits for console y"],
71
65
  ["ahub permit <id> <option>", "answer a permission request shown by tail (\"deny\" cancels)"],
72
66
  ["ahub roster [--json] [--color=auto|always|never]", "human only: every known peer, participation, start mode and retained work"],
73
- ["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff"],
67
+ ["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff; remove also deletes the roster key of an instance no longer declared"],
74
68
  ["ahub settings [list] [--json] | get <key> [--json]", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
75
69
  ["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "human only: change or reload validated settings, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
76
70
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
@@ -93,7 +87,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
93
87
  ["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
94
88
  ["ahub task cancel <id> --reason <text> [--superseded-by <ref>] [--revision N]", "person only: close obsolete work with a reason; retain its history without stopping native work or releasing dependents"],
95
89
  ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned [--created <time>]", "your later verdict on an approved task, kept in the research records (research must be on)"],
96
- ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>]", "give a review verdict on a task"],
90
+ ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>]", "give a review verdict on a task"],
97
91
  ["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
98
92
  ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
99
93
  ["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
@@ -144,7 +138,18 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
144
138
  ["ahub manager", "internal: the dashboard manager process that ahub ui --all starts"],
145
139
  ]],
146
140
  ];
147
- export const HELP: readonly HelpEntry[] = SECTIONS.flatMap(([section, rows]) => rows.map(([usage, description]) => ({ section, usage, description })));
141
+ export function helpEntries(definitions: readonly AdapterDefinition[] = ADAPTERS): readonly HelpEntry[] {
142
+ const byKind = new Map(definitions.map(definition => [definition.kind, definition]));
143
+ const order = deriveAdapterLists(definitions).HELP_ORDER;
144
+ const native = order.flatMap(kind => byKind.get(kind)?.cli.usage ?? []);
145
+ const instanceKinds = order.filter(kind => byKind.get(kind)?.instances !== undefined);
146
+ const instances: readonly [string, string] = [`ahub ${instanceKinds.join("|")} --as <instance> [options...]`, "start an extra instance of that kind declared under instances in .agenthub/config.local.json"];
147
+ return SECTIONS.flatMap(([section, rows]) => {
148
+ const entries: readonly (readonly [string, string])[] = section === "Agents" ? [rows[0]!, ...native, ...(instanceKinds.length ? [instances] : []), ...rows.slice(1)] : rows;
149
+ return entries.map(([usage, description]) => ({ section, usage, description }));
150
+ });
151
+ }
152
+ export const HELP = helpEntries();
148
153
 
149
154
  const COMMAND = /^ahub (-{0,2}[a-z][a-z-]*(?: ?\| ?-{0,2}[a-z][a-z-]*)*)/;
150
155
  /** The command words a usage documents: `ahub pause|resume <peer>` documents pause and resume. */
@@ -158,9 +163,10 @@ const DESCRIPTION = 34;
158
163
  * (80 to 100 columns). A usage too long for that column puts its description on the next line.
159
164
  * With a command, only its entries; an unknown command renders nothing.
160
165
  */
161
- export function renderHelp(columns: number, color: boolean, command?: string): string {
166
+ export function renderHelp(columns: number, color: boolean, command?: string, definitions?: readonly AdapterDefinition[]): string {
162
167
  const width = Math.min(100, Math.max(80, columns || 80));
163
- const entries = command === undefined ? HELP : HELP.filter(entry => helpCommands(entry).includes(command));
168
+ const model = definitions === undefined ? HELP : helpEntries(definitions);
169
+ const entries = command === undefined ? model : model.filter(entry => helpCommands(entry).includes(command));
164
170
  const lines: Span[][] = command === undefined ? wrap(`agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory`, width, 0).map(text => [{ text }]) : [];
165
171
  let section = "";
166
172
  for (const entry of entries) {
@@ -178,3 +184,8 @@ export function renderHelp(columns: number, color: boolean, command?: string): s
178
184
  }
179
185
  return lines.map(line => paint(line, color)).join("\n");
180
186
  }
187
+
188
+ /** Body-free configured command guidance; no native argv or environment values enter help. */
189
+ export function configuredAcpHelp(kind: string): string {
190
+ return `ahub ${kind} [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]\nConfigured ACP runs headless through the hub, with native permissions and NO hub sandbox.\nDeclare it in untracked .agenthub/config.local.json, restart the hub, then enable roster.${kind}.\nPreview withholds native argv and starts nothing. Native/model overrides and --unattended are refused.`;
191
+ }