@staix/agent-hub 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +16 -6
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +17 -3
  5. package/docs/agent-notes/budget.md +4 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +14 -9
  8. package/docs/agent-notes/tasks.md +9 -4
  9. package/docs/agent-notes/tests.md +3 -3
  10. package/docs/dogfood.md +9 -4
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +90 -18
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +9 -3
  15. package/docs/sdlc.md +17 -0
  16. package/docs/security.md +37 -10
  17. package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
  18. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  19. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  20. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  21. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  22. package/docs/verified.json +29 -17
  23. package/package.json +1 -1
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +992 -312
  26. package/src/adapters/acp.ts +43 -7
  27. package/src/adapters/claude-channel.ts +26 -4
  28. package/src/adapters/claude-implementer.ts +243 -0
  29. package/src/adapters/claude-print-child.ts +90 -0
  30. package/src/adapters/claude-task-sessions.ts +80 -0
  31. package/src/adapters/compat.ts +4 -0
  32. package/src/adapters/conformance/enrollment.ts +2 -1
  33. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  34. package/src/adapters/definitions/builtin-metadata.ts +14 -2
  35. package/src/adapters/definitions/native-channel-state.ts +1 -1
  36. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  37. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  38. package/src/adapters/definitions/native-factories.ts +8 -0
  39. package/src/adapters/kimi-wire-usage.ts +303 -0
  40. package/src/adapters/registry-types.ts +7 -3
  41. package/src/adapters/registry.ts +14 -2
  42. package/src/adapters/reviewer-print.ts +30 -53
  43. package/src/cli/console-config.ts +123 -0
  44. package/src/cli/console-state.ts +28 -1
  45. package/src/cli/console.ts +25 -1
  46. package/src/cli/facts-hook.ts +9 -5
  47. package/src/cli/help.ts +4 -3
  48. package/src/cli/launch.ts +10 -4
  49. package/src/cli/main.ts +47 -9
  50. package/src/cli/output.ts +28 -0
  51. package/src/cli/reset.ts +1 -1
  52. package/src/cli/settings-edit.ts +89 -0
  53. package/src/cli/terminal-recovery.ts +1 -6
  54. package/src/hub/action-enforce.ts +82 -0
  55. package/src/hub/action-policy.ts +22 -10
  56. package/src/hub/board.ts +26 -2
  57. package/src/hub/budget.ts +5 -0
  58. package/src/hub/bus.ts +5 -5
  59. package/src/hub/claude-launch-proof.ts +33 -0
  60. package/src/hub/config-apply.ts +5 -0
  61. package/src/hub/config-edit-host.ts +173 -0
  62. package/src/hub/config-edit.ts +282 -0
  63. package/src/hub/config-tier.ts +89 -0
  64. package/src/hub/control-client.ts +24 -3
  65. package/src/hub/daemon.ts +368 -59
  66. package/src/hub/deliverable-files.ts +131 -0
  67. package/src/hub/deliverables.ts +125 -0
  68. package/src/hub/delivery-journal.ts +18 -2
  69. package/src/hub/events.ts +3 -1
  70. package/src/hub/hub-tools.ts +19 -3
  71. package/src/hub/instances.ts +7 -4
  72. package/src/hub/kimi-usage.ts +144 -0
  73. package/src/hub/manager.ts +1 -1
  74. package/src/hub/peers.ts +7 -0
  75. package/src/hub/permission-payload.ts +15 -0
  76. package/src/hub/routing.ts +118 -3
  77. package/src/hub/settings.ts +1 -1
  78. package/src/hub/tasks.ts +202 -15
  79. package/src/hub/token-window.ts +33 -0
  80. package/src/hub/usage-rollup.ts +16 -6
  81. package/src/hub/workflow.ts +16 -0
  82. package/src/ui/index.html +115 -1
@@ -24,8 +24,19 @@ export interface PermissionRequest {
24
24
  kind?: string;
25
25
  /** The tool's name alone, when the adapter knows it apart from the title (which can quote the payload). */
26
26
  tool?: string;
27
+ /** An always-ask class call (#636): the host never answers it on its own, not even for an unattended hub. */
28
+ classed?: true;
27
29
  }
28
30
 
