@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
@@ -20,7 +20,7 @@ export function operationLines(op: RecoveryOperation, runner: number | "unknown"
20
20
  const held = runner === "unknown" ? "; whether a runner holds it cannot be read" : runner ? `; runner ${runner} is working` : op.phase === "running" ? "; no runner holds it (the last one stopped mid-step)" : "";
21
21
  const lines = [`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, ` ${op.phase} at "${stepLabel(op.step)}", updated ${ago(now - op.updatedAt)} ago${held}`];
22
22
  for (const p of op.projects) {
23
- const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value)}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
23
+ const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value, { authority: "historical" })}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
24
24
  lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
25
25
  }
26
26
  // The error ends with the commands status would print; the menu below offers them instead.
@@ -76,7 +76,7 @@ export function renderOperation(op: RecoveryOperation, runner: number | "unknown
76
76
  if (key.startsWith("restored:")) {
77
77
  let outcome = "recorded outcome unavailable";
78
78
  try {
79
- const state = restoreReceiptStatus(value);
79
+ const state = restoreReceiptStatus(value, { authority: "historical" }); // display only: a revoked declaration must not hide the receipt (#225)
80
80
  outcome = state === "done" ? "session restored" : state === "failed" ? "session restoration needs attention" : state.startsWith("waiting:") ? "waiting for the recorded session to attach" : "session restoration is pending";
81
81
  } catch { /* Malformed historical metadata is unknown, never successful. */ }
82
82
  output.push(...lines(` ${key.slice("restored:".length)}: ${outcome}`, "muted", options));
@@ -1,4 +1,4 @@
1
- import { definitionOf } from "../adapters/registry.ts";
1
+ import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
2
2
  import { readInstanceConfig } from "../hub/instance-config.ts";
3
3
  import type { TerminalBinding } from "./terminal-recovery.ts";
4
4
 
@@ -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 {
@@ -102,7 +103,8 @@ export function restoreTransition(receipt: RestoreReceipt, evidence: RestoreEvid
102
103
  /** Checkable compact documentation, enumerated through the same pure decision function. */
103
104
  export function renderRestoreTransitionTable(): string {
104
105
  const launch = { packageEntrypoint: "table", command: "table", argv: [], env: {} };
105
- const binding: TerminalBinding = { peer: "codex", handle: "selected", incarnationId: "selected", worktreeId: "selected", projectRoot: "table", sessionId: "planned", launch, launchMetadata: launch };
106
+ const examplePeer = ADAPTERS.find(definition => definition.transport === "codex-appserver")!.kind;
107
+ const binding: TerminalBinding = { peer: examplePeer, handle: "selected", incarnationId: "selected", worktreeId: "selected", projectRoot: "table", sessionId: "planned", launch, launchMetadata: launch };
106
108
  const receipts: RestoreReceipt[] = [{ state: "pending" }, { state: "waiting", origin: "pending", binding }, { state: "waiting", origin: "failed", binding }, { state: "done", binding }, { state: "failed" }];
107
109
  const launchers: RestoreEvidence["launcher"][] = ["live", "gone", "unknown", "no-record-yet", "replaced", "older-instance"];
108
110
  const result = (receipt: RestoreReceipt, evidence: RestoreEvidence, unchanged = false) => {
@@ -35,7 +35,7 @@ export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
35
35
  const orcaExecutable = () => process.env.ORCA_CLI_COMMAND || (process.env.ORCA_DEV_REPO_ROOT ? "orca-dev" : process.platform === "linux" && !process.env.ORCA_TERMINAL_HANDLE ? "orca-ide" : "orca");
36
36
 
37
37
  /** Definitions are resolved against the owning project's current trusted declarations, never a global instance map. */
38
- const projectDefinition = (peer: string, projectRoot: string) => definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(projectRoot).instances);
38
+ const projectDefinition = (peer: string, projectRoot: string) => { const builtin = definitionOf(peer); if (builtin) return builtin; const bound = readInstanceConfig(projectRoot); return bound.catalog.definitionOf(peer, bound.instances); };
39
39
 
40
40
  const sessionState = (binding: TerminalBinding): "found" | "missing" | "unknown" => {
41
41
  const state = projectDefinition(binding.peer, binding.projectRoot)?.session.transcriptExists?.(binding);
@@ -144,7 +144,15 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
144
144
 
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
- const declared = Object.keys(readInstanceConfig(projectRoot).instances ?? {});
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";
149
+ const configured = Object.keys(bound.adapters ?? {});
150
+ const configuredIds = [...configured, ...Object.entries(bound.instances ?? {}).filter(([, entry]) => configured.includes(entry.kind)).map(([id]) => id)];
151
+ if (configuredIds.length && !(Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("configured_acp"))) {
152
+ const rows = observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers) ? Object.entries(observedPeers) : undefined;
153
+ if (!rows || !rows.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") || rows.some(([id, peer]) => configuredIds.includes(id) && (peer as { state: string }).state !== "offline")) return "source lacks configured_acp; configured native identities require a supporting source before recovery";
154
+ }
155
+ const declared = Object.keys(bound.instances ?? {});
148
156
  if (!declared.length) return undefined;
149
157
  if (Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("peer_instances")) return undefined;
150
158
  if (observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers)) {
@@ -152,7 +160,7 @@ export function instanceRecoverySourceRefusal(projectRoot: string, features: unk
152
160
  if (peers.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") &&
153
161
  !peers.some(([id, peer]) => declared.includes(id) && (peer as { state: string }).state !== "offline")) return undefined;
154
162
  }
155
- 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";
156
164
  }
157
165
 
158
166
  export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection> {
@@ -178,10 +186,16 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
178
186
  }
179
187
  // A dead pid or a missing project directory is final whatever protocol its manifest names: probing it would read
180
188
  // "unavailable" forever and wedge abort, resume and stop-and-archive (#215).
181
- if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported)) return {
182
- state: base.state, peers: [], blockers: base.state === "stopped" ? snapshotError ? [snapshotError.message] : [] : [base.state === "incompatible" ? "manual-bootstrap-required: source lacks the recovery contract; use its matching CLI" : base.error ?? base.state],
189
+ if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported)) {
190
+ let blockers: string[];
191
+ if (base.state === "stopped") blockers = snapshotError ? [snapshotError.message] : [];
192
+ else if (base.state === "incompatible") blockers = ["manual-bootstrap-required: source lacks the recovery contract; use its matching CLI"];
193
+ else blockers = [base.error ?? base.state];
194
+ return {
195
+ state: base.state, peers: [], blockers,
183
196
  ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}), ...(snapshotError ? { snapshotError } : {}),
184
- };
197
+ };
198
+ }
185
199
  let status = base.status;
186
200
  if (!status && control && sourceProtocol === PROTOCOL) return {
187
201
  state: "unavailable", peers: [], blockers: ["runtime changed during recovery inspection"],
@@ -613,6 +627,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
613
627
  return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
614
628
  }
615
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");
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");
616
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");
617
633
  return { ...target, targetFeatures: [...op.targetFeatures] };
618
634
  }
@@ -624,6 +640,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
624
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`);
625
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");
626
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");
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");
627
645
  if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
628
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");
629
647
  const verified = { ...target, targetFeatures };
@@ -674,7 +692,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
674
692
  // #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
675
693
  const transcript = projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout" ? sessionState(binding) : "found";
676
694
  if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
677
- if (transcript === "missing" && !planned.freshStart?.includes("codex")) {
695
+ if (transcript === "missing" && !planned.freshStart?.includes(binding.peer)) {
678
696
  throw new Error(`${unresumable(binding)}; no terminal was closed. To continue without its conversation, end that Codex session and close its Orca terminal ${binding.handle} first, then resume`);
679
697
  }
680
698
  const idle = await waitForIdle(binding, 600_000, terminalOptions(run));
@@ -1,3 +1,4 @@
1
+ import { nativeRestoreGroup } from "../adapters/definitions/native-identity-defaults.ts";
1
2
  import { definitionOf } from "../adapters/registry.ts";
2
3
  import { readInstanceConfig } from "../hub/instance-config.ts";
3
4
  import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
@@ -18,7 +19,7 @@ import { RecoveryPresentationError, type RecoveryPresentationCause, type Recover
18
19
  const projectDefinition = (peer: string, root?: string) => {
19
20
  const builtin = definitionOf(peer);
20
21
  if (builtin || !root) return builtin;
21
- try { return definitionOf(peer, readInstanceConfig(root).instances); } catch { return undefined; }
22
+ try { const bound = readInstanceConfig(root); return bound.catalog.definitionOf(peer, bound.instances); } catch { return undefined; }
22
23
  };
23
24
 
24
25
 
@@ -894,7 +895,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
894
895
  };
895
896
  await target();
896
897
  attempted = true;
897
- await driver.restore(planned, progress, op, projectDefinition(native, planned.project.root)?.transport === "claude-channel" ? "claude" : "native", save, native);
898
+ await driver.restore(planned, progress, op, nativeRestoreGroup(projectDefinition(native, planned.project.root)?.transport), save, native);
898
899
  // A done receipt is written by the runtime only after authenticated attachment and exact settlement.
899
900
  // Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
900
901
  const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
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;
@@ -15,9 +17,9 @@ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "
15
17
  export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
16
18
  export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
17
19
  export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
18
- export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable"] as const;
20
+ export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes"] as const;
19
21
  export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
20
- export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency"; peer?: string; dependency?: number; code: TaskStallCode; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
22
+ export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency" | "no-writes"; peer?: string; dependency?: number; code: TaskStallCode; turns?: number; acceptance?: number; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
21
23
 
22
24
  export interface TaskRefs {
23
25
  repo?: string;
@@ -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;
@@ -55,10 +57,19 @@ export interface HistoryEntry {
55
57
  sweep?: SweepRecord;
56
58
  /** The PII screen did not clear this entry's note (#198): only the console reads it. */
57
59
  withheld?: true;
60
+ /** Host evidence at done, never inferred from the owner's summary. */
61
+ noWrites?: true;
58
62
  /** Host-recorded expected cost and exact review head, never narrative. */
59
63
  reviewCost?: ReviewCost;
60
64
  /** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
61
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[] };
62
73
  reviewerFamily?: string;
63
74
  reviewerReason?: ReviewerPoolReason;
64
75
  reviewBlocked?: true;
@@ -151,6 +162,8 @@ export class Board {
151
162
  private pendingChanges: { task: Task; entry: HistoryEntry }[] | undefined;
152
163
  /** Every recorded change, as it lands (telemetry, issue #40). */
153
164
  onChange?: (task: Task, entry: HistoryEntry) => void;
165
+ /** Host lifecycle policy, installed by Tasks; off projects keep their original history shape. */
166
+ trackPlanRevisions?: () => boolean;
154
167
  private changed(task: Task, entry: HistoryEntry): void {
155
168
  if (this.pendingChanges) { this.pendingChanges.push({ task, entry }); return; }
156
169
  try { this.onChange?.(task, entry); } catch { /* never throw after a board write */ }
@@ -230,7 +243,7 @@ export class Board {
230
243
 
231
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 {
232
245
  const now = Date.now();
233
- 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) } } : {}) }];
234
247
  const { lastInsertRowid } = this.taskWrite(() => this.db
235
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
236
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));
@@ -287,10 +300,11 @@ export class Board {
287
300
  const value = JSON.parse(row.payload) as TaskStall, cause = value.cause;
288
301
  const peer = (id: unknown) => typeof id === "string" && /^[a-z][a-z0-9_-]{0,63}$/i.test(id);
289
302
  if (Object.keys(value).some(key => !["task", "signature", "replanRevision", "cause", "at"].includes(key)) || !Number.isSafeInteger(value.task) || value.task < 1 || !Number.isSafeInteger(value.replanRevision) || value.replanRevision < 0 || !Number.isFinite(value.at)
290
- || !cause || !["reservation", "no-eligible-peer", "cancelled-dependency"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
303
+ || !cause || !["reservation", "no-eligible-peer", "cancelled-dependency", "no-writes"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
304
+ || cause.kind === "no-writes" && (cause.code !== "no-writes" || !peer(cause.peer) || !Number.isSafeInteger(cause.turns) || cause.turns! < 1 || !Number.isSafeInteger(cause.acceptance) || cause.acceptance! < 0)
291
305
  || cause.peer !== undefined && !peer(cause.peer) || cause.dependency !== undefined && (!Number.isSafeInteger(cause.dependency) || cause.dependency < 1)
292
306
  || cause.refusals !== undefined && (!Array.isArray(cause.refusals) || cause.refusals.length > 64 || cause.refusals.some(row => Object.keys(row).some(key => !["peer", "code"].includes(key)) || !peer(row.peer) || !TASK_STALL_CODES.includes(row.code)))
293
- || Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals"].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
307
+ || Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals", ...(cause.kind === "no-writes" ? ["turns", "acceptance"] : [])].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
294
308
  return [value];
295
309
  } catch { return []; }
296
310
  });
@@ -304,7 +318,7 @@ export class Board {
304
318
 
305
319
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
306
320
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
307
- 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" | "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 {
308
322
  const task = this.get(id);
309
323
  if (!task) throw new Error(`no task #${id}`);
310
324
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -320,7 +334,22 @@ export class Board {
320
334
  }
321
335
  if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
322
336
  const reviewMetadata = reviewerHistory(extra);
323
- 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 } : {}) }];
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 } : {}) }];
324
353
  this.taskWrite(() => this.db
325
354
  .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
326
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));
package/src/hub/budget.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { definitionOf } from "../adapters/registry.ts";
1
+ import { definitionOf, type AdapterDefinition } from "../adapters/registry.ts";
2
2
  import { Database } from "bun:sqlite";
