@staix/agent-hub 0.12.44 → 0.12.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +9 -0
  6. package/docs/agent-notes/adapters.md +58 -44
  7. package/docs/agent-notes/budget.md +1 -1
  8. package/docs/agent-notes/daemon.md +9 -7
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +8 -5
  11. package/docs/agent-notes/tests.md +7 -7
  12. package/docs/operations.md +99 -7
  13. package/docs/quickstart.md +5 -1
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +6 -3
  16. package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
  17. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  18. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  19. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  20. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  21. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  22. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  23. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  24. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  25. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  27. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  28. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  29. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  30. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  35. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  36. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  38. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  39. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  43. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  44. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  47. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  56. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  57. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  59. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  60. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  68. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  70. package/docs/verified.json +19 -16
  71. package/package.json +5 -1
  72. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  73. package/plugins/agent-hub/server.js +9818 -9605
  74. package/src/adapters/adapter-api.ts +2 -0
  75. package/src/adapters/agy/definition.ts +3 -1
  76. package/src/adapters/claude-channel.ts +14 -5
  77. package/src/adapters/codex-appserver.ts +1 -0
  78. package/src/adapters/conformance/acp.ts +114 -0
  79. package/src/adapters/conformance/codex.ts +191 -0
  80. package/src/adapters/conformance/enrollment.ts +43 -0
  81. package/src/adapters/conformance/factories.ts +17 -0
  82. package/src/adapters/conformance/fake-paths.ts +12 -0
  83. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  84. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  85. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  86. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  87. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  88. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  89. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  90. package/src/adapters/conformance/index.ts +11 -0
  91. package/src/adapters/conformance/kit.ts +50 -0
  92. package/src/adapters/conformance/local-pi.ts +100 -0
  93. package/src/adapters/conformance/owned-acp.ts +44 -0
  94. package/src/adapters/definitions/builtin-metadata.ts +23 -6
  95. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  96. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  97. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  98. package/src/adapters/definitions/qwen.ts +4 -1
  99. package/src/adapters/external-metadata.ts +48 -0
  100. package/src/adapters/external-module-scan.ts +126 -0
  101. package/src/adapters/external-modules.ts +240 -0
  102. package/src/adapters/pi.ts +2 -0
  103. package/src/adapters/public.ts +7 -0
  104. package/src/adapters/registry-types.ts +10 -0
  105. package/src/adapters/registry.ts +51 -15
  106. package/src/cli/help.ts +21 -16
  107. package/src/cli/identity.ts +1 -1
  108. package/src/cli/main.ts +23 -5
  109. package/src/cli/upgrade-labels.ts +1 -1
  110. package/src/cli/upgrade-presentation.ts +1 -1
  111. package/src/cli/upgrade-restore-state.ts +3 -2
  112. package/src/cli/upgrade-runtime.ts +4 -1
  113. package/src/hub/board.ts +30 -4
  114. package/src/hub/config-apply.ts +18 -10
  115. package/src/hub/config-reload.ts +8 -5
  116. package/src/hub/config-trust.ts +1 -1
  117. package/src/hub/control-client.ts +4 -1
  118. package/src/hub/daemon.ts +95 -39
  119. package/src/hub/facts.ts +76 -13
  120. package/src/hub/hub-tools.ts +21 -5
  121. package/src/hub/instance-config.ts +17 -5
  122. package/src/hub/instance-execution.ts +20 -8
  123. package/src/hub/instances.ts +2 -1
  124. package/src/hub/peers.ts +9 -0
  125. package/src/hub/plan-conformance.ts +83 -0
  126. package/src/hub/review-findings.ts +104 -0
  127. package/src/hub/roster.ts +31 -5
  128. package/src/hub/sdlc-queue.ts +19 -4
  129. package/src/hub/sdlc-release.ts +16 -1
  130. package/src/hub/sdlc.ts +27 -12
  131. package/src/hub/settings.ts +5 -1
  132. package/src/hub/snapshots.ts +8 -6
  133. package/src/hub/tasks.ts +275 -45
  134. package/src/hub/ui.ts +2 -2
  135. package/src/ui/index.html +559 -78
  136. package/src/ui/office-model.ts +3 -1
  137. package/src/ui/peer-palette.ts +44 -0
@@ -66,8 +66,9 @@ export function decodeRestoreReceipt(value: unknown, options: { authority?: "cur
66
66
  return { state: "done", binding: terminalBinding(value, historical) };
67
67
  }
68
68
 
69
- export function restoreReceiptStatus(value: unknown): RestoreReceiptStatus {
70
- const receipt = decodeRestoreReceipt(value);
69
+ /** The authority is the caller's: a decision reads the current one, a screen that only shows what was recorded reads the historical one (#225). */
70
+ export function restoreReceiptStatus(value: unknown, options: { authority?: "current" | "historical" } = {}): RestoreReceiptStatus {
71
+ const receipt = decodeRestoreReceipt(value, options);
71
72
  return !receipt ? "unstarted" : receipt.state === "waiting" ? `waiting:${receipt.origin}` : receipt.state;
72
73
  }
73
74
  export function canChooseFresh(value: unknown): boolean {
@@ -145,6 +145,7 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
145
145
  /** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
146
146
  export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
147
147
  const bound = readInstanceConfig(projectRoot);
148
+ if (bound.adapter_modules?.length && !(Array.isArray(features) && features.includes("external_adapter_modules"))) return "source lacks external_adapter_modules; restart with a supporting source before recovering module peers";
148
149
  const configured = Object.keys(bound.adapters ?? {});
149
150
  const configuredIds = [...configured, ...Object.entries(bound.instances ?? {}).filter(([, entry]) => configured.includes(entry.kind)).map(([id]) => id)];
150
151
  if (configuredIds.length && !(Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("configured_acp"))) {
@@ -159,7 +160,7 @@ export function instanceRecoverySourceRefusal(projectRoot: string, features: unk
159
160
  if (peers.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") &&
160
161
  !peers.some(([id, peer]) => declared.includes(id) && (peer as { state: string }).state !== "offline")) return undefined;
161
162
  }
162
- return "source lacks peer_instances support for this project's declared instances; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end any extra native sessions and temporarily remove the instance declarations, upgrade the source, then declare them again; legacy peer identities were not reinterpreted";
163
+ return "source lacks peer_instances support and a declared instance of this project may be live; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end the extra native sessions first (a source whose declared instances are all offline proceeds without further steps); only if one cannot be ended, temporarily remove its declaration, upgrade the source, then declare it again; legacy peer identities were not reinterpreted";
163
164
  }
164
165
 
165
166
  export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection> {
@@ -626,6 +627,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
626
627
  return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
627
628
  }
628
629
  if (cached && op.targetFeatures !== undefined) {
630
+ if (!op.targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
629
631
  if (!op.targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
630
632
  if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
631
633
  return { ...target, targetFeatures: [...op.targetFeatures] };
@@ -638,6 +640,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
638
640
  if (!metadata || typeof metadata !== "object" || !Number.isInteger(metadata.protocol)) throw new Error(`the staged target's control protocol could not be read (probe exit ${probe.code}); staged package retained, runtimes unchanged; resume again once it can run`);
639
641
  if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
640
642
  const targetFeatures: readonly string[] = [...metadata.features];
643
+ if (!targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
641
644
  if (!targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
642
645
  if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
643
646
  if (!targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
package/src/hub/board.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { planFingerprint } from "./plan-conformance.ts";
1
2
  import type { ReviewerPoolReason } from "./reviewer-pool.ts";
2
3
  import type { GitHubSource } from "./github-import.ts";
3
4
  import { Database } from "bun:sqlite";
@@ -6,6 +7,7 @@ import { HUB, type PeerId } from "./envelope.ts";
6
7
  import type { SweepRecord } from "./task-sweep.ts";
7
8
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
8
9
  import type { SdlcReceipt } from "./sdlc.ts";
10
+ import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
9
11
  import type { CostPlan, ReviewCost } from "./cost-governance.ts";
10
12
 
11
13
  export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
@@ -28,7 +30,7 @@ export interface TaskRefs {
28
30
  /** What an owner says it will change, before it starts (issue #31). Each list holds short free-text items. */
29
31
  export const PLAN_KEYS = ["paths", "symbols", "signatures", "insertion_points"] as const;
30
32
  export const DOCUMENT_PLAN_KEYS = ["paths", "sections", "sources", "claims"] as const;
31
- export const ALL_PLAN_KEYS = [...PLAN_KEYS, "sections", "sources", "claims"] as const;
33
+ export const ALL_PLAN_KEYS = [...PLAN_KEYS, "sections", "sources", "claims", "proof", "risks"] as const;
32
34
  export type TaskPlan = Partial<Record<(typeof ALL_PLAN_KEYS)[number], string[]>> & CostPlan;
33
35
  /** Task outcomes older than this are pruned: demotion (issue #36) reads one week, seven half-lives. */
34
36
  export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
@@ -61,6 +63,13 @@ export interface HistoryEntry {
61
63
  reviewCost?: ReviewCost;
62
64
  /** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
63
65
  reviewContext?: "fresh" | "continuing";
66
+ /** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
67
+ reviewFindings?: ReviewFindingSet;
68
+ /** Host-authored plan content revision, separate from backlog planningRevision. */
69
+ planRevision?: { revision: number; fingerprint: string };
70
+ planObservation?: { repo: string; pr: number; head: string; revision: number; fingerprint: string };
71
+ planDepartureInvalidated?: true;
72
+ planDeparture?: { generation: string; scope?: string; repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] };
64
73
  reviewerFamily?: string;
65
74
  reviewerReason?: ReviewerPoolReason;
66
75
  reviewBlocked?: true;
@@ -153,6 +162,8 @@ export class Board {
153
162
  private pendingChanges: { task: Task; entry: HistoryEntry }[] | undefined;
154
163
  /** Every recorded change, as it lands (telemetry, issue #40). */
155
164
  onChange?: (task: Task, entry: HistoryEntry) => void;
165
+ /** Host lifecycle policy, installed by Tasks; off projects keep their original history shape. */
166
+ trackPlanRevisions?: () => boolean;
156
167
  private changed(task: Task, entry: HistoryEntry): void {
157
168
  if (this.pendingChanges) { this.pendingChanges.push({ task, entry }); return; }
158
169
  try { this.onChange?.(task, entry); } catch { /* never throw after a board write */ }
@@ -232,7 +243,7 @@ export class Board {
232
243
 
233
244
  propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; profile?: import("./profiles.ts").ProfileName; profileClass?: string; customProfile?: true; role?: string; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[]; backlog?: boolean; external?: GitHubSource }): Task {
234
245
  const now = Date.now();
235
- const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed" }];
246
+ const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed", ...(this.trackPlanRevisions?.() && t.plan && Object.keys(t.plan).length ? { planRevision: { revision: 1, fingerprint: planFingerprint(t.plan) } } : {}) }];
236
247
  const { lastInsertRowid } = this.taskWrite(() => this.db
237
248
  .query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, reserved, signals, history, created, updated, project_profile, task_role, external_repo, external_issue) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
238
249
  .run(t.title, t.detail ?? "", t.class, t.backlog ? "backlog" : "proposed", JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now, t.profile && (t.profile !== "dev" || t.customProfile || t.profileClass !== undefined && t.profileClass !== t.class) ? JSON.stringify({ profile: t.profile, profileClass: t.profileClass ?? t.class }) : null, t.role ?? null, t.external?.repo.toLowerCase() ?? null, t.external?.issue ?? null));
@@ -307,7 +318,7 @@ export class Board {
307
318
 
308
319
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
309
320
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
310
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
321
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
311
322
  const task = this.get(id);
312
323
  if (!task) throw new Error(`no task #${id}`);
313
324
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -323,7 +334,22 @@ export class Board {
323
334
  }
324
335
  if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
325
336
  const reviewMetadata = reviewerHistory(extra);
326
- const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
337
+ let reviewFindings: ReviewFindingSet | undefined;
338
+ if (extra.reviewFindings !== undefined) {
339
+ if (!["approved", "changes_requested"].includes(event) || patch.state !== event) throw new Error("finding metadata requires a saved review verdict");
340
+ const head = extra.reviewFindings.head;
341
+ if (head !== undefined && !/^[a-f0-9]{40}$/.test(head)) throw new Error("finding metadata requires a full reviewed head");
342
+ reviewFindings = { ...(head ? { head } : {}), items: parseReviewFindings(extra.reviewFindings.items, { person: by === "user", maxNits: 64 }) };
343
+ }
344
+ if (extra.planDeparture && (by !== "user" || event !== "plan departure accepted")) throw new Error("plan departure acceptance is person-only");
345
+ const trackPlan = this.trackPlanRevisions?.() === true, hasDeparture = task.history.some(h => h.planDeparture);
346
+ const fingerprint = patch.plan && (trackPlan || hasDeparture) ? planFingerprint(next.plan ?? {}) : undefined;
347
+ const planChanged = fingerprint !== undefined && fingerprint !== planFingerprint(task.plan ?? {});
348
+ const planRevision = trackPlan && planChanged ? { revision: (task.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0) + 1, fingerprint: fingerprint! } : undefined;
349
+ // Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
350
+ const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
351
+ const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
352
+ const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
327
353
  this.taskWrite(() => this.db
328
354
  .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
329
355
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
@@ -1,3 +1,5 @@
1
+ import { adapterModuleDeclarations } from "../adapters/external-modules.ts";
2
+ import { externalMetadataDefinitions, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
1
3
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
2
4
  import type { HubConfig } from "./daemon.ts";
3
5
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
@@ -10,7 +12,7 @@ import { resolveProfile } from "./profiles.ts";
10
12
  import { taskSweepConfig } from "./task-sweep.ts";
11
13
  import { projectionConfig } from "./text-projection.ts";
12
14
  import { validateInstances, type InstancesConfig } from "./instances.ts";
13
- import { peerRosterConfig } from "./roster.ts";
15
+ import { peerRosterConfig, staleInstanceKey } from "./roster.ts";
14
16
  import { CHARACTER_IDS } from "../pet/characters.ts";
15
17
  import { petPalette } from "../pet/config.ts";
16
18
 
@@ -18,9 +20,9 @@ export type ApplyClass = "live" | "next use" | "restart";
18
20
  export interface ApplyRule { applies: ApplyClass; reason?: string }
19
21
  export interface ConfigIdentityScope { catalog: ProjectAdapterCatalog; instances: InstancesConfig }
20
22
  /** Plain captured metadata stays cloneable with ConfigSources; native callbacks are compiled outside it. */
21
- export interface ConfigIdentitySnapshot { adapters?: HubConfig["adapters"]; instances: InstancesConfig }
23
+ export interface ConfigIdentitySnapshot { adapters?: HubConfig["adapters"]; externalAdapters?: readonly ExternalAdapterMetadata[]; instances: InstancesConfig }
22
24
  export function configIdentityScope(snapshot: ConfigIdentitySnapshot): ConfigIdentityScope {
23
- return { catalog: createProjectAdapterCatalog(snapshot.adapters), instances: snapshot.instances };
25
+ return { catalog: createProjectAdapterCatalog(snapshot.adapters, externalMetadataDefinitions(snapshot.externalAdapters)), instances: snapshot.instances };
24
26
  }
25
27
  export interface ConfigChange extends ApplyRule { key: string; before: unknown; after: unknown }
26
28
  export interface ConfigLayer { name: "config.json" | "config.local.json"; raw: Record<string, unknown> }
@@ -134,6 +136,7 @@ const schema: Record<string, Check> = {
134
136
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
135
137
  watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
136
138
  permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
139
+ adapter_modules: v => { adapterModuleDeclarations(v); },
137
140
  adapters: v => { createProjectAdapterCatalog(v); }, instances: v => { validateInstances(v, createProjectAdapterCatalog()); }, pet: validatePetConfig,
138
141
  batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
139
142
  memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
@@ -166,27 +169,28 @@ const schema: Record<string, Check> = {
166
169
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
167
170
  };
168
171
  export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
169
- export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog): asserts raw is Record<string, unknown> {
172
+ export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog, inertModuleRoster = false): asserts raw is Record<string, unknown> {
170
173
  const value = object(raw, "config");
171
- const catalog = value.adapters !== undefined ? createProjectAdapterCatalog(value.adapters) : context ?? createProjectAdapterCatalog();
174
+ const catalog = value.adapters !== undefined ? createProjectAdapterCatalog(value.adapters, context?.externalDefinitions) : context ?? createProjectAdapterCatalog();
172
175
  const instances = validateInstances(value.instances ?? declarations, catalog);
173
176
  block({ ...schema,
174
177
  instances: v => { validateInstances(v, catalog); },
175
178
  permission_modes: v => { permissionDefaults(v, instances, catalog); },
176
- roster: v => { peerRosterConfig(v, instances, catalog); },
179
+ roster: v => { peerRosterConfig(v, instances, catalog, inertModuleRoster); },
177
180
  peers: v => { peerStartConfig(v, instances, catalog); },
178
181
  })(raw, "config");
179
182
  }
180
- export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
183
+ export function validateEffectiveConfig(value: unknown, catalog?: ProjectAdapterCatalog): asserts value is HubConfig {
181
184
  const raw = { ...object(value, "config") };
182
185
  delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
183
- validateRawConfig(raw);
186
+ validateRawConfig(raw, undefined, catalog);
184
187
  const profile = resolveProfile(raw.profile, raw.profile_overrides);
185
188
  for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
186
189
  for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
187
190
  }
188
191
 
189
192
  const restart: Record<string, string> = {
193
+ adapter_modules: "External adapter modules are verified and imported only at daemon startup",
190
194
  adapters: "Configured adapter identity, commands and capability policy are fixed at daemon startup",
191
195
  instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
192
196
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
@@ -214,7 +218,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
214
218
  if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
215
219
  else if (shape?.item) { if (shape.keys && !shape.keys.includes(part) && !(top === "roster" && scopes.some(scope => scope.catalog.definitionOf(part, scope.instances)))) return fail(key, "unknown configuration key"); check = shape.item; }
216
220
  else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
217
- else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances", "adapters"].includes(top)) break;
221
+ else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances", "adapters", "adapter_modules"].includes(top)) break;
218
222
  else return fail(key, "unknown configuration key");
219
223
  }
220
224
  }
@@ -237,7 +241,11 @@ export function configChanges(running: HubConfig, next: HubConfig, scopes: reado
237
241
  if (same(before, after)) return;
238
242
  if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
239
243
  for (const child of new Set([...Object.keys(before), ...Object.keys(after)])) walk(at(before, [child]), at(after, [child]), key ? `${key}.${child}` : child);
240
- } else changes.push({ key, before, after, ...configApply(key, scopes) });
244
+ } else {
245
+ // #225: deleting a roster key whose instance was undeclared is live and needs no identity; adding or setting one stays an unknown key.
246
+ const stale = after === undefined && /^roster\.[^.]+$/.test(key) && scopes.some(scope => staleInstanceKey(key.slice("roster.".length), scope.catalog));
247
+ changes.push({ key, before, after, ...(stale ? { applies: "live" as const } : configApply(key, scopes)) });
248
+ }
241
249
  };
242
250
  const clean = (v: HubConfig) => Object.fromEntries(Object.entries(v).filter(([key]) => !["ignored", "retired", "permission_default_sources"].includes(key)));
243
251
  walk(clean(running), clean(next), ""); return changes.sort((a, b) => a.key.localeCompare(b.key));
@@ -1,4 +1,5 @@
1
1
  import { ADAPTERS, createProjectAdapterCatalog } from "../adapters/registry.ts";
2
+ import { externalMetadataDefinitions, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
2
3
  import { createHash } from "node:crypto";
3
4
  import { readFileSync } from "node:fs";
4
5
  import { join } from "node:path";
@@ -86,7 +87,7 @@ function validateRoutingRaw(value: unknown): void {
86
87
  }
87
88
 
88
89
  /** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
89
- export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing, cwd?: string): ConfigSources {
90
+ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing, cwd?: string, externalAdapters?: readonly ExternalAdapterMetadata[]): ConfigSources {
90
91
  const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
91
92
  const jsonLayers: ConfigLayer[] = [];
92
93
  for (const name of ["config.json", "config.local.json"] as const) {
@@ -103,13 +104,15 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
103
104
  const localRaw = jsonLayers.find(layer => layer.name === "config.local.json")?.raw ?? {};
104
105
  let declared;
105
106
  let identityAdapters: ConfigIdentitySnapshot["adapters"];
107
+ let inertModuleRoster = false;
106
108
  let catalog = createProjectAdapterCatalog();
107
109
  try {
108
110
  if (cwd) {
109
- const accepted = readInstanceConfig(cwd, jsonLayers);
111
+ const accepted = readInstanceConfig(cwd, jsonLayers, externalAdapters === undefined ? undefined : externalMetadataDefinitions(externalAdapters));
110
112
  catalog = accepted.catalog; declared = accepted.instances; identityAdapters = accepted.adapters;
113
+ inertModuleRoster = !!accepted.adapter_modules?.length;
111
114
  } else {
112
- catalog = createProjectAdapterCatalog(localRaw?.adapters);
115
+ catalog = createProjectAdapterCatalog(localRaw?.adapters, externalMetadataDefinitions(externalAdapters));
113
116
  identityAdapters = localRaw?.adapters as ConfigIdentitySnapshot["adapters"];
114
117
  declared = validateInstances(localRaw?.instances, catalog);
115
118
  }
@@ -135,7 +138,7 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
135
138
  const structural = { ...raw };
136
139
  // Declaration provenance was checked before compilation; ignored source data is never re-admitted.
137
140
  delete structural.adapters; delete structural.instances;
138
- validateRawConfig(structural, declared, catalog);
141
+ validateRawConfig(structural, declared, catalog, inertModuleRoster);
139
142
  }
140
143
  catch (error) { throw new Error(`.agenthub/${name}: ${(error as Error).message}`); }
141
144
  }
@@ -153,5 +156,5 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
153
156
  try { routing = parseRouting(base, overlay); }
154
157
  catch { throw new Error(".agenthub/routing.toml / routing.local.toml: invalid effective routing"); }
155
158
  }
156
- return { texts, layers, trustLayers, routing, fingerprint: configFingerprint(texts), identity: { ...(identityAdapters ? { adapters: structuredClone(identityAdapters) } : {}), instances: declared ?? validateInstances(undefined, catalog) } };
159
+ return { texts, layers, trustLayers, routing, fingerprint: configFingerprint(texts), identity: { ...(identityAdapters ? { adapters: structuredClone(identityAdapters) } : {}), instances: declared ?? validateInstances(undefined, catalog), ...(catalog.externalDefinitions.length ? { externalAdapters: catalog.externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName })) } : {}) } };
157
160
  }
@@ -29,7 +29,7 @@ export const MACHINE_LOCAL = [
29
29
  "terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
30
30
  ] as const;
31
31
  /** Legacy exported field list stays compatible; new policies use the complete list below. */
32
- export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "text_projection", "instances"] as const;
32
+ export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
33
33
 
34
34
  /** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
35
35
  export function configRepositoryPresent(cwd: string): boolean {
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { projectContext } from "./project.ts";
4
+ import { externalAdapterMetadata, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
4
5
 
5
6
  export function stateDirFor(cwd: string): string {
6
7
  return projectContext(cwd).stateDir;
@@ -8,7 +9,7 @@ export function stateDirFor(cwd: string): string {
8
9
 
9
10
  /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
10
11
  export const PROTOCOL = 16;
11
- export const DAEMON_FEATURES = ["configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
12
+ export const DAEMON_FEATURES = ["external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
12
13
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
13
14
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
14
15
 
@@ -38,6 +39,7 @@ export interface ControlDescriptor {
38
39
  features?: readonly string[];
39
40
  /** The daemon's accepted native start modes, used by launchers when the file value is retained. */
40
41
  startModes?: Readonly<Record<string, "tui" | "headless">>;
42
+ externalAdapters?: readonly ExternalAdapterMetadata[];
41
43
  /** #226: the daemon's processSignature, written into status.json only (not the status reply); absent before 0.12.21. */
42
44
  pidSignature?: string;
43
45
  }
@@ -54,6 +56,7 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
54
56
  return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
55
57
  instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid, features: Array.isArray(status.features) ? status.features.filter((feature: unknown): feature is string => typeof feature === "string") : [],
56
58
  ...(startModes ? { startModes } : {}),
59
+ ...(status.externalAdapters !== undefined ? { externalAdapters: externalAdapterMetadata(status.externalAdapters) } : {}),
57
60
  ...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
58
61
  } catch {
59
62
  return undefined;