31
+ /** What the host's always-ask gate says about one request (#636): the approval text it must show, and a class-budget choice when one applies. */
32
+ export interface ActionGateVerdict {
33
+ title: string;
34
+ /** A person's explicit choice to approve the classes for the rest of the session's budget (`label` names them); never an ordinary allow. */
35
+ budget?: { label: string; approve(): boolean };
36
+ }
37
+ /** The option id of that choice. The agent never sees it: it is answered with the agent's own allow_once. */
38
+ export const ACTION_BUDGET_OPTION = "agent-hub:action-budget";
39
+
29
40
  export interface ACPUsageDiagnostic {
30
41
  source: "usage_update" | "prompt_result";
31
42
  availability: "known" | "unsupported" | "invalid";
@@ -108,6 +119,11 @@ export interface AcpOptions {
108
119
  onPermission?: (req: PermissionRequest, signal?: AbortSignal) => Promise<string | undefined>;
109
120
  /** Tool titles approved once without asking: the hub's own tools, as Codex gets them (issue #72). Exact names only. */
110
121
  autoApprove?: (toolTitle: string) => boolean;
122
+ /**
123
+ * Always-ask (#636): given a request's tool names, kind and arguments, a verdict only when a person must be asked, else undefined (the
124
+ * request then follows the permission mode as before). A verdict beats `autoApprove`, withholds `allow_always` and carries the approval text.
125
+ */
126
+ actionGate?: (call: { names: readonly string[]; kind?: string; input: unknown }, session: string) => ActionGateVerdict | undefined;
111
127
  /** Transient person-only stream; no native update escapes through another observer. */
112
128
  onActivity?: (item: Activity) => void;
113
129
  isPrivateTurn?: () => boolean;
@@ -323,6 +339,9 @@ export class AcpPeer extends BasePeer {
323
339
  }
324
340
 
325
341
  getPermissionMode(): PermissionMode { return this.permissionMode; }
342
+
343
+ /** Whether this peer's permission requests pass through an always-ask gate (#636). */
344
+ get actionGated(): boolean { return this.opts.actionGate !== undefined; }
326
345
  /** Offline does not certify shutdown: retain the original runtime until its owned group is confirmed gone. */
327
346
  get pendingOwnedProcess(): boolean { return this.opts.requireOwnedGroup === true && this.proc !== undefined; }
328
347
  get permissionModeState(): PermissionMode | "unknown" | "unmanaged" {
@@ -662,7 +681,14 @@ export class AcpPeer extends BasePeer {
662
681
  // Configured ACP titles are not trusted MCP identity, even when they spell a hub tool exactly.
663
682
  const identity = this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? undefined
664
683
  : [typeof call.title === "string" ? call.title : undefined, canonical].find((candidate) => candidate !== undefined && this.opts.autoApprove?.(candidate));
665
- if (once && identity) {
684
+ // Always-ask (#636): classed before anything may answer for the person. A call whose arguments the agent did not report is not classed
685
+ // (unknown follows the permission mode); a classed call is never auto-approved, whatever its title says.
686
+ const rawInput = call.rawInput ?? (id === undefined ? undefined : this.toolInputs.get(id) ?? jsonObject(this.toolText.get(id)));
687
+ let gate: ActionGateVerdict | undefined;
688
+ try {
689
+ gate = rawInput === undefined || !this.sessionId ? undefined : this.opts.actionGate?.({ names: [call.title, announced, canonical].filter((name): name is string => typeof name === "string"), ...(kind ? { kind } : {}), input: rawInput }, this.sessionId);
690
+ } catch { gate = { title: "Action approval: the always-ask check failed; approve only if you know what runs" }; } // a failing gate asks, never allows
691
+ if (once && identity && !gate) {
666
692
  this.opts.log?.(`permission auto-approved for ${this.id}: ${identity}`); // the name only: arguments may quote a PII turn
667
693
  this.emitActivity("ask", `${String(msg.id)}: ${activityText(announced ?? call.title)} (automatic)`);
668
694
  this.emitActivity("answered", `${String(msg.id)}: allow_once (automatic)`);
@@ -671,7 +697,7 @@ export class AcpPeer extends BasePeer {
671
697
  // The approver has to see what runs, not only the tool's name ("Bash"). The request itself carries it
672
698
  // for some agents; for the rest it was on the `tool_call` update that announced the call, as rawInput or,
673
699
  // failing that, as streamed argument text that only counts once it is a complete JSON object.
674
- const raw = call.rawInput ?? (id === undefined ? undefined : this.toolInputs.get(id) ?? jsonObject(this.toolText.get(id)));
700
+ const raw = rawInput;
675
701
  const full = raw === undefined ? "" : typeof raw === "string" ? raw : JSON.stringify(raw);
676
702
  // A cut payload hides its tail, and a tail can change what runs: it is marked and buys no session-wide grant.
677
703
  const cut = full.length > 600;
@@ -679,9 +705,12 @@ export class AcpPeer extends BasePeer {
679
705
  // A request titled with the argument JSON is no title at all: display what the call was announced as.
680
706
  const named = typeof call.title === "string" && jsonObject(call.title) === undefined ? call.title : announced;
681
707
  // An unknown payload is never dressed up as a description, and it must not buy a blanket grant.
682
- const title: string = raw === undefined ? `${named ?? "tool call"} (payload not reported by the agent)` : `${named ?? "tool call"}${input}`;
708
+ const title: string = gate ? gate.title : raw === undefined ? `${named ?? "tool call"} (payload not reported by the agent)` : `${named ?? "tool call"}${input}`;
683
709
  this.emitActivity("ask", `${String(msg.id)}: ${activityText(title)}; answer in ahub console`);
684
- const options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => o.kind !== "allow_always" || !this.nativePolicy?.lockedReadOnly && raw !== undefined && !cut);
710
+ let options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => o.kind !== "allow_always" || !gate && !this.nativePolicy?.lockedReadOnly && raw !== undefined && !cut);
711
+ // The class-budget choice is the hub's own option: answered to the agent as its allow_once once the ledger has recorded the grant.
712
+ const budgetOption: PermissionOption | undefined = gate?.budget && once ? { optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" } : undefined;
713
+ if (budgetOption) options = [...options, budgetOption];
685
714
  // Only a bare tool name travels on its own (a desktop notice shows it); anything prose-like stays in the title.
686
715
  const tool = typeof named === "string" && /^[\w.:@-]{1,80}$/.test(named) ? named : undefined;
687
716
  // Waiting for a person is not the agent going silent: keep the watchdog from cancelling the turn meanwhile.
@@ -692,17 +721,24 @@ export class AcpPeer extends BasePeer {
692
721
  let picked: string | undefined;
693
722
  try {
694
723
  const cancelled = new Promise<undefined>(resolve => controller.signal.addEventListener("abort", () => resolve(undefined), { once: true }));
695
- picked = await Promise.race([this.opts.onPermission?.({ peer: this.id, title, options, ...(tool ? { tool } : {}), ...(kind ? { kind } : {}) }, controller.signal).catch(() => undefined), cancelled]);
724
+ picked = await Promise.race([this.opts.onPermission?.({ peer: this.id, title, options, ...(tool ? { tool } : {}), ...(kind ? { kind } : {}), ...(gate ? { classed: true as const } : {}) }, controller.signal).catch(() => undefined), cancelled]);
696
725
  } finally {
697
726
  clearInterval(alive); // also when there is no handler at all
698
727
  this.permissionWaits.delete(controller);
699
728
  }
700
- const valid = !controller.signal.aborted && this.state !== "offline" && turn === this.turn && !this.stopping && options.some((o) => o.optionId === picked);
729
+ let valid = !controller.signal.aborted && this.state !== "offline" && turn === this.turn && !this.stopping && options.some((o) => o.optionId === picked);
730
+ let answer = picked;
731
+ // A class-budget grant is recorded only for a still-current session and policy; if the ledger refuses it, nothing is allowed (fail closed).
732
+ if (valid && budgetOption && picked === budgetOption.optionId) {
733
+ let granted = false;
734
+ try { granted = gate!.budget!.approve(); } catch { /* not granted */ }
735
+ if (granted && once) answer = once.optionId; else valid = false;
736
+ }
701
737
  if (turn === this.turn) this.emitActivity("answered", `${String(msg.id)}: ${valid ? options.find(o => o.optionId === picked)?.kind : "cancelled"}`);
702
738
  this.send({
703
739
  jsonrpc: "2.0",
704
740
  id: msg.id,
705
- result: { outcome: valid ? { outcome: "selected", optionId: picked } : { outcome: "cancelled" } },
741
+ result: { outcome: valid ? { outcome: "selected", optionId: answer } : { outcome: "cancelled" } },
706
742
  });
707
743
  }
708
744
 
@@ -1,3 +1,4 @@
1
+ import { realPath } from "../hub/project.ts";
1
2
  import { readInstanceConfig } from "../hub/instance-config.ts";
2
3
  import { effectiveInstanceRoles } from "../hub/instances.ts";
3
4
  import { activeLifecycle } from "../hub/lifecycle-schema.ts";
@@ -13,7 +14,7 @@ import { readFileSync } from "node:fs";
13
14
  import { join } from "node:path";
14
15
  import { ControlClient, stateDirFor } from "../hub/control-client.ts";
15
16
  import { VERSION } from "../version.ts";
16
- import { ACTIONABLE_MESSAGE_INSTRUCTION, projectConductorTools, projectToolList, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
17
+ import { IMPLEMENTER_HUB_TOOL_NAMES, IMPLEMENTER_PERMISSION_TOOL, ACTIONABLE_MESSAGE_INSTRUCTION, projectConductorTools, projectToolList, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
17
18
  import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
18
19
  import { renderHeldHint } from "../hub/delivery-hints.ts";
19
20
 
@@ -24,6 +25,7 @@ const stateDir = process.env.AGENTHUB_STATE_DIR ?? stateDirFor(process.cwd());
24
25
  const projectRoot = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
25
26
  /** tools mode: the same server, run by Kimi (ACP mcpServers), Codex (mcp_servers override) or Jcode. Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
26
27
  const toolsMode = process.env.AGENTHUB_MODE === "tools";
28
+ const permissionTool = toolsMode && process.env.AGENTHUB_PERMISSION_TOOL === "1";
27
29
  /**
28
30
  * #368: a tools-mode server acts as exactly the peer whose owned launch started it, and the project's Jcode MCP entry names none, so the
29
31
  * id comes from the owned process env alone. With none (a person's own `jcode` run in this project outside the hub) it connects as nobody,
@@ -257,8 +259,14 @@ async function connectLoop(): Promise<void> {
257
259
  let code: number | undefined;
258
260
  let closeReason: string | undefined;
259
261
  try {
262
+ let executionCwd: string | undefined;
263
+ if (!toolsMode) {
264
+ try { executionCwd = realPath(process.cwd()); }
265
+ catch { /* A removed native cwd revokes root evidence, never the messaging connection. */ }
266
+ }
260
267
  const client = await ControlClient.connect(stateDir, { role: toolsMode ? "tools" : "peer", peer: peerId, ...(toolsMode ? {} : { channel }),
261
268
  ...(process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
269
+ ...(executionCwd !== undefined ? { cwd: executionCwd } : {}),
262
270
  ...(!toolsMode && standingBy ? { standby: true } : {}),
263
271
  ...(process.env.AGENTHUB_PROJECT_DIR ? { projectRoot } : {}) });
264
272
  client.onPush = (msg) => {
@@ -333,6 +341,7 @@ async function buildToolList() { return toolListFor(await activeLifecycle(stateD
333
341
  function toolListFor(lifecycle: Awaited<ReturnType<typeof activeLifecycle>>) {
334
342
  return {
335
343
  tools: projectToolList([
344
+ ...(permissionTool ? [IMPLEMENTER_PERMISSION_TOOL] : []),
336
345
  {
337
346
  name: "hub_send",
338
347
  description:
@@ -348,7 +357,7 @@ function toolListFor(lifecycle: Awaited<ReturnType<typeof activeLifecycle>>) {
348
357
  additionalProperties: false,
349
358
  },
350
359
  },
351
- ...(toolsMode
360
+ ...(toolsMode && !permissionTool
352
361
  ? []
353
362
  : [
354
363
  {
@@ -370,7 +379,7 @@ function toolListFor(lifecycle: Awaited<ReturnType<typeof activeLifecycle>>) {
370
379
  ]),
371
380
  ...profileTaskTools(profile, lifecycle?.stages, lifecycle?.criteria).filter(tool => (lifecycle !== undefined || tool.name !== "hub_sdlc_record") && (!deliveryOnly || tool.name !== "hub_delivery_retry")),
372
381
  ...projectConductorTools(lifecycle?.startPeers),
373
- ], lifecycle?.readOnly),
382
+ ], lifecycle?.readOnly).filter(tool => !permissionTool || IMPLEMENTER_HUB_TOOL_NAMES.includes(tool.name)),
374
383
  };
375
384
  }
376
385
 
@@ -383,13 +392,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
383
392
  async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }) {
384
393
  const { name, arguments: args } = req.params;
385
394
  if (unidentified) return { ...text(UNIDENTIFIED), isError: true };
395
+ if (permissionTool && !IMPLEMENTER_HUB_TOOL_NAMES.includes(name)) return { ...text("tool unavailable to this owned implementer child"), isError: true };
396
+ if (name === "hub_permission_request") {
397
+ const deny = (message: string) => text(JSON.stringify({ behavior: "deny", message }));
398
+ if (!permissionTool || !hub) return deny("hub permission unavailable");
399
+ if (typeof args?.tool_name !== "string" || !/^[A-Za-z][A-Za-z0-9_.:/-]{0,127}$/.test(args.tool_name)
400
+ || !args.input || typeof args.input !== "object" || Array.isArray(args.input)
401
+ || typeof args.tool_use_id !== "string" || !args.tool_use_id.length || args.tool_use_id.length > 256) return deny("invalid native permission request");
402
+ try {
403
+ // Keep original arguments only in this native request closure; the host returns a decision, not replacements.
404
+ const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input }, 3_605_000);
405
+ return res.ok === true && res.behavior === "allow" ? text(JSON.stringify({ behavior: "allow", updatedInput: args?.input })) : deny(typeof res.message === "string" ? res.message : "permission denied");
406
+ } catch { return deny("hub permission unavailable"); }
407
+ }
386
408
  if (name === "hub_delivery_done" && channel) {
387
409
  if (!hub) return text(offline());
388
410
  const a = args ?? {};
389
411
  const result = await hub.request({ t: "delivery_complete", deliveryId: a.delivery_id, generation: a.delivery_generation });
390
412
  return text(result.ok ? "delivery completed" : `not completed: ${result.error}`);
391
413
  }
392
- if (name === "hub_inbox" && !toolsMode) {
414
+ if (name === "hub_inbox" && (!toolsMode || permissionTool)) {
393
415
  let remote: string[] = [], notice = "", polling = "", counted = false;
394
416
  if (hub) {
395
417
  try {
@@ -0,0 +1,243 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { BasePeer, type OwnedToolsLaunch } from "../hub/peers.ts";
6
+ import { frame, type Envelope } from "../hub/envelope.ts";
7
+ import { realPath } from "../hub/project.ts";
8
+ import { IMPLEMENTER_HUB_TOOL_NAMES } from "../hub/hub-tools.ts";
9
+ import { claudeStateFile } from "../hub/claude-state.ts";
10
+ import { sessionSettings } from "../cli/launch.ts";
11
+ import { assessVersion, COMPAT } from "./compat.ts";
12
+ import { OwnedPrintChild, PrintChildCleanupError, type OwnedPrintChildOptions } from "./claude-print-child.ts";
13
+ import { ClaudeTaskSessions } from "./claude-task-sessions.ts";
14
+ import type { PermissionMode } from "../hub/permission-mode.ts";
15
+ import type { ReviewerPrintUsage } from "./reviewer-print.ts";
16
+
17
+ export interface ClaudeImplementerOptions {
18
+ cwd: string;
19
+ stateDir: string;
20
+ projectRoot: string;
21
+ instanceId: string;
22
+ server: string;
23
+ factsHook: string;
24
+ systemPrompt: string;
25
+ watchdogMs: number;
26
+ mode?: "ask" | "ask-when-needed";
27
+ /** Exact host-reported transcript evidence only; unknown/read errors retain the recorded UUID. */
28
+ transcriptPersisted?(task: number, sessionId: string): boolean | "unknown";
29
+ /** Host-only lifecycle test seam, never project configuration. */
30
+ stopChild?: OwnedPrintChildOptions["stop"];
31
+ command?: readonly string[];
32
+ /** Host-owned policy and board reads, never native or model-selected authority. */
33
+ admit(env: Envelope): boolean;
34
+ chatTask(): number | undefined;
35
+ onUsage?(usage: ReviewerPrintUsage, task?: number): void;
36
+ notify?(message: string): void;
37
+ /** Test/embedding-only proof port; project configuration cannot select it. */
38
+ prove?(): Promise<() => boolean>;
39
+ }
40
+ export interface ImplementerTurn { sessionId: string; resume: boolean; persistent: boolean }
41
+ export function claudeImplementerArgs(turn: ImplementerTurn, mcp: string, settings: string, prompt: string): string[] {
42
+ return ["-p", "--output-format", "stream-json", "--verbose", turn.resume ? "--resume" : "--session-id", turn.sessionId,
43
+ ...(!turn.persistent ? ["--no-session-persistence"] : []), "--strict-mcp-config", "--mcp-config", mcp, "--setting-sources", "", "--settings", settings,
44
+ "--permission-mode", "default", "--permission-prompts", "host", "--permission-prompt-tool", "mcp__agent-hub__hub_permission_request",
45
+ "--allowedTools", ...IMPLEMENTER_HUB_TOOL_NAMES.map(name => `mcp__agent-hub__${name}`), "--append-system-prompt", prompt];
46
+ }
47
+
48
+ /** A receipt-backed CLI version and a per-start binary fence. Version reads use an isolated, disposable HOME.
49
+ * ponytail: native prompt behavior is certified by the dated deterministic receipt, not re-inferred at startup;
50
+ * the upgrade is another isolated native permission probe before extending COMPAT. */
51
+ export async function proveClaudeImplementer(peer: string, command: readonly string[]): Promise<() => boolean> {
52
+ const binary = Bun.which(command[0]!, { PATH: process.env.PATH }) ?? command[0]!;
53
+ const fingerprint = () => { const file = realPath(binary), stat = lstatSync(file); return [file, stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"); };
54
+ const binding = fingerprint(), home = mkdtempSync(join(tmpdir(), "ahub-claude-proof-"));
55
+ let version = "", timedOut = false;
56
+ const child = new OwnedPrintChild({ peer, command, args: ["--version"], cwd: home, input: "", envMode: "replace",
57
+ env: { PATH: process.env.PATH, HOME: home, CLAUDE_CONFIG_DIR: join(home, ".claude") }, maxFrameBytes: 4096, maxOutputBytes: 4096,
58
+ onLine: line => { version += line; }, onLimit: () => { timedOut = true; void child.stop().catch(() => {}); } });
59
+ const timer = setTimeout(() => { timedOut = true; void child.stop().catch(() => {}); }, 3000);
60
+ try {
61
+ const code = await child.done;
62
+ if (code !== 0 || timedOut) throw new Error("Claude implementer permission proof unavailable");
63
+ const observed = COMPAT["claude-implementer"].parse(version), verdict = assessVersion("claude-implementer", observed);
64
+ if (!verdict.ok) throw new Error(verdict.reason);
65
+ if (fingerprint() !== binding) throw new Error("Claude implementer binary changed during proof");
66
+ return () => { try { return fingerprint() === binding; } catch { return false; } };
67
+ } finally { clearTimeout(timer); await child.stop(); rmSync(home, { recursive: true, force: true }); }
68
+ }
69
+
70
+ /** One owned print child at a time. Idle holds no native process; task continuity is ids-only on disk. */
71
+ export class ClaudeImplementerPeer extends BasePeer {
72
+ readonly hubNative = true;
73
+ private generation = 0;
74
+ private owned: OwnedPrintChild | undefined;
75
+ private running: Promise<void> | undefined;
76
+ private active: OwnedToolsLaunch | undefined;
77
+ private childAbort: AbortController | undefined;
78
+ private abort: AbortController | undefined;
79
+ private cleanup: (() => void) | undefined;
80
+ private binding: (() => boolean) | undefined;
81
+ private command: readonly string[] | undefined;
82
+ private deliveryId: string | undefined;
83
+ private inputReleased = false;
84
+ private sessions: ClaudeTaskSessions;
85
+ private stopping: Promise<void> | undefined;
86
+ private stoppingTarget: "idle" | "offline" | undefined;
87
+ private mode: "ask" | "ask-when-needed";
88
+ constructor(id: string, private readonly options: ClaudeImplementerOptions) {
89
+ super(id, options.watchdogMs);
90
+ if (options.mode !== undefined && options.mode !== "ask" && options.mode !== "ask-when-needed") throw new Error("Claude implementer refuses unsupported permission mode");
91
+ this.mode = options.mode ?? "ask";
92
+ this.sessions = new ClaudeTaskSessions(join(options.stateDir, claudeStateFile("claude-task-sessions", id)));
93
+ }
94
+ get permissionModeState(): "ask" | "ask-when-needed" { return this.mode; }
95
+ async setPermissionMode(mode: PermissionMode): Promise<void> {
96
+ if (mode !== "ask" && mode !== "ask-when-needed") throw new Error("Claude implementer refuses never-ask");
97
+ this.mode = mode; // Native remains default: the host facts gate applies this choice to the current child.
98
+ }
99
+ get sessionGeneration(): string | undefined { return this.active?.generation; }
100
+ ownedTools(launchId: unknown): OwnedToolsLaunch | undefined { return typeof launchId === "string" && this.active?.launchId === launchId && !this.abort?.signal.aborted ? this.active : undefined; }
101
+ keepApprovalAlive(): void { if (this.state === "busy" && this.active) this.touch(); }
102
+ recoveryMetadata(): Record<string, unknown> { return { launch: { kind: "claude-implementer", peer: this.id }, ...(this.active ? { sessionId: this.active.sessionId } : {}) }; }
103
+ async start(): Promise<void> {
104
+ if (this.owned || this.running || this.stopping) throw new Error("Claude implementer cleanup is not complete");
105
+ if (this.state !== "offline") return;
106
+ const generation = ++this.generation;
107
+ const selected = this.options.command ?? ["claude"];
108
+ const command = this.options.prove ? selected : [realPath(Bun.which(selected[0]!, { PATH: process.env.PATH }) ?? selected[0]!), ...selected.slice(1)];
109
+ const binding = await (this.options.prove?.() ?? proveClaudeImplementer(this.id, command));
110
+ if (generation !== this.generation) return;
111
+ this.command = command; this.binding = binding; this.setState("idle");
112
+ }
113
+ async stop(reason = "Claude implementer stopped before settlement"): Promise<void> { return this.cancel(reason, "offline"); }
114
+ protected override onWatchdog(): void { void this.cancel("Claude implementer watchdog timeout", "idle").catch(() => {}); }
115
+ private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string): void {
116
+ const id = this.deliveryId; this.deliveryId = undefined;
117
+ if (id) this.delivery({ id, state, ...(reason ? { reason } : {}) });
118
+ }
119
+ private cancel(reason: string, next: "idle" | "offline"): Promise<void> {
120
+ if (this.stopping) {
121
+ if (next === "offline") this.stoppingTarget = "offline";
122
+ return this.stopping;
123
+ }
124
+ this.stoppingTarget = next;
125
+ ++this.generation; this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; this.abort?.abort(); this.clearWatchdog();
126
+ this.settle(this.inputReleased ? "needs_review" : "failed_safe", reason);
127
+ if (this.owned || this.running) this.setState("busy");
128
+ const work: Promise<void> = Promise.resolve().then(async () => {
129
+ if (this.owned) await this.owned.stop(); // Retain handle/busy if group cleanup fails.
130
+ const running = this.running;
131
+ await running?.catch(() => {});
132
+ if (this.running === running) this.running = undefined;
133
+ this.cleanup?.(); this.cleanup = undefined; this.owned = undefined;
134
+ // An idle callback can synchronously drain the next Bus delivery.
135
+ const target = this.stoppingTarget ?? next;
136
+ if (this.stopping === work) { this.stopping = undefined; this.stoppingTarget = undefined; }
137
+ this.setState(target);
138
+ });
139
+ this.stopping = work;
140
+ void work.finally(() => { if (this.stopping === work) { this.stopping = undefined; this.stoppingTarget = undefined; } }).catch(() => {});
141
+ return work;
142
+ }
143
+ async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
144
+ if (this.state !== "idle" || this.owned || this.running || this.stopping) throw new Error("Claude implementer is not idle");
145
+ if (envs.some(env => env.private)) throw new Error("Claude implementer refuses private delivery");
146
+ const groups: { task?: number; envs: Envelope[] }[] = [];
147
+ for (const env of envs) {
148
+ // Completion/cancellation notices and foreign task refs do not veto ordinary messages in the digest.
149
+ if (!this.options.admit(env)) continue;
150
+ const raw = env.refs?.task, task = raw === undefined ? this.options.chatTask() : Number(raw);
151
+ if (task !== undefined && (!Number.isSafeInteger(task) || task <= 0)) continue;
152
+ let group = groups.find(item => item.task === task);
153
+ if (!group) { group = { task, envs: [] }; groups.push(group); }
154
+ group.envs.push(env);
155
+ }
156
+ const generation = this.generation; this.deliveryId = deliveryId; this.inputReleased = false;
157
+ this.abort = new AbortController(); this.setState("busy");
158
+ if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
159
+ // Register the run identity before any synchronous failure can publish idle.
160
+ const running: Promise<void> = Promise.resolve().then(() => this.run(groups, generation, running));
161
+ this.running = running;
162
+ void running.finally(() => { if (this.running === running) this.running = undefined; }).catch(() => {});
163
+ }
164
+ private async run(groups: { task?: number; envs: Envelope[] }[], generation: number, running: Promise<void>): Promise<void> {
165
+ const current = () => generation === this.generation && !this.abort?.signal.aborted;
166
+ try {
167
+ for (let group of groups) {
168
+ if (!current() || !this.binding?.()) throw new Error("Claude implementer admission changed");
169
+ group = { ...group, envs: group.envs.filter(env => !env.private && this.options.admit(env)) };
170
+ if (!group.envs.length) continue;
171
+ let turn = this.sessions.session(group.task);
172
+ if (turn.resume && group.task !== undefined) {
173
+ let persisted: boolean | "unknown" = "unknown";
174
+ try { persisted = this.options.transcriptPersisted?.(group.task, turn.sessionId) ?? "unknown"; } catch { /* unknown is never missing */ }
175
+ if (persisted === false) {
176
+ this.sessions.forgetMissing(group.task, turn.sessionId); turn = this.sessions.session(group.task);
177
+ this.options.notify?.(`${this.id}: task #${group.task} session fresh; recorded transcript is missing`);
178
+ }
179
+ }
180
+ const launchId = randomUUID(), directory = mkdtempSync(join(this.options.stateDir, "claude-child-")), settings = join(directory, "settings.json");
181
+ this.cleanup = () => rmSync(directory, { recursive: true, force: true });
182
+ writeFileSync(settings, sessionSettings(undefined, { script: this.options.factsHook, stateDir: this.options.stateDir, purpose: "permission" }), { mode: 0o600, flag: "wx" });
183
+ const env = { AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: this.id, AGENTHUB_STATE_DIR: this.options.stateDir, AGENTHUB_PROJECT_DIR: this.options.projectRoot,
184
+ AGENTHUB_INSTANCE_ID: this.options.instanceId, AGENTHUB_LAUNCH_ID: launchId, AGENTHUB_HOOK_PURPOSE: "permission", AGENTHUB_PERMISSION_TOOL: "1" };
185
+ const mcp = JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", this.options.server], env } } });
186
+ let result = false, failed = false;
187
+ const child = new OwnedPrintChild({ peer: this.id, command: this.command!, args: claudeImplementerArgs(turn, mcp, settings, this.options.systemPrompt), cwd: this.options.cwd,
188
+ input: group.envs.map(frame).join("\n\n"), env, current, stop: this.options.stopChild,
189
+ onSpawn: () => {
190
+ try {
191
+ if (!this.binding?.() || group.envs.some(env => env.private || !this.options.admit(env))) throw new Error("Claude implementer admission changed before child start");
192
+ } catch {
193
+ // Fence synchronously before OwnedPrintChild can release stdin; cleanup is asynchronous and owned.
194
+ void this.cancel("Claude implementer admission changed before child start", "idle").catch(() => {});
195
+ return;
196
+ }
197
+ this.inputReleased = true; // OwnedPrintChild releases stdin immediately after this synchronous admission fence.
198
+ this.childAbort = new AbortController();
199
+ this.active = { permissionHook: true, launchId, generation: `${generation}:${launchId}`, sessionId: turn.sessionId, signal: this.childAbort.signal, ...(group.task !== undefined ? { task: group.task } : {}) };
200
+ },
201
+ onLeaderExit: () => { this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; },
202
+ onActivity: () => this.touch(),
203
+ onLimit: () => { void this.cancel("Claude implementer output limit exceeded", "idle").catch(() => {}); },
204
+ onFrame: event => {
205
+ const row = event as { type?: unknown; subtype?: unknown; is_error?: unknown; usage?: ReviewerPrintUsage; session_id?: unknown } | null;
206
+ if (row?.session_id === turn.sessionId && group.task !== undefined) {
207
+ try { if (!this.sessions.markStarted(group.task, turn.sessionId)) throw new Error("session changed"); }
208
+ catch { void this.cancel("Claude implementer session publication failed", "idle").catch(() => {}); return; }
209
+ }
210
+ if (row?.type !== "result" || result) return;
211
+ result = true; failed = row.is_error === true || row.subtype !== "success";
212
+ if (row.usage) this.options.onUsage?.(row.usage, group.task);
213
+ } });
214
+ this.owned = child;
215
+ let code: number | null;
216
+ try { code = await child.done; }
217
+ catch (error) {
218
+ // spawn ENOENT has no owned group. A started child with failed group cleanup must keep its handle.
219
+ if (child.process.pid === undefined) this.owned = undefined;
220
+ throw error;
221
+ }
222
+ this.active = undefined; this.owned = undefined;
223
+ this.cleanup?.(); this.cleanup = undefined;
224
+ if (!current()) return;
225
+ if (code !== 0 || !result || failed) throw new Error("Claude implementer turn failed");
226
+ }
227
+ if (current()) {
228
+ if (this.running === running) this.running = undefined;
229
+ this.settle(this.inputReleased ? "completed" : "failed_safe", this.inputReleased ? undefined : "Claude implementer has no admissible envelopes"); this.setState("idle");
230
+ }
231
+ } catch (error) {
232
+ this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined;
233
+ if (current()) {
234
+ this.settle(this.inputReleased ? "needs_review" : "failed_safe", error instanceof PrintChildCleanupError ? "Claude implementer cleanup incomplete" : "Claude implementer turn failed");
235
+ if (this.owned) this.setState("busy"); else {
236
+ this.cleanup?.(); this.cleanup = undefined;
237
+ if (this.running === running) this.running = undefined;
238
+ this.setState("idle");
239
+ }
240
+ }
241
+ }
242
+ }
243
+ }
@@ -0,0 +1,90 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
3
+
4
+ export class PrintChildCleanupError extends Error {}
5
+ export interface OwnedPrintChildOptions {
6
+ peer: string;
7
+ command: readonly string[];
8
+ args: readonly string[];
9
+ cwd: string;
10
+ env?: NodeJS.ProcessEnv;
11
+ envMode?: "replace";
12
+ input: string;
13
+ /** The peer owns generation and authority; this helper never mints either. */
14
+ current?: () => boolean;
15
+ stop?: (child: ChildProcess) => Promise<void>;
16
+ onSpawn?: () => void;
17
+ onLeaderExit?: () => void;
18
+ onError?: (error: Error) => void;
19
+ /** Bounded plain-text frames, for model-free metadata commands as well as NDJSON. */
20
+ onLine?: (line: string) => void;
21
+ onFrame?: (event: unknown) => void;
22
+ onActivity?: () => void;
23
+ onLimit?: () => void;
24
+ maxFrameBytes?: number;
25
+ maxOutputBytes?: number;
26
+ }
27
+
28
+ /** Shared owned print transport, independent of reviewer or implementer lifecycle and MCP authority. */
29
+ export class OwnedPrintChild {
30
+ readonly process: ChildProcess;
31
+ /** Resolves only after the leader exited and its owned process group is confirmed gone. */
32
+ readonly done: Promise<number | null>;
33
+ private stopping: Promise<void> | undefined;
34
+ private limited = false;
35
+
36
+ constructor(private readonly options: OwnedPrintChildOptions) {
37
+ const [command, ...prefix] = options.command;
38
+ if (!command) throw new Error("print child command is missing");
39
+ const child = this.process = spawn(command, [...prefix, ...options.args], {
40
+ cwd: options.cwd, detached: true,
41
+ env: peerChildEnv(options.peer, options.envMode === "replace" ? options.env : { ...process.env, ...options.env }),
42
+ stdio: ["pipe", "pipe", "ignore"],
43
+ });
44
+ // ponytail: 256 KiB per NDJSON frame and 4 MiB stdout by default; raise only with native receipts.
45
+ const frameLimit = options.maxFrameBytes ?? 256 * 1024, outputLimit = options.maxOutputBytes ?? 4 * 1024 * 1024;
46
+ let buffer = "", outputBytes = 0;
47
+ const current = () => !this.limited && options.current?.() !== false;
48
+ const limit = () => { if (!this.limited) { this.limited = true; options.onLimit?.(); } };
49
+ this.done = new Promise((resolve, reject) => {
50
+ child.once("error", error => { options.onError?.(error); reject(error); });
51
+ child.stdin!.on("error", () => { /* exit, rather than a prompt write, settles the owned child */ });
52
+ child.once("spawn", () => {
53
+ trackGroup(child);
54
+ if (!current()) { child.stdin!.destroy(); return; }
55
+ options.onSpawn?.();
56
+ if (current()) child.stdin!.end(options.input);
57
+ else child.stdin!.destroy();
58
+ });
59
+ child.stdout!.on("data", (chunk: Buffer) => {
60
+ if (!current()) return;
61
+ outputBytes += chunk.length;
62
+ if (outputBytes > outputLimit) return limit();
63
+ buffer += chunk.toString("utf8");
64
+ for (let index; (index = buffer.indexOf("\n")) >= 0;) {
65
+ const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
66
+ if (Buffer.byteLength(line) > frameLimit) return limit();
67
+ try { options.onLine?.(line); } catch { /* a host observer never breaks owned teardown */ }
68
+ if (!current()) return;
69
+ try { options.onFrame?.(JSON.parse(line)); } catch { /* not a stream-json line */ }
70
+ if (!current()) return;
71
+ }
72
+ if (Buffer.byteLength(buffer) > frameLimit) return limit();
73
+ options.onActivity?.();
74
+ });
75
+ child.once("exit", code => {
76
+ options.onLeaderExit?.();
77
+ void this.stop().then(() => resolve(code), error => reject(new PrintChildCleanupError((error as Error).message)));
78
+ });
79
+ });
80
+ }
81
+
82
+ /** Failed stops retain the handle and can be retried by the owning peer; never publish idle on failure. */
83
+ stop(): Promise<void> {
84
+ if (this.stopping) return this.stopping;
85
+ const stopping = Promise.resolve().then(() => this.options.stop ? this.options.stop(this.process) : stopOwnedProcess(this.process, { group: true }));
86
+ this.stopping = stopping;
87
+ void stopping.catch(() => { if (this.stopping === stopping) this.stopping = undefined; });
88
+ return stopping;
89
+ }
90
+ }
@@ -0,0 +1,80 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { atomicPrivateJSON } from "../hub/recovery-store.ts";
5
+
6
+ export interface ClaudeTaskSession {
7
+ sessionId: string;
8
+ resume: boolean;
9
+ persistent: boolean;
10
+ }
11
+ type Entry = { sessionId: string; started: boolean };
12
+ type State = { version: 1; sessions: Record<string, Entry> };
13
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
14
+ const taskKey = (task: number): string => {
15
+ if (!Number.isSafeInteger(task) || task <= 0) throw new Error("invalid Claude task session task id");
16
+ return String(task);
17
+ };
18
+ function readState(file: string): State {
19
+ let text: string;
20
+ try { text = readFileSync(file, "utf8"); }
21
+ catch (error) {
22
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, sessions: {} };
23
+ throw error; // Unknown/unreadable state must never silently reset task continuity.
24
+ }
25
+ const value: unknown = JSON.parse(text);
26
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid Claude task session state");
27
+ const state = value as Record<string, unknown>;
28
+ if (state.version !== 1 || !state.sessions || typeof state.sessions !== "object" || Array.isArray(state.sessions) || Object.keys(state).some(key => key !== "version" && key !== "sessions")) throw new Error("invalid Claude task session state");
29
+ const sessions: Record<string, Entry> = {};
30
+ for (const [key, raw] of Object.entries(state.sessions)) {
31
+ if (taskKey(Number(key)) !== key || !raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid Claude task session entry");
32
+ const entry = raw as Record<string, unknown>;
33
+ if (typeof entry.sessionId !== "string" || !UUID.test(entry.sessionId) || typeof entry.started !== "boolean" || Object.keys(entry).some(key => key !== "sessionId" && key !== "started")) throw new Error("invalid Claude task session entry");
34
+ sessions[key] = { sessionId: entry.sessionId, started: entry.started };
35
+ }
36
+ return { version: 1, sessions };
37
+ }
38
+
39
+ /** One daemon-owned store per peer, at a path supplied through claudeStateFile. Never stores task text. */
40
+ export class ClaudeTaskSessions {
41
+ private state: State;
42
+ constructor(private readonly file: string) { this.state = readState(file); }
43
+
44
+ private write(sessions: Record<string, Entry>): void {
45
+ const next: State = { version: 1, sessions };
46
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
47
+ atomicPrivateJSON(this.file, next);
48
+ this.state = next; // A failed durable write cannot advance the in-memory identity.
49
+ }
50
+
51
+ /** The host resolves task attribution. No unique task means a fresh, unpersisted one-off child. */
52
+ session(task?: number): ClaudeTaskSession {
53
+ if (task === undefined) return { sessionId: randomUUID(), resume: false, persistent: false };
54
+ const key = taskKey(task);
55
+ let entry = this.state.sessions[key];
56
+ if (!entry) {
57
+ entry = { sessionId: randomUUID(), started: false };
58
+ this.write({ ...this.state.sessions, [key]: entry });
59
+ }
60
+ return { sessionId: entry.sessionId, resume: entry.started, persistent: true };
61
+ }
62
+
63
+ /** Called only after a native frame names this exact session; spawn alone is not transcript evidence. */
64
+ markStarted(task: number, expectedSessionId: string): boolean {
65
+ const key = taskKey(task), entry = this.state.sessions[key];
66
+ if (!entry || entry.sessionId !== expectedSessionId) return false;
67
+ if (!entry.started) this.write({ ...this.state.sessions, [key]: { ...entry, started: true } });
68
+ return true;
69
+ }
70
+
71
+ /** Host-only: call solely after transcriptPersisted positively reports a missing transcript, never on a read error. */
72
+ forgetMissing(task: number, expectedSessionId: string): boolean {
73
+ const key = taskKey(task), entry = this.state.sessions[key];
74
+ if (!entry || entry.sessionId !== expectedSessionId) return false;
75
+ const sessions = { ...this.state.sessions };
76
+ delete sessions[key];
77
+ this.write(sessions);
78
+ return true;
79
+ }
80
+ }
@@ -27,6 +27,10 @@ export interface CompatPolicy {
27
27
 
28
28
  const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
29
29
  export const COMPAT = {
30
+ // Receipt: docs/verification/2026-10-23-headless-claude-permissions.md
31
+ "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309"], gate: "contract", probe: ["claude", "--version"],
32
+ parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
33
+ trustNote: "The native prompt-tool allow, deny, MCP error/disconnect and hook-failure fallthrough have an isolated receipt. No dontAsk/bypass fallback exists; another version's permission behavior is unverified." },
30
34
  // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md
31
35
  "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
32
36
  parse: out => plain(out.trim().replace(/^codex-cli /, "")),
@@ -20,7 +20,8 @@ export function assertAcpLegEnrollment(definitions: readonly AdapterDefinition[]
20
20
  }
21
21
  export function definitionIdentities(definitions: readonly AdapterDefinition[]): { id: string; definition: AdapterDefinition }[] {
22
22
  return definitions.flatMap(definition => [{ id: definition.kind, definition },
23
- ...(definition.reviewerVariant ? [{ id: `${definition.kind}:reviewer`, definition: definition.reviewerVariant }] : [])]);
23
+ ...(definition.reviewerVariant ? [{ id: `${definition.kind}:reviewer`, definition: definition.reviewerVariant }] : []),
24
+ ...(definition.headlessVariant ? [{ id: `${definition.kind}:implementer`, definition: definition.headlessVariant }] : [])]);
24
25
  }
25
26
  export function assertEnrollment(definitions: readonly AdapterDefinition[], enrolled: readonly string[], unsupported: Readonly<Record<string, string>>, unsupportedLegs: Readonly<Record<string, string>> = {}, coherence?: (definition: AdapterDefinition) => void): void {
26
27
  assertAcpLegEnrollment(definitions, unsupportedLegs);