3
3
  import type { PeerId } from "./envelope.ts";
4
4
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
@@ -13,6 +13,10 @@ export interface UsageWindow {
13
13
  windowMins?: number;
14
14
  source: string;
15
15
  }
16
+ /** No declared source is unknown, not a synthetic empty provider reading. */
17
+ export function readNativeQuota(definition: AdapterDefinition | undefined, value: unknown): UsageWindow[] | undefined {
18
+ return definition?.budget?.reader?.read(value);
19
+ }
16
20
  interface Reading extends UsageWindow {
17
21
  at: number;
18
22
  }
@@ -46,6 +50,8 @@ export interface BudgetConfig {
46
50
  export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
47
51
 
48
52
  export interface BudgetHooks {
53
+ /** Startup-bound project resolver; undefined keeps builtin-only compatibility. */
54
+ definitionOf?(peer: PeerId): AdapterDefinition | undefined;
49
55
  /** Stable/idempotent for this Budget lifetime. All lifecycle hooks receive this canonical account id. */
50
56
  account?(peer: PeerId): PeerId;
51
57
  pause(peer: PeerId): void;
@@ -202,7 +208,8 @@ export class Budget {
202
208
  // Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
203
209
  const reporter = peer;
204
210
  peer = this.account(peer);
205
- const adapter = definitionOf(reporter) ?? definitionOf(peer);
211
+ const resolve = this.hooks.definitionOf ?? definitionOf;
212
+ const adapter = resolve(reporter) ?? resolve(peer);
206
213
  if (reporter === "user" || reporter === "hub" || peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
207
214
  // An override ignores soft readings, not a provider that has actually refused another turn.
208
215
  if (hard && this.ignoreUntil.delete(peer)) {
package/src/hub/bus.ts CHANGED
@@ -322,7 +322,7 @@ export class Bus {
322
322
  try {
323
323
  this.deferredHub.delete(env.id);
324
324
  this.publishNow(env);
325
- } catch (error) {
325
+ } catch {
326
326
  // Only publication state is transactional. A live peer's preface, pauses, attempts and
327
327
  // recovery mode are independent state, not something a failed checkpoint may reload.
328
328
  this.deferredHub.clear(); for (const [id, pending] of beforeDeferred) this.deferredHub.set(id, pending);
@@ -673,7 +673,11 @@ export class Bus {
673
673
  this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
674
674
  }
675
675
 
676
- /** Stopped-runtime reset only: audit every deferred recipient without publishing or releasing work. */
676
+ /**
677
+ * Reset only: audit every deferred recipient without publishing or releasing work. The guard below is what it
678
+ * checks: no attached peers, no flush or replay in progress and a readable journal. It cannot see a running
679
+ * daemon, so the caller (`resetRuntime`) must hold the stopped-hub proof; a bare Bus has none.
680
+ */
677
681
  discardDeferredHub(reason: string): string[] {
678
682
  if (this.peers.size || this.flushingHub || this.replayingHub || !this.journal || this.storageError) throw new Error("deferred reset requires a stopped, readable delivery journal");
679
683
  const pending = [...this.deferredHub];
@@ -1,37 +1,2 @@
1
- // No imports: the statusline tee and the CLI load this without the daemon (#450 I3).
2
- const PEER = /^[a-z][a-z0-9-]{0,31}$/;
3
-
4
- /** The peer a Claude process writes for: its AGENTHUB_PEER_ID when well formed, otherwise the builtin claude peer. */
5
- export function claudePeer(value: string | undefined): string {
6
- return value !== undefined && PEER.test(value) ? value : "claude";
7
- }
8
-
9
- /** Per-peer Claude state file names. The builtin claude peer keeps its original names, so nothing migrates. */
10
- export function claudeStateFile(base: "claude-launch" | "claude-session" | "claude-context", peer: string): string {
11
- // Instance ids are always <kind>-<name>; a builtin id has no hyphen.
12
- return peer.includes("-") && PEER.test(peer) ? `${base}.${peer}.json` : `${base}.json`;
13
- }
14
-
15
- /** A Claude peer's own row in terminal-recovery.json for this daemon, never a sibling instance's (row order is arbitrary). */
16
- export function ownTerminalRow(records: unknown, peer: string, projectRoot: string, instanceId: string): { launchId?: unknown } | undefined {
17
- if (!Array.isArray(records)) return undefined;
18
- return records.find((row) => row?.peer === peer && row?.projectRoot === projectRoot && row?.instanceId === instanceId);
19
- }
20
-
21
- /**
22
- * Whether a Claude session's transcript exists: the path the peer's own status line reported for that session, else
23
- * the builtin's default location. An instance may run under its own CLAUDE_CONFIG_DIR, which the hub cannot see:
24
- * without its reported path the answer is unknown, and unknown counts as persisted (it never waives session identity).
25
- */
26
- export function transcriptPersisted(reported: { sessionId?: string; transcriptPath?: string }, sessionId: string, instance: boolean, fallbackPath: string, exists: (path: string) => boolean | "unknown"): boolean {
27
- // Only a positive "missing" (ENOENT) is not persisted; an unreadable path is unknown and keeps the identity strict.
28
- const found = (path: string) => exists(path) !== false;
29
- if (reported.sessionId === sessionId && reported.transcriptPath) return found(reported.transcriptPath);
30
- return instance || found(fallbackPath);
31
- }
32
-
33
- /** lstat evidence: true when present, false only on ENOENT/ENOTDIR, "unknown" for any other failure (EACCES included). */
34
- export function pathEvidence(path: string, lstat: (path: string) => unknown): boolean | "unknown" {
35
- try { lstat(path); return true; }
36
- catch (error) { const code = (error as { code?: string }).code; return code === "ENOENT" || code === "ENOTDIR" ? false : "unknown"; }
37
- }
1
+ // Stable leaf import for released statusline/CLI callers. Native policy belongs to the adapter.
2
+ export { claudePeer, claudeStateFile, ownTerminalRow, transcriptPersisted, pathEvidence } from "../adapters/definitions/native-channel-state.ts";
@@ -1,4 +1,4 @@
1
- import { definitionOf, type KindMap } from "../adapters/registry.ts";
1
+ import { definitionOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
2
2
  import { Database } from "bun:sqlite";
3
3
  import type { Task } from "./board.ts";
4
4
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
@@ -10,6 +10,7 @@ import type { HubEvent } from "./events.ts";
10
10
  import type { SupervisionFeed } from "./supervision.ts";
11
11
  import type { Budget } from "./budget.ts";
12
12
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
13
+ import { supportsHeadlessInstances } from "./instances.ts";
13
14
 
14
15
  /** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
15
16
  export function conductorProgressSink(record: (event: HubEvent) => void, feed: Pick<SupervisionFeed, "milestone">): (event: HubEvent) => void {
@@ -109,14 +110,14 @@ export type ConductorStart = { peer: string; mode: "headless" } | { peer: "claud
109
110
  * What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
110
111
  * only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
111
112
  */
112
- export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap): ConductorStart {
113
+ export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): ConductorStart {
113
114
  const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
114
115
  if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
115
- const adapter = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
116
+ const adapter = typeof peer === "string" ? (adapterCatalog?.definitionOf ?? definitionOf)(peer, instances) : undefined;
116
117
  if (typeof peer !== "string" || !adapter?.start.conductorStartable) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
117
118
  const selected = peer;
118
119
  const extra = adapter.kind !== selected;
119
- if (extra && (!adapter.start.create || !adapter.start.forms.includes("headless") || !["env", "acp-session"].includes(adapter.identity.mcp))) throw refuse(`${peer}: this native transport does not support extra headless instances`);
120
+ if (extra && !supportsHeadlessInstances(adapter)) throw refuse(`${peer}: this native transport does not support extra headless instances`);
120
121
  const own = startMode(selected);
121
122
  if (extra && own !== "headless") throw refuse(`${peer}: extra instances require headless mode in I2`);
122
123
  if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
@@ -156,6 +157,7 @@ export interface ConductEvent {
156
157
  kind: "conduct"; actor: string; action: string; task?: number; peer?: string;
157
158
  }
158
159
  export interface ConductorHooks {
160
+ adapterCatalog?: ProjectAdapterCatalog;
159
161
  roles(): unknown;
160
162
  capabilities(): Record<string, unknown>;
161
163
  status(): ConductorStatusInput;
@@ -236,8 +238,8 @@ export class Conductor {
236
238
  }
237
239
  if (tool === "hub_peer_start") {
238
240
  const instances = this.hooks.instances?.();
239
- const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances);
240
- if (definitionOf(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
241
+ const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances, this.hooks.adapterCatalog);
242
+ if ((this.hooks.adapterCatalog?.definitionOf ?? definitionOf)(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
241
243
  const result = await this.hooks.start(start.peer);
242
244
  const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
243
245
  const already = result && typeof result === "object" && (result as { already?: unknown }).already === true ? { already: true } : {};
@@ -1,7 +1,9 @@
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";
4
- import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
6
+ import { createProjectAdapterCatalog, type ProjectAdapterCatalog, ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
5
7
  import { CLASSES } from "./board.ts";
6
8
  import { permissionDefaults } from "./permission-mode.ts";
7
9
  import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
@@ -10,12 +12,18 @@ 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
 
17
19
  export type ApplyClass = "live" | "next use" | "restart";
18
20
  export interface ApplyRule { applies: ApplyClass; reason?: string }
21
+ export interface ConfigIdentityScope { catalog: ProjectAdapterCatalog; instances: InstancesConfig }
22
+ /** Plain captured metadata stays cloneable with ConfigSources; native callbacks are compiled outside it. */
23
+ export interface ConfigIdentitySnapshot { adapters?: HubConfig["adapters"]; externalAdapters?: readonly ExternalAdapterMetadata[]; instances: InstancesConfig }
24
+ export function configIdentityScope(snapshot: ConfigIdentitySnapshot): ConfigIdentityScope {
25
+ return { catalog: createProjectAdapterCatalog(snapshot.adapters, externalMetadataDefinitions(snapshot.externalAdapters)), instances: snapshot.instances };
26
+ }
19
27
  export interface ConfigChange extends ApplyRule { key: string; before: unknown; after: unknown }
20
28
  export interface ConfigLayer { name: "config.json" | "config.local.json"; raw: Record<string, unknown> }
21
29
  export interface ReloadGuard { key: string; path: readonly string[]; risk: "safe" | "raises"; floor?: string | boolean | number; store: "runtime" | "config" | "routing" }
@@ -128,7 +136,8 @@ const schema: Record<string, Check> = {
128
136
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
129
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,
130
138
  permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
131
- instances: v => { validateInstances(v); }, pet: validatePetConfig,
139
+ adapter_modules: v => { adapterModuleDeclarations(v); },
140
+ adapters: v => { createProjectAdapterCatalog(v); }, instances: v => { validateInstances(v, createProjectAdapterCatalog()); }, pet: validatePetConfig,
132
141
  batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
133
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,
134
143
  short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
@@ -145,7 +154,7 @@ const schema: Record<string, Check> = {
145
154
  mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
146
155
  contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
147
156
  approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
148
- task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
157
+ task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, no_writes_turns: number(0, Number.MAX_SAFE_INTEGER, true), interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
149
158
  checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
150
159
  if (value === undefined) continue;
151
160
  if (key === "timeout_s") timerSeconds(value, `${k}.${key}`);
@@ -160,24 +169,29 @@ const schema: Record<string, Check> = {
160
169
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
161
170
  };
162
171
  export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
163
- export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
172
+ export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog, inertModuleRoster = false): asserts raw is Record<string, unknown> {
164
173
  const value = object(raw, "config");
165
- const instances = validateInstances(value.instances ?? declarations);
166
- if (!Object.keys(instances).length) return block(schema)(raw, "config");
167
- block({ ...schema, permission_modes: v => { permissionDefaults(v, instances); },
168
- roster: v => { peerRosterConfig(v, instances); }, peers: v => { peerStartConfig(v, instances); },
174
+ const catalog = value.adapters !== undefined ? createProjectAdapterCatalog(value.adapters, context?.externalDefinitions) : context ?? createProjectAdapterCatalog();
175
+ const instances = validateInstances(value.instances ?? declarations, catalog);
176
+ block({ ...schema,
177
+ instances: v => { validateInstances(v, catalog); },
178
+ permission_modes: v => { permissionDefaults(v, instances, catalog); },
179
+ roster: v => { peerRosterConfig(v, instances, catalog, inertModuleRoster); },
180
+ peers: v => { peerStartConfig(v, instances, catalog); },
169
181
  })(raw, "config");
170
182
  }
171
- export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
183
+ export function validateEffectiveConfig(value: unknown, catalog?: ProjectAdapterCatalog): asserts value is HubConfig {
172
184
  const raw = { ...object(value, "config") };
173
185
  delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
174
- validateRawConfig(raw);
186
+ validateRawConfig(raw, undefined, catalog);
175
187
  const profile = resolveProfile(raw.profile, raw.profile_overrides);
176
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");
177
189
  for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
178
190
  }
179
191
 
180
192
  const restart: Record<string, string> = {
193
+ adapter_modules: "External adapter modules are verified and imported only at daemon startup",
194
+ adapters: "Configured adapter identity, commands and capability policy are fixed at daemon startup",
181
195
  instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
182
196
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
183
197
  memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
@@ -190,21 +204,21 @@ const restart: Record<string, string> = {
190
204
  const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
191
205
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
192
206
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
193
- export function configApply(key: string): ApplyRule {
207
+ export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
194
208
  const top = key.split(".")[0]!;
195
209
  if (!(Object.hasOwn(schema, top) || top === "permission" || top === "routing")) return fail(key, "unknown configuration key");
196
210
  if (top !== "routing" && top !== "permission" && key.includes(".")) {
197
211
  const special: Record<string, readonly string[]> = {
198
- task_sweep: ["enabled", "auto_reassign", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
212
+ task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
199
213
  checks: ["timeout_s", "runner", ...CLASSES], terminal: ["open"],
200
214
  };
201
215
  let check = schema[top];
202
216
  for (const [index, part] of key.split(".").slice(1).entries()) {
203
217
  const shape = check && shapes.get(check);
204
218
  if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
205
- else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
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; }
206
220
  else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
207
- else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances"].includes(top)) break;
221
+ else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances", "adapters", "adapter_modules"].includes(top)) break;
208
222
  else return fail(key, "unknown configuration key");
209
223
  }
210
224
  }
@@ -219,13 +233,19 @@ export function configApply(key: string): ApplyRule {
219
233
  const canonical = (v: unknown): unknown => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical((v as Record<string, unknown>)[k])])) : v;
220
234
  const same = (a: unknown, b: unknown): boolean => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
221
235
  const at = (raw: unknown, path: readonly string[]): unknown => path.reduce<unknown>((v, k) => v && typeof v === "object" && Object.hasOwn(v, k) ? (v as Record<string, unknown>)[k] : undefined, raw);
222
- export function configChanges(running: HubConfig, next: HubConfig): ConfigChange[] {
236
+ export function configChanges(running: HubConfig, next: HubConfig, scopes: readonly ConfigIdentityScope[]): ConfigChange[] {
223
237
  const changes: ConfigChange[] = [];
238
+ // Scopes are captured/validated by the host. Their union classifies removed/added keys only; it cannot
239
+ // authorize a declaration or turn a restart-retained hybrid into a fresh startup configuration.
224
240
  const walk = (before: unknown, after: unknown, key: string) => {
225
241
  if (same(before, after)) return;
226
242
  if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
227
243
  for (const child of new Set([...Object.keys(before), ...Object.keys(after)])) walk(at(before, [child]), at(after, [child]), key ? `${key}.${child}` : child);
228
- } else changes.push({ key, before, after, ...configApply(key) });
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
+ }
229
249
  };
230
250
  const clean = (v: HubConfig) => Object.fromEntries(Object.entries(v).filter(([key]) => !["ignored", "retired", "permission_default_sources"].includes(key)));
231
251
  walk(clean(running), clean(next), ""); return changes.sort((a, b) => a.key.localeCompare(b.